From 4f37cf1e2891adc43bb9e23ac76438222c544910 Mon Sep 17 00:00:00 2001 From: stash Date: Sat, 12 Sep 2026 01:27:48 -0700 Subject: [PATCH 1/9] feat(agent): add dimcode persistent Pi harness and terminal --- .github/workflows/dimcode.yml | 47 + agent/.gitignore | 4 + agent/README.md | 76 + agent/build.mjs | 23 + agent/package-lock.json | 4721 +++++++++++++++++++++++++++ agent/package.json | 41 + agent/skills/dimensional/SKILL.md | 22 + agent/src/config.ts | 57 + agent/src/gateway.ts | 500 +++ agent/src/main.ts | 216 ++ agent/src/mcp.ts | 213 ++ agent/src/media.ts | 91 + agent/src/protocol.ts | 206 ++ agent/src/render.ts | 338 ++ agent/src/service.ts | 104 + agent/src/session.ts | 84 + agent/src/setup.ts | 108 + agent/src/terminal.ts | 546 ++++ agent/test/fixtures/mcp_server.py | 50 + agent/test/fixtures/session.ts | 134 + agent/test/gateway.test.ts | 168 + agent/test/integration.test.ts | 208 ++ agent/test/lifecycle.test.ts | 94 + agent/test/media.test.ts | 105 + agent/test/render.test.ts | 48 + agent/test/run.mjs | 29 + agent/tsconfig.json | 34 + dimos/agents/mcp/mcp_server.py | 36 +- dimos/agents/mcp/test_mcp_server.py | 18 +- dimos/cli/commands/lifecycle.py | 24 +- dimos/core/run_registry.py | 7 + dimos/core/test_daemon.py | 36 + 32 files changed, 8373 insertions(+), 15 deletions(-) create mode 100644 .github/workflows/dimcode.yml create mode 100644 agent/.gitignore create mode 100644 agent/README.md create mode 100644 agent/build.mjs create mode 100644 agent/package-lock.json create mode 100644 agent/package.json create mode 100644 agent/skills/dimensional/SKILL.md create mode 100644 agent/src/config.ts create mode 100644 agent/src/gateway.ts create mode 100644 agent/src/main.ts create mode 100644 agent/src/mcp.ts create mode 100644 agent/src/media.ts create mode 100644 agent/src/protocol.ts create mode 100644 agent/src/render.ts create mode 100644 agent/src/service.ts create mode 100644 agent/src/session.ts create mode 100644 agent/src/setup.ts create mode 100644 agent/src/terminal.ts create mode 100644 agent/test/fixtures/mcp_server.py create mode 100644 agent/test/fixtures/session.ts create mode 100644 agent/test/gateway.test.ts create mode 100644 agent/test/integration.test.ts create mode 100644 agent/test/lifecycle.test.ts create mode 100644 agent/test/media.test.ts create mode 100644 agent/test/render.test.ts create mode 100644 agent/test/run.mjs create mode 100644 agent/tsconfig.json diff --git a/.github/workflows/dimcode.yml b/.github/workflows/dimcode.yml new file mode 100644 index 00000000000..84ae1c45cd4 --- /dev/null +++ b/.github/workflows/dimcode.yml @@ -0,0 +1,47 @@ +name: dimcode + +on: + pull_request: + paths: + - 'agent/**' + - 'web/sdk/**' + - 'web/shared/**' + - 'web/relay/**' + - '.github/workflows/dimcode.yml' + push: + branches: [main] + paths: + - 'agent/**' + - 'web/sdk/**' + - 'web/shared/**' + - 'web/relay/**' + - '.github/workflows/dimcode.yml' + +permissions: {} + +jobs: + check: + runs-on: ubuntu-22.04 + timeout-minutes: 15 + permissions: + contents: read + defaults: + run: + working-directory: agent + steps: + - uses: actions/checkout@v7 + with: + lfs: false + - uses: actions/setup-node@v7 + with: + node-version: 24 + cache: npm + cache-dependency-path: agent/package-lock.json + - uses: denoland/setup-deno@v2 + with: + deno-version: v2.6.10 + - run: npm ci + - run: npm run check + env: + DIMCODE_TEST_DENO: deno + - run: npm pack diff --git a/agent/.gitignore b/agent/.gitignore new file mode 100644 index 00000000000..b5fab8d947a --- /dev/null +++ b/agent/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +*.tgz +.test/ diff --git a/agent/README.md b/agent/README.md new file mode 100644 index 00000000000..b9f172ce6e7 --- /dev/null +++ b/agent/README.md @@ -0,0 +1,76 @@ +# dimcode + +Persistent terminal agent for DimOS, built on Pi. The gateway runs independently of robot blueprints. Existing MCP exposes skills; Pi's coding tools use the existing DimOS CLI/Python APIs. Media uses the existing Web SDK only while a renderer needs it. + +## Install + +Requires Node 24 or 26. From this checkout: + +```sh +cd agent +npm ci +npm run check +npm pack +npm install -g ./dimensional-dimcode-0.1.0.tgz +dimcode setup --provider openai +dimcode +``` + +The package name is `@dimensional/dimcode`; npm publication is separate from building/installing the tarball. DimOS is optional at harness startup. To install its base package in a **new** environment: + +```sh +dimcode install-dimos /path/to/new/venv +``` + +Use DimOS's existing installation instructions for robot extras/system packages. Existing installations and editable checkouts can be selected with `dimcode python /path/to/python` and `dimcode dimos /path/to/dimos`. Executable paths are local configuration, and each session has its own workspace. + +## Authentication and service + +API keys are entered outside chat and stored by Pi in the user-only auth file. An environment key can be installed with `dimcode setup --provider openai --key-env OPENAI_API_KEY`. ChatGPT subscription login uses `--provider openai-codex --oauth`. Use Anthropic API keys for Claude in dimcode. Refresh/logout use Pi's provider implementation. + +```sh +dimcode service install +dimcode service status +dimcode service uninstall +``` + +This is a Linux **user** service. Without it, first attach starts a detached gateway. `dimcode stop` stops that gateway; `dimcode gateway` runs it in the foreground for diagnostics. `dimcode --foreground` runs gateway and terminal in one foreground process. + +Config/auth live in `$XDG_CONFIG_HOME/dimcode` (or `~/.config/dimcode`), sessions in `$XDG_STATE_HOME/dimcode`, previews in `$XDG_CACHE_HOME/dimcode`. `DIMCODE_HOME` overrides the config directory. The runtime socket is user-only. Service removal preserves sessions, credentials and DimOS data. + +## Connect and work + +```sh +dimcode connect go2 http://127.0.0.1:9990/mcp +dimcode relay http://127.0.0.1:7780 my-robot +dimcode --cwd /path/to/app +dimcode sessions +dimcode --session SESSION_ID +dimcode --session SESSION_ID --view +dimcode run "inspect the app and explain its blueprint" +``` + +Endpoints are explicit; ports are examples, not instance identities. Use `/reload` after changing endpoints. Every advertised skill is registered with its original schema, metadata and remote routing. Tool names are bounded and collision-resistant. There is no static copy of the robot's tools or a parallel lifecycle service. + +Terminal commands: `/new`, `/sessions`, `/resume ID`, `/models`, `/model PROVIDER MODEL`, `/login PROVIDER [oauth]`, `/logout PROVIDER`, `/abort`, `/steer TEXT`, `/follow TEXT`, `/reload`, `/image PATH`, `/expand`, `/exit`. Ctrl-C detaches. One terminal owns input; other viewers may observe. Detaching keeps the turn running. Restart restores Pi history and never automatically replays external actions. + +Pi owns context loading, skills, compaction, models and coding-tool behavior. Workspace instructions and configured Pi extensions load normally. Attached terminals support serialized dialogs/notifications; executable extension UI factories belong in the terminal renderer and cannot be sent through a socket. + +## Tool rendering + +`dimcode_render` displays existing image, point-cloud or numeric-series exports and returns the selected PNG to the model. Point clouds use XYZ rows; series use timestamp/value pairs. See the bundled Dimensional skill for formats. The original file, SHA-256, selection metadata and display decimation remain explicit. The renderer never repeats a memory query. + +Live tools select an existing relay/robot/channel. The terminal receives frames directly through the Web SDK and coalesces drawing to 10 Hz. The gateway retains one final snapshot for model context. Closing/cancelling the tool releases consumers; the last consumer closes the connection. MediaPool is generic over decoded SDK slots and accepts existing decoder registries. The initial live image renderer handles JPEG; other channel types use their owning decoder/renderer or saved exports. + +Graphics use Pi terminal-image support with text fallback. Derived PNGs have a bounded 128 MiB cache; original DimOS recordings stay with DimOS. No recording, raw continuous video, new transport protocol or new DimOS gateway is introduced. + +## Validation + +```sh +npm run check +DIMCODE_TEST_PYTHON=/path/to/dimos/python DIMCODE_TEST_DENO=deno npm test +``` + +The second command adds actual DimOS MCP-handler and Node-to-Deno QUIC integration tests. Other tests cover session ownership/detach/recovery, duplicate prompts, renderer provenance and lazy media disposal. Fixture tests do not claim a physical-robot run. + +Use `dimos --replay run unitree-go2 --daemon` for standard recorded-data validation. External apps are selected as `distribution-name.blueprint-name`. Lifecycle commands in this change support exact `--run ID` targeting. A Python-launched coordinator can instead be inspected with existing public Python APIs. diff --git a/agent/build.mjs b/agent/build.mjs new file mode 100644 index 00000000000..89ddb0e342b --- /dev/null +++ b/agent/build.mjs @@ -0,0 +1,23 @@ +import { build } from "esbuild"; +import { chmod, mkdir, rm } from "node:fs/promises"; +import { resolve } from "node:path"; + +await rm("dist", { recursive: true, force: true }); +await mkdir("dist", { recursive: true }); +await build({ + entryPoints: ["src/main.ts"], + outdir: "dist", + bundle: true, + platform: "node", + target: "node24", + format: "esm", + packages: "external", + sourcemap: true, + alias: { + "@dimos/sdk": resolve("../web/sdk/src/index.ts"), + "@dimos/shared/manifest": resolve("../web/shared/manifest.ts"), + "@dimos/shared": resolve("../web/shared/protocol.ts"), + }, + banner: { js: "#!/usr/bin/env node" }, +}); +await chmod("dist/main.js", 0o755); diff --git a/agent/package-lock.json b/agent/package-lock.json new file mode 100644 index 00000000000..95d6d21c5ba --- /dev/null +++ b/agent/package-lock.json @@ -0,0 +1,4721 @@ +{ + "name": "@dimensional/dimcode", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@dimensional/dimcode", + "version": "0.1.0", + "license": "Apache-2.0", + "dependencies": { + "@earendil-works/pi-ai": "0.85.1", + "@earendil-works/pi-coding-agent": "0.85.1", + "@earendil-works/pi-tui": "0.85.1", + "@modelcontextprotocol/client": "2.0.0", + "rwebtransport": "0.2.2", + "sharp": "0.35.4", + "typebox": "1.3.7", + "zod": "^4.1.0" + }, + "bin": { + "dimcode": "dist/main.js" + }, + "devDependencies": { + "@types/node": "^24.3.0", + "esbuild": "^0.25.0", + "prettier": "^3.6.0", + "typescript": "5.9.3" + }, + "engines": { + "node": ">=24 <25 || >=26 <27" + } + }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.123.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.123.0.tgz", + "integrity": "sha512-Y9oX9mPNGZClHQOFqrWRk43Srcu/UHuPq3rfxxOq7JgW0gi+lJA2MAOK4Ul3k/+AUrwRWFJvd0tK3oC0Pw25dw==", + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1", + "standardwebhooks": "^1.0.0" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/client-bedrock-runtime": { + "version": "3.1048.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1048.0.tgz", + "integrity": "sha512-u+NT61JZEkRFtpL0CAw1N1dwxnaLgwVXQl/zjJxTGgLyS/jTIdg2SdoEoCTHxgDyCnqa1HEi9QOoE9/pYRNpOQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/credential-provider-node": "^3.972.42", + "@aws-sdk/eventstream-handler-node": "^3.972.16", + "@aws-sdk/middleware-eventstream": "^3.972.12", + "@aws-sdk/middleware-websocket": "^3.972.19", + "@aws-sdk/token-providers": "3.1048.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/node-http-handler": "^4.7.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.978.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.978.0.tgz", + "integrity": "sha512-2yX9LUmxPklVjSGTb8dfnWRJSiFQ3TeH2nn7G1mdKHTfnabzF0+gfrS8rYfLWmZrQ8A3mEcxMJjRc51dL5KWaA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.5", + "@aws-sdk/xml-builder": "^3.972.40", + "@aws/lambda-invoke-store": "^0.3.0", + "@smithy/core": "^3.33.3", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.17.2", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.71", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.71.tgz", + "integrity": "sha512-JN+JHruYZw3GUZB8YGAlDk4wTDPOEAEEdEzj5nS0xodWR4smzHsN7PnK2j6IeOsDIj2aqua5DSbhXl9Gtf90FQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.0", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.73", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.73.tgz", + "integrity": "sha512-uyYYnJOnlis8uQzaYGPd7N1JoioCoNpXgnkXYixsWJXHXgXyYi8WXJSDfofxJeWfQIGWLe2Nwyq60Uc7MZdVOg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.0", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/node-http-handler": "^4.11.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http/node_modules/@smithy/node-http-handler": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.12.1.tgz", + "integrity": "sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.18.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.973.16", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.16.tgz", + "integrity": "sha512-i++ly+0Uxa+u3ebSSyr0S/3CFhFJDxCXT3+Zj+mW2bXenEx5bKGCdTIKFu39SgXBNhWDjex/8cXUx9MUTMCrTw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.0", + "@aws-sdk/credential-provider-env": "^3.972.71", + "@aws-sdk/credential-provider-http": "^3.972.73", + "@aws-sdk/credential-provider-login": "^3.972.78", + "@aws-sdk/credential-provider-process": "^3.972.71", + "@aws-sdk/credential-provider-sso": "^3.973.15", + "@aws-sdk/credential-provider-web-identity": "^3.972.77", + "@aws-sdk/nested-clients": "^3.997.45", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.78", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.78.tgz", + "integrity": "sha512-eUtswnXu0+Ii9ieRK+0L7aPFV3Z/dnW2VntJzjBP9xs8s+8p5nBNuymIXtXwZ+5r5+XJP3e32nMkuZ/r0HozEA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.0", + "@aws-sdk/nested-clients": "^3.997.45", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.83", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.83.tgz", + "integrity": "sha512-jdso7ejzfRnatxMUZK4S/U6KbaDPCvfIV4XL+IQAPFDBt5rj5Fq595euqlK8Le4lNCMFR9oUpt+1l0aMgaayOQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.71", + "@aws-sdk/credential-provider-http": "^3.972.73", + "@aws-sdk/credential-provider-ini": "^3.973.16", + "@aws-sdk/credential-provider-process": "^3.972.71", + "@aws-sdk/credential-provider-sso": "^3.973.15", + "@aws-sdk/credential-provider-web-identity": "^3.972.77", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.71", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.71.tgz", + "integrity": "sha512-lYmXJa4gvq4xN1lrT5NiP5vIYYKcGWAdj8y+8o6dlcateB5eF3Dn8DtmjjHKfMBrTPAMr2pebIiX/UOj8c1/UA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.0", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.973.15", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.15.tgz", + "integrity": "sha512-6Jhcf4v0pSFdjk1EW2kvzuEBKD+UZ2uNcHUIglKKLndD20YhvkL2kdmDOV5/j4mYuWWwe/a1FQ1aomU86/Cg5Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.0", + "@aws-sdk/nested-clients": "^3.997.45", + "@aws-sdk/token-providers": "3.1129.0", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers": { + "version": "3.1129.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1129.0.tgz", + "integrity": "sha512-Sbl3rpzQdsG4ZK2zh0JWUYyZPKKorJlVOddA2T0DVbKJFrsW8J6wgnslxxUH04+WaBMr4A1HzJZvZX0xUvkniA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.0", + "@aws-sdk/nested-clients": "^3.997.45", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.77", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.77.tgz", + "integrity": "sha512-uylIQSUWpfLuH2LovxEEfwzJGM/SabLOfLMg6YXu/E8jJEKUdpdILCVCQCdFvHyu/7dLJOHPMfrSwduxO56NkQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.0", + "@aws-sdk/nested-clients": "^3.997.45", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/eventstream-handler-node": { + "version": "3.972.34", + "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.34.tgz", + "integrity": "sha512-cTeVzpu1xEAkryTZBYhGwnQ6gOGyp8ZYZvmn0Sg/nI/ABmy/CRHHxPDJDUi9PxwxUtGGaatvfRUB3FCgT/rSWw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-eventstream": { + "version": "3.972.29", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.29.tgz", + "integrity": "sha512-dlRzHCgyB8W6hLuDC5pcT5q+ziPt00n4QGgGBE17ucLVU4zMa6lsbuUdQ2Pm75Z5VA8GF+R/+SgrRcaTdIzSIQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-websocket": { + "version": "3.972.53", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.53.tgz", + "integrity": "sha512-bIrDaMENQmYRBHntOiOheqkiw5+fhKW4Lqb+mS1uqF0VwvdWI22fW2HFgWrng66CmYd+4k8ePlpj38sEfTuMLQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.0", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.997.45", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.45.tgz", + "integrity": "sha512-mooq9Q+jLa18VoM7HouczmslZU60iiB0aKc/Ztnq/luIL1ud0z4DnYprLR/ZO1gp331S9tJctM1HZr7u6YKBXQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.0", + "@aws-sdk/signature-v4-multi-region": "^3.996.46", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/node-http-handler": "^4.11.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients/node_modules/@smithy/node-http-handler": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.12.1.tgz", + "integrity": "sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.18.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.46", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.46.tgz", + "integrity": "sha512-L+2xZTye/2T96f3lwCws0Zw6GG2JHZW9e8FpVgGBeeExSKyeoZ6CWRpBml/7DNiK/O26jrgPM9F+Ay8VkgzUWQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.5", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1048.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1048.0.tgz", + "integrity": "sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.974.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.5.tgz", + "integrity": "sha512-LkwLL2BLbC6wNNm4JaH9mbEqBMdOZCct6VAYqhdN4U1xrWM+fUJQEfbHwQgDypapOWTRtlk25akb5afM0P8CIQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-locate-window": { + "version": "3.965.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.10.tgz", + "integrity": "sha512-ycwH6Zd2GhuSqdXX9ihbCjeGTB6xOJs+O3+Jb8/zDG9978XU80qs75dfkPJRMNKe5MvBZPuNeFpd4JZKPoUF4g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.40", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.40.tgz", + "integrity": "sha512-wlFmCIGUlwF4zx/kncw+bmxTQh1HeSJq4mYV/V5cZUSJadDP3kXvGW8Rn21cimj/7y9ju+47oYWXi97vF7czaA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz", + "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@earendil-works/pi-ai": { + "version": "0.85.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.85.1.tgz", + "integrity": "sha512-+VgVIJDkDO2efYJKEEqvPTH4zmnIaXdAppGbO+vKFA9qy5PdhFiAenuFAkU+oiCSfOC4dMHDyrjdQeL4ZoC5CQ==", + "license": "MIT", + "dependencies": { + "@anthropic-ai/sdk": "0.123.0", + "@aws-sdk/client-bedrock-runtime": "3.1048.0", + "@earendil-works/pi-telemetry": "^0.85.1", + "@google/genai": "1.52.0", + "@smithy/node-http-handler": "4.7.3", + "http-proxy-agent": "7.0.2", + "https-proxy-agent": "7.0.6", + "openai": "6.40.0", + "partial-json": "0.1.7", + "typebox": "1.3.7" + }, + "bin": { + "pi-ai": "dist/cli.js" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent": { + "version": "0.85.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.85.1.tgz", + "integrity": "sha512-FGRN+OHbWaefBPGaTggAdLjrIHW+s2PzLyglz/5dfLzb9of7uuXMXYC0fJIeZTw+shS32o2cuQ9jF7YSDuL/oQ==", + "hasShrinkwrap": true, + "license": "MIT", + "dependencies": { + "@earendil-works/chord": "^0.85.1", + "@earendil-works/pi-agent-core": "^0.85.1", + "@earendil-works/pi-ai": "^0.85.1", + "@earendil-works/pi-tui": "^0.85.1", + "@silvia-odwyer/photon-node": "0.3.4", + "chalk": "5.6.2", + "cross-spawn": "7.0.6", + "diff": "8.0.4", + "grok-mermaid": "0.2.2", + "highlight.js": "10.7.3", + "hosted-git-info": "9.0.3", + "ignore": "7.0.5", + "jiti": "2.7.0", + "minimatch": "10.2.5", + "proper-lockfile": "4.1.2", + "semver": "7.8.0", + "typebox": "1.3.7", + "undici": "8.9.0", + "yaml": "2.9.0" + }, + "bin": { + "pi": "dist/bundle/cli.js" + }, + "engines": { + "node": ">=22.19.0" + }, + "optionalDependencies": { + "@mariozechner/clipboard": "0.3.9" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@anthropic-ai/sdk": { + "version": "0.123.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.123.0.tgz", + "integrity": "sha512-Y9oX9mPNGZClHQOFqrWRk43Srcu/UHuPq3rfxxOq7JgW0gi+lJA2MAOK4Ul3k/+AUrwRWFJvd0tK3oC0Pw25dw==", + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1", + "standardwebhooks": "^1.0.0" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-crypto/crc32": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", + "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/client-bedrock-runtime": { + "version": "3.1048.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1048.0.tgz", + "integrity": "sha512-u+NT61JZEkRFtpL0CAw1N1dwxnaLgwVXQl/zjJxTGgLyS/jTIdg2SdoEoCTHxgDyCnqa1HEi9QOoE9/pYRNpOQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/credential-provider-node": "^3.972.42", + "@aws-sdk/eventstream-handler-node": "^3.972.16", + "@aws-sdk/middleware-eventstream": "^3.972.12", + "@aws-sdk/middleware-websocket": "^3.972.19", + "@aws-sdk/token-providers": "3.1048.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/node-http-handler": "^4.7.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/core": { + "version": "3.974.11", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.11.tgz", + "integrity": "sha512-QpnINq5FZH6EOaDEkmHdT7eUunbvD27pDNQypaWjFyYz7Zl1q3UCMQErBZxpmfGfI7MvI2TlK8KTkgNpv8b1ug==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@aws-sdk/xml-builder": "^3.972.24", + "@aws/lambda-invoke-store": "^0.2.2", + "@smithy/core": "^3.24.2", + "@smithy/signature-v4": "^5.4.2", + "@smithy/types": "^4.14.1", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.37", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.37.tgz", + "integrity": "sha512-/jpPvEh6f7ntmIzf7dNxoNX6Q8vt8UpesCjbW6mFfk4V1NW6bIy9qxcQ6WbA8As5yQhsZOe+xeNd4xHX8kdY2Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.39", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.39.tgz", + "integrity": "sha512-pIgTpisWyWg7X1bUbzSjuUYosYTD0Ghz2M0hkSTmb3a6i3qV3uU+NYJPI/E2XSC0HcsZh5rsLPzeXrkb2DS0Cg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/node-http-handler": "^4.7.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.41.tgz", + "integrity": "sha512-u2tyjaxJJzW8UtW4SM1ZcPMDwO6y+kV+llvou+Adts0FAKyzes5jG4izQN+KX3yE8ZROpS5y1LJ//xL2iSf76w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/credential-provider-env": "^3.972.37", + "@aws-sdk/credential-provider-http": "^3.972.39", + "@aws-sdk/credential-provider-login": "^3.972.41", + "@aws-sdk/credential-provider-process": "^3.972.37", + "@aws-sdk/credential-provider-sso": "^3.972.41", + "@aws-sdk/credential-provider-web-identity": "^3.972.41", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/credential-provider-imds": "^4.3.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.41.tgz", + "integrity": "sha512-0LBitxXiAiaE5nlFPfpNIww/8FRY/I7WIndWsc9GmNFOM7cE1wNpVNQEGEk9Outg5l8xl+3vybxFyUy4l9q/LQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.42", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.42.tgz", + "integrity": "sha512-D4oon2zbqqsWOJUM99Gm3/ZyJ0IJvTXVN3PyloGb3kQEyI36fjCZheZj422lAgTWWd6TSHgiImLt3RIaLdv3dQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.37", + "@aws-sdk/credential-provider-http": "^3.972.39", + "@aws-sdk/credential-provider-ini": "^3.972.41", + "@aws-sdk/credential-provider-process": "^3.972.37", + "@aws-sdk/credential-provider-sso": "^3.972.41", + "@aws-sdk/credential-provider-web-identity": "^3.972.41", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/credential-provider-imds": "^4.3.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.37", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.37.tgz", + "integrity": "sha512-7nVaHBUaWIddASYfVaA9O4D5ZVjewU3sCol9WqZPGfW0nR+0WqE0xHZnD/U2L33PlOB8KNXGKZ6wOES/QijKzg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.41.tgz", + "integrity": "sha512-IOWAWEHe5LkjSKkkUUX9ciV6Y1scHTsnfEkdt5yyC4Slrc7AGbkLPrpntjqh18ksJAMOaVhoBsO8p2WyTcY2wQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/token-providers": "3.1048.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.41.tgz", + "integrity": "sha512-mbACk9Yypa8nm4iGZLs0PofOXEcTDOUw6wDnsPXNDNSd2WNXs1tSo+6nc/fh0jLYdfVZThhBL98PHW4aXFsG5A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/eventstream-handler-node": { + "version": "3.972.16", + "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.16.tgz", + "integrity": "sha512-yedpPgKftqjU5SlPFHfqWpOw6xSCRieWRG1euWOlXn4WJxt2VX92VprCa2PpSOXjVCAeK6dTjW9eJRXVig9yGA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/middleware-eventstream": { + "version": "3.972.12", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.12.tgz", + "integrity": "sha512-tHTHHCHNrq6XklQvlzHBDJG4Iuhh7NVPRdtmvP+nHFA+5sxPlIDzlAHHgfoYHGvT3NXP1yVP/L5c3opUn6T3Qg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/middleware-websocket": { + "version": "3.972.19", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.19.tgz", + "integrity": "sha512-mkEhOGYozqKQkbFaVrjwr0faiwwZza1v5/jSY6Tucm3bD+uKTazIUH/4Yo6aMnQD2ua2W9cMP6s8mvwTcjtqHw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/signature-v4": "^5.4.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/nested-clients": { + "version": "3.997.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.9.tgz", + "integrity": "sha512-jPR3rnmRI4hWYyzfmTGBr7NblMp8QYYeflHXba1H6+7CGrWVqWKQzaXFQ4qbExqPRsXN3T3L3JxFhr6aouXUGQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/signature-v4-multi-region": "^3.996.27", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/node-http-handler": "^4.7.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.27", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.27.tgz", + "integrity": "sha512-0Phbz4t6HI3D3skxvG2uI+VWU034/nSIw1T8d+FPzzQG9EQTrw94o9mOKO2Gv3n3Oc8P7JD7RAUxkoneLWv5Eg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/signature-v4": "^5.4.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/token-providers": { + "version": "3.1048.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1048.0.tgz", + "integrity": "sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/types": { + "version": "3.973.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.8.tgz", + "integrity": "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/util-locate-window": { + "version": "3.965.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.5.tgz", + "integrity": "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/xml-builder": { + "version": "3.972.24", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.24.tgz", + "integrity": "sha512-V8z5YcDPfsvzrBlj0xR1vhRtocblhYbqdreCJB/voGd4Sr5zjNAeWxexbnqVtskTJe0vFb5KMqbSL++ePl+zRw==", + "license": "Apache-2.0", + "dependencies": { + "@nodable/entities": "2.1.0", + "@smithy/types": "^4.14.1", + "fast-xml-parser": "5.7.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws/lambda-invoke-store": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz", + "integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/chord": { + "version": "0.85.1", + "resolved": "https://registry.npmjs.org/@earendil-works/chord/-/chord-0.85.1.tgz", + "license": "MIT", + "dependencies": { + "esbuild": "0.28.1" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-agent-core": { + "version": "0.85.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.85.1.tgz", + "license": "MIT", + "dependencies": { + "@earendil-works/chord": "^0.85.1", + "@earendil-works/pi-ai": "^0.85.1", + "@earendil-works/pi-telemetry": "^0.85.1", + "diff": "8.0.4", + "ignore": "7.0.5", + "typebox": "1.3.7", + "yaml": "2.9.0" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-ai": { + "version": "0.85.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.85.1.tgz", + "license": "MIT", + "dependencies": { + "@anthropic-ai/sdk": "0.123.0", + "@aws-sdk/client-bedrock-runtime": "3.1048.0", + "@earendil-works/pi-telemetry": "^0.85.1", + "@google/genai": "1.52.0", + "@smithy/node-http-handler": "4.7.3", + "http-proxy-agent": "7.0.2", + "https-proxy-agent": "7.0.6", + "openai": "6.40.0", + "partial-json": "0.1.7", + "typebox": "1.3.7" + }, + "bin": { + "pi-ai": "dist/cli.js" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-telemetry": { + "version": "0.85.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-0.85.1.tgz", + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-tui": { + "version": "0.85.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.85.1.tgz", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "1.6.0", + "marked": "18.0.5" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@google/genai": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.52.0.tgz", + "integrity": "sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^10.3.0", + "p-retry": "^4.6.2", + "protobufjs": "^7.5.4", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.25.2" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard/-/clipboard-0.3.9.tgz", + "integrity": "sha512-ABnA53mdfkGZwOFUdZNv2S0CWGO/EIuPj8Vv9xmBFmSYg/qFc7ihO6q5FcQjvoE67kZpWkEc4AhD6B/os04yuA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@mariozechner/clipboard-darwin-arm64": "0.3.9", + "@mariozechner/clipboard-darwin-universal": "0.3.9", + "@mariozechner/clipboard-darwin-x64": "0.3.9", + "@mariozechner/clipboard-linux-arm64-gnu": "0.3.9", + "@mariozechner/clipboard-linux-arm64-musl": "0.3.9", + "@mariozechner/clipboard-linux-riscv64-gnu": "0.3.9", + "@mariozechner/clipboard-linux-x64-gnu": "0.3.9", + "@mariozechner/clipboard-linux-x64-musl": "0.3.9", + "@mariozechner/clipboard-win32-arm64-msvc": "0.3.9", + "@mariozechner/clipboard-win32-x64-msvc": "0.3.9" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-darwin-arm64": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-arm64/-/clipboard-darwin-arm64-0.3.9.tgz", + "integrity": "sha512-BfgV7vCEWZwJwZJw03r6bP5+tf0iI/ANuQYCxi9RNn7FrWB3yzGuMKCrNLRl6V761vXRdL8+OqZ0wd4TqlsNOQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-darwin-universal": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-universal/-/clipboard-darwin-universal-0.3.9.tgz", + "integrity": "sha512-BGGR4iA9Z2shAjI65eI5xtyb3LYNlDW9X3gxKxDbqtbnREohsrqznov6zpKoIrsRWpzlYVEdKphS7ksJ0/ndSQ==", + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-darwin-x64": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-x64/-/clipboard-darwin-x64-0.3.9.tgz", + "integrity": "sha512-4kURmCbS6nt8uYhtmWpUcJWyPHfmAr5dTpXD1nO3pIfa+TSQ9DbrGOYCKH+aEFW47XhQ4Vp8ZTszie+wfFvDKg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-arm64-gnu": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-gnu/-/clipboard-linux-arm64-gnu-0.3.9.tgz", + "integrity": "sha512-g59OkUGP2DDfCOIKypHeYgv2M55u/cKvXa5dSxFbEJ34XvIQMdcVmpKCkGUro3ZgefXiGVdwguvTMQGpHWzIXw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-arm64-musl": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-musl/-/clipboard-linux-arm64-musl-0.3.9.tgz", + "integrity": "sha512-AGuJdgKsmJdm4Pych7kv3sqe591ERRaAHW3xjLooiFzn8J+PxUyof++7YZrB5Y5tpnTO+K18Og3taj2NpluCRQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-riscv64-gnu": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-riscv64-gnu/-/clipboard-linux-riscv64-gnu-0.3.9.tgz", + "integrity": "sha512-DXBEAiuMpk7dhS1a9NzNxVAFi1vaKoPu7rQNgY8LIDLGrK3lnIp3nT10DUum+PKVJoJppIP+NAA8IZe4DMNDPw==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-x64-gnu": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-gnu/-/clipboard-linux-x64-gnu-0.3.9.tgz", + "integrity": "sha512-WORrMLd6EpElEME7JRKfSaY34nW1P5LbdgK5YNCS1ncG2LqmITsSMEJ8nh2mpvxb3TxqbOOKgY7k9eMJYlW9Mw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-x64-musl": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-musl/-/clipboard-linux-x64-musl-0.3.9.tgz", + "integrity": "sha512-/DHn+1DrfL6oRaPPWXaOKvonFFrni666fxd+zFqiQEfvBH0tsHVWjq9iqBk0oDp0qaPA72lIMy5BptxISBEhZQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-win32-arm64-msvc": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-arm64-msvc/-/clipboard-win32-arm64-msvc-0.3.9.tgz", + "integrity": "sha512-O5FHD3ErkMwMhNzAfu3ggy0ug4z7btZuoQgwwxlzPrwV2bxlD6WDpqBY4NCgICAgZdDKdp+loUEKVAVt8aYnhQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-win32-x64-msvc": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-x64-msvc/-/clipboard-win32-x64-msvc-0.3.9.tgz", + "integrity": "sha512-ihQC3EufqEY81vhXBgVBtK4prL+wc62zJsSvxrgz7K1hsdt6OObz6v9p3Rn1OG3GJksTTKMJF0u/guMISHPhSA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@nodable/entities": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", + "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/utf8": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", + "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@silvia-odwyer/photon-node": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@silvia-odwyer/photon-node/-/photon-node-0.3.4.tgz", + "integrity": "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA==", + "license": "Apache-2.0" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/core": { + "version": "3.24.3", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.24.3.tgz", + "integrity": "sha512-Ep/7tPamGY8mgESE3LyLKtxJyy6U52WWAqr/3wial47Sj4u3PiIF73AOGI27UyLy9duTkhZbgzodOfLV4TduZg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/credential-provider-imds": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.3.3.tgz", + "integrity": "sha512-I2Bti0DKFo2IJyN28ijCsx51BAumEYR4/1yZ1FXyBygy9MqbnMqCev4JPth/MbpRfBSRAX35hITSnAdJRo1u5w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/fetch-http-handler": { + "version": "5.4.3", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.4.3.tgz", + "integrity": "sha512-F+DRf8IJazRJgYog2A/yJK7eYVc0rqTlRzO+5ZxjJd4WkZoKz0IJRncf7G6t1pdVT3kryJcwuTFhN1c5m6N47A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/node-http-handler": { + "version": "4.7.3", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.3.tgz", + "integrity": "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/signature-v4": { + "version": "5.4.3", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.4.3.tgz", + "integrity": "sha512-53+75QuPl6DL+ct6vVEB51FDO5oulXr20TPV46VvJZg76lIlXNWfxi8j+G2V/t0I2qxCBOa3vX/8bmjrpFVo9g==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/types": { + "version": "4.14.2", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.2.tgz", + "integrity": "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@types/node": { + "version": "22.19.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", + "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/diff": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/node_modules/fast-xml-builder": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", + "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.5.0", + "xml-naming": "^0.1.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/fast-xml-parser": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.3.tgz", + "integrity": "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "@nodable/entities": "^2.1.0", + "fast-xml-builder": "^1.1.7", + "path-expression-matcher": "^1.5.0", + "strnum": "^2.2.3" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/gaxios": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz", + "integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/google-auth-library": { + "version": "10.6.2", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz", + "integrity": "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/grok-mermaid": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/grok-mermaid/-/grok-mermaid-0.2.2.tgz", + "integrity": "sha512-XcJEP5dDC8liHBh52mlLjU18fNvu1ckFsu0QpIG3+APZ270fsj9wxpiA6cOURmbUEuoMVgjbC2+UYgTdCqqgzA==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/highlight.js": { + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", + "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/hosted-git-info": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz", + "integrity": "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==", + "license": "ISC", + "dependencies": { + "lru-cache": "^11.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/lru-cache": { + "version": "11.4.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.4.0.tgz", + "integrity": "sha512-W+R+kFL4HgVxONq2bhXPi3bGpzGe/yEhVOp233qw9wCRtgncJ15P3bC+e4zZMu4Cq7d+WAJjXGW0uUkifhcatA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/marked": { + "version": "18.0.5", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz", + "integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/@earendil-works/pi-coding-agent/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==", + "license": "Apache-2.0", + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/p-retry/node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/partial-json": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/partial-json/-/partial-json-0.1.7.tgz", + "integrity": "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/path-expression-matcher": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", + "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/proper-lockfile/node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/protobufjs": { + "version": "7.6.5", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz", + "integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/standardwebhooks": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.1.1.tgz", + "integrity": "sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/strnum": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz", + "integrity": "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/typebox": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.3.7.tgz", + "integrity": "sha512-meKuifc33Pccx0O6PdIzYMq3Og8zvP4TIi/a+Bw3AEMZMxOD0+RHGQvpglEe6Zdy3wZ8nqn/j95h8LUZLk/6Hg==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.9.0.tgz", + "integrity": "sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==", + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/xml-naming": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", + "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/@earendil-works/pi-telemetry": { + "version": "0.85.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-0.85.1.tgz", + "integrity": "sha512-Bg/YN6kA7Swja/NQxka8xFdecb4E/auIEGF2G5A25EaQXhRnPj300/7/KpgsDDMYUzHTDAv4RyUxaQPJKW81Rw==", + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-tui": { + "version": "0.85.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.85.1.tgz", + "integrity": "sha512-OIzw9efInmO4WOBnD4TxcTdBjmzvYJpzslkgoUro946nEGoYWg5rwv1p4fDt3/JvMx9QybryUCUwlm7j8Dreig==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "1.6.0", + "marked": "18.0.5" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@google/genai": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.52.0.tgz", + "integrity": "sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^10.3.0", + "p-retry": "^4.6.2", + "protobufjs": "^7.5.4", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.25.2" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz", + "integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.3" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz", + "integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.3" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz", + "integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.4" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz", + "integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz", + "integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz", + "integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz", + "integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz", + "integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz", + "integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz", + "integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz", + "integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz", + "integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz", + "integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz", + "integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz", + "integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz", + "integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz", + "integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz", + "integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz", + "integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.3" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz", + "integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz", + "integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.3" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz", + "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz", + "integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.4" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz", + "integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz", + "integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz", + "integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@modelcontextprotocol/client": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.0.0.tgz", + "integrity": "sha512-8f1OghQ2rjzIOfqgUCP+8GiUWqRs89njoWLNqAe8kWmDePv3s1fZXseej+QXemssEuuOvLLmLO/kqM3IQHtISw==", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/core": "2.0.0", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "jose": "^6.1.3", + "pkce-challenge": "^5.0.0", + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@modelcontextprotocol/core": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.0.0.tgz", + "integrity": "sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA==", + "license": "MIT", + "dependencies": { + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "license": "BSD-3-Clause" + }, + "node_modules/@smithy/core": { + "version": "3.34.1", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.34.1.tgz", + "integrity": "sha512-dLcOUxz8YCv1RZUMKq6GbyUf95pLbrqh34bPvpCZ1+CByFF31BEAFewZjsGCnVsZTKdThNENfGyAgk2TJqVwSw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.18.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.2.tgz", + "integrity": "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.2", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.8.0.tgz", + "integrity": "sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.18.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.7.3", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.3.tgz", + "integrity": "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.3.tgz", + "integrity": "sha512-7ImGm+FkHRLcBaRttIAMZ6bzJZWb2cJGoYjq46F2UjycujWzrL9GEN9h4w7eQyXJYnltrUhxbbieBAIRrdqpow==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.18.0.tgz", + "integrity": "sha512-CgB6HHWer/vrKps24ulRIbpcpb7K4xAU7SkZ7YHzBPlwHsvsrCJFEXK421s+cJzX+ZrqtA/TuU5w1HzI7k9N8A==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.13.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.4.tgz", + "integrity": "sha512-YJ7EqCstVTzIr0fMr7qul/977en+pQHrfmuKIo6Zr9i75Be21dr3MovcfvGtyvi2HAUrRerWps5sMO9I7WaxDw==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "license": "MIT" + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "license": "MIT" + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cross-spawn/node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/cross-spawn/node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", + "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "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/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/gaxios": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.3.1.tgz", + "integrity": "sha512-kB3rzJV7d9juLZh8/56QTXCwQfxyhdOMdyYk1HdQKFtF8TJTDTZQJtixWIwXdE9Jji91mC41DUNpjleo4L4eAQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/google-auth-library": { + "version": "10.9.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.9.1.tgz", + "integrity": "sha512-i1ydyHrqcIxXkWh/uBmVkzCvIuq5yiK2ATndIe5XxKholrG/MTYP9xGYka4sQhrbIAgGjL2B6NOE7rFaiF3fXw==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/jose": { + "version": "6.2.12", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", + "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/marked": { + "version": "18.0.5", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz", + "integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "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/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "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==", + "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", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/partial-json": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/partial-json/-/partial-json-0.1.7.tgz", + "integrity": "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==", + "license": "MIT" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/prettier": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/protobufjs": { + "version": "7.6.6", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.6.tgz", + "integrity": "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/rwebtransport": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/rwebtransport/-/rwebtransport-0.2.2.tgz", + "integrity": "sha512-ZjTUGjlyJTgyq6+4red14r0rqTgj3Ol9z4xUaZNqSdq1A0xkYFWHx2AOdQ4BcxxpyI707nCbinO5y+G71nfXrw==", + "hasInstallScript": true, + "license": "Apache-2.0", + "engines": { + "node": ">=24 <25 || >=26 <27" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", + "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.4", + "@img/sharp-darwin-x64": "0.35.4", + "@img/sharp-freebsd-wasm32": "0.35.4", + "@img/sharp-libvips-darwin-arm64": "1.3.3", + "@img/sharp-libvips-darwin-x64": "1.3.3", + "@img/sharp-libvips-linux-arm": "1.3.3", + "@img/sharp-libvips-linux-arm64": "1.3.3", + "@img/sharp-libvips-linux-ppc64": "1.3.3", + "@img/sharp-libvips-linux-riscv64": "1.3.3", + "@img/sharp-libvips-linux-s390x": "1.3.3", + "@img/sharp-libvips-linux-x64": "1.3.3", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", + "@img/sharp-libvips-linuxmusl-x64": "1.3.3", + "@img/sharp-linux-arm": "0.35.4", + "@img/sharp-linux-arm64": "0.35.4", + "@img/sharp-linux-ppc64": "0.35.4", + "@img/sharp-linux-riscv64": "0.35.4", + "@img/sharp-linux-s390x": "0.35.4", + "@img/sharp-linux-x64": "0.35.4", + "@img/sharp-linuxmusl-arm64": "0.35.4", + "@img/sharp-linuxmusl-x64": "0.35.4", + "@img/sharp-webcontainers-wasm32": "0.35.4", + "@img/sharp-win32-arm64": "0.35.4", + "@img/sharp-win32-ia32": "0.35.4", + "@img/sharp-win32-x64": "0.35.4" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/standardwebhooks": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.1.1.tgz", + "integrity": "sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typebox": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.3.7.tgz", + "integrity": "sha512-meKuifc33Pccx0O6PdIzYMq3Og8zvP4TIi/a+Bw3AEMZMxOD0+RHGQvpglEe6Zdy3wZ8nqn/j95h8LUZLk/6Hg==", + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "license": "MIT" + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/zod": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.2.tgz", + "integrity": "sha512-lh5RCAGFa1Cm2hjtNwLQhSs/AsqdWnTQaBER9fEwN/88pSh7KOtJavtBx/0VlkN/uFd61SwYmljLMDAsHlvzBQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/agent/package.json b/agent/package.json new file mode 100644 index 00000000000..f72ea10e77b --- /dev/null +++ b/agent/package.json @@ -0,0 +1,41 @@ +{ + "name": "@dimensional/dimcode", + "version": "0.1.0", + "description": "Persistent DimOS coding agent and terminal, built on Pi", + "type": "module", + "license": "Apache-2.0", + "bin": { + "dimcode": "dist/main.js" + }, + "files": [ + "dist", + "skills", + "README.md" + ], + "engines": { + "node": ">=24 <25 || >=26 <27" + }, + "scripts": { + "build": "node build.mjs", + "typecheck": "tsc --noEmit", + "test": "node test/run.mjs", + "check": "npm run typecheck && npm test && npm run build", + "format": "prettier --write src test *.mjs" + }, + "dependencies": { + "@earendil-works/pi-ai": "0.85.1", + "@earendil-works/pi-coding-agent": "0.85.1", + "@earendil-works/pi-tui": "0.85.1", + "@modelcontextprotocol/client": "2.0.0", + "rwebtransport": "0.2.2", + "sharp": "0.35.4", + "typebox": "1.3.7", + "zod": "^4.1.0" + }, + "devDependencies": { + "@types/node": "^24.3.0", + "esbuild": "^0.25.0", + "prettier": "^3.6.0", + "typescript": "5.9.3" + } +} diff --git a/agent/skills/dimensional/SKILL.md b/agent/skills/dimensional/SKILL.md new file mode 100644 index 00000000000..d2681b01373 --- /dev/null +++ b/agent/skills/dimensional/SKILL.md @@ -0,0 +1,22 @@ +--- +name: dimensional +description: Install DimOS, build and operate apps/blueprints, invoke deployed skills, and inspect real sensor/memory results. +--- + +Use the selected DimOS executable/Python environment and workspace from the session instructions. Check installed help when versions differ. Never replace an existing environment silently. + +- Installation: use `dimcode install-dimos NEW_VENV` for the base package, or the existing DimOS installation recipe for required robot extras. For a checkout use its documented uv setup. Harness setup and model auth are independent of DimOS installation. +- Apps: `dimos list`, `dimos show-config`, `dimos bake`, source editing, and external package entry points in the `dimos.blueprints` group. Run external apps by the qualified `distribution-name.blueprint-name` shown by `dimos list`; bare names select built-ins. Do not edit the built-in registry for an external app. +- Runtime: `dimos run BLUEPRINT --daemon`; inspect `dimos status --run ID`, `dimos log --run ID`, `dimos stop --run ID`, `dimos restart --run ID`. Older versions may lack exact targeting: use the existing public run_registry APIs, never silently act on the latest run instead. +- Python-launched coordinators: `Dimos.connect()` and its public module/RPC inspection APIs; `stop()` on that connection disconnects only. Use separate configured transport buses/endpoints for independent coordinators. +- All deployed @skill tools come from the selected MCP endpoint. Use those tools to preserve capability arbitration and background stop semantics. Direct RPC is not a substitute for the MCP dispatcher. On endpoint changes use /reload. Missing tools do not mean every robot dependency should be installed. +- Full CLI access stays in Bash: runtime/config/apps; mcp and agent-send; shell/spy/lcmspy/agentspy/humancli/top; topic/map/rerun-bridge; mem/data/dataprep/cache; nav-eval/evals; hardware/go2tool/calibration; login/logout/whoami. Interactive programs need an interactive terminal, not a background Bash call. +- Dimensional cloud login and model-provider login are separate. Never ask for credentials in chat or tool arguments; use the terminal /login or setup flow. + +For memory, use existing stream operations. Evaluate a query once with materialize(), then derive scores, selected peaks, images and comparisons from that same cache. Preserve timestamps, observation IDs and coordinate frames in exports. The docs reconstruction example includes a skipped block/API TODO; verify the chosen callable operation on actual data. + +Use dimcode_render for an existing saved image or JSON export: points come from the existing point cloud extraction API as XYZ rows; series are numeric [timestamp,value] rows derived from the evaluated operation. Retain original exports and their metadata. The renderer labels display projection/decimation and hashes the source file. It must never rerun a query to obtain a prettier display. + +For live rendering, explicitly choose the existing relay, robot and channel. Web SDK subscriptions open only for the tool/view's lifetime. Label the view live: it is not a historical selection, and relay header timestamps may be send time. Only a selected final snapshot belongs in model context. Non-image encodings need their existing domain decoder/renderer or an explicit saved export. + +For validation, start with the standard unitree-go2 and unitree-go2-agentic blueprints in replay mode. A running gateway does not require a blueprint or its LLM client. Do not infer a physical robot target from a default IP. Inspect non-moving skills first on explicitly selected hardware. diff --git a/agent/src/config.ts b/agent/src/config.ts new file mode 100644 index 00000000000..7f707008cd0 --- /dev/null +++ b/agent/src/config.ts @@ -0,0 +1,57 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join, resolve } from "node:path"; +import { z } from "zod"; + +export const endpointSchema = z.object({ + name: z.string().regex(/^[a-z][a-z0-9_-]*$/), + url: z.url(), +}); +export const configSchema = z.object({ + workspace: z.string().default(process.cwd()), + python: z.string().optional(), + dimos: z.string().optional(), + mcp: z.array(endpointSchema).default([]), + relay: z.url().optional(), + robot: z.string().optional(), +}); +export type Config = z.infer; +export type Endpoint = z.infer; +export const paths = (env: NodeJS.ProcessEnv = process.env) => { + const home = homedir(); + const config = resolve( + env.DIMCODE_HOME ?? + join(env.XDG_CONFIG_HOME ?? join(home, ".config"), "dimcode"), + ); + const state = join( + env.XDG_STATE_HOME ?? join(home, ".local/state"), + "dimcode", + ); + return { + config, + state, + cache: join(env.XDG_CACHE_HOME ?? join(home, ".cache"), "dimcode"), + sessions: join(state, "sessions"), + socket: join(env.XDG_RUNTIME_DIR ?? state, "dimcode.sock"), + }; +}; +export type Paths = ReturnType; +export async function loadConfig(p: Paths): Promise { + try { + return configSchema.parse( + JSON.parse(await readFile(join(p.config, "config.json"), "utf8")), + ); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") + return configSchema.parse({}); + throw error; + } +} +export async function saveConfig(p: Paths, config: Config): Promise { + await mkdir(p.config, { recursive: true, mode: 0o700 }); + await writeFile( + join(p.config, "config.json"), + JSON.stringify(configSchema.parse(config), null, 2) + "\n", + { mode: 0o600 }, + ); +} diff --git a/agent/src/gateway.ts b/agent/src/gateway.ts new file mode 100644 index 00000000000..4c297180729 --- /dev/null +++ b/agent/src/gateway.ts @@ -0,0 +1,500 @@ +import { randomUUID } from "node:crypto"; +import { chmod, lstat, mkdir, unlink } from "node:fs/promises"; +import { createServer, type Socket } from "node:net"; +import { dirname } from "node:path"; +import { SessionManager } from "@earendil-works/pi-coding-agent"; +import type { AuthInteraction, AuthPrompt } from "@earendil-works/pi-ai"; +import type { Config, Paths } from "./config.js"; +import { openSession, type SessionHandle } from "./session.js"; +import { + type Command, + type Event, + type Packet, + type Results, + type Snapshot, + readLines, + reachable, + requestSchema, + send, +} from "./protocol.js"; + +interface Entry { + handle: SessionHandle; + peers: Set; + owner?: Socket; + seq: number; + running: boolean; + text: string; + notices: string[]; + tools: Map; + accepted: Set; + dialogs: Map< + string, + { resolve: (value: string) => void; reject: (error: Error) => void } + >; +} +export type SessionFactory = typeof openSession; +export class Gateway { + private closing = false; + private readonly sockets = new Set(); + private readonly opening = new Map>(); + private readonly entries = new Map(); + private readonly peers = new Map(); + private readonly server = createServer((socket) => this.connect(socket)); + constructor( + private readonly config: Config, + private readonly paths: Paths, + private readonly factory: SessionFactory = openSession, + ) {} + async start(): Promise { + await mkdir(dirname(this.paths.socket), { recursive: true, mode: 0o700 }); + const listen = () => + new Promise((resolve, reject) => { + const failed = (error: Error) => { + this.server.off("listening", ready); + reject(error); + }; + const ready = () => { + this.server.off("error", failed); + resolve(); + }; + this.server.once("error", failed); + this.server.once("listening", ready); + this.server.listen(this.paths.socket); + }); + try { + await listen(); + } catch (error) { + if ( + !( + error instanceof Error && + "code" in error && + error.code === "EADDRINUSE" + ) || + !(await lstat(this.paths.socket)).isSocket() || + (await reachable(this.paths.socket)) + ) + throw error; + await unlink(this.paths.socket); + await listen(); + } + await chmod(this.paths.socket, 0o600); + } + private emit(entry: Entry, event: Event): void { + if (event.type === "notice" || event.type === "turn_error") { + entry.notices.push(event.message); + entry.notices = entry.notices.slice(-10); + } + const packet: Packet = { type: "event", seq: ++entry.seq, event }; + for (const peer of entry.peers) send(peer, packet); + } + private async restore( + id?: string, + cwd = this.config.workspace, + ): Promise { + const key = id ?? randomUUID(); + const pending = this.opening.get(key); + if (pending) return pending; + const creating = this.makeEntry(id, cwd); + this.opening.set(key, creating); + try { + return await creating; + } finally { + this.opening.delete(key); + } + } + private async makeEntry( + id?: string, + cwd = this.config.workspace, + ): Promise { + if (id && this.entries.has(id)) return this.entries.get(id)!; + const saved = id + ? (await SessionManager.listAll(this.paths.sessions)).find( + (item) => item.id === id, + ) + : undefined; + if (id && !saved) throw new Error("Unknown session"); + const manager = saved + ? SessionManager.open(saved.path, this.paths.sessions) + : SessionManager.create(cwd, this.paths.sessions); + const notices: string[] = []; + let entry: Entry | undefined; + const handle = await this.factory( + this.config, + this.paths, + manager, + (message) => + entry + ? this.emit(entry, { type: "notice", message }) + : notices.push(message), + ); + if (this.closing) { + await handle.close(); + throw new Error("Gateway shutting down"); + } + entry = { + handle, + peers: new Set(), + seq: 0, + running: false, + text: "", + notices, + tools: new Map(), + accepted: new Set(), + dialogs: new Map(), + }; + for (const item of manager.getEntries()) + if ( + item.type === "custom" && + item.customType === "dimcode.prompt" && + typeof item.data === "string" + ) + entry.accepted.add(item.data); + const current = entry; + handle.session.subscribe((event) => { + if (event.type === "message_update") { + const update = event.assistantMessageEvent; + if (update.type === "text_delta") { + current.text += update.delta; + this.emit(current, { type: "text_delta", delta: update.delta }); + } + return; + } + if (event.type === "message_start" && event.message.role === "assistant") + current.text = ""; + if (event.type === "message_end" && event.message.role === "assistant") + current.text = ""; + if ( + event.type === "tool_execution_start" || + event.type === "tool_execution_update" + ) + current.tools.set(event.toolCallId, event); + if (event.type === "tool_execution_end") + current.tools.delete(event.toolCallId); + this.emit(current, event); + }); + const baseUI = handle.session.extensionRunner.getUIContext(); + const interaction = this.interaction(current); + await handle.session.bindExtensions({ + mode: "rpc", + uiContext: { + ...baseUI, + select: (title, options, opts) => + interaction.prompt({ + type: "select", + message: title, + options: options.map((value) => ({ id: value, label: value })), + signal: opts?.signal, + }), + confirm: async (title, message, opts) => + (await interaction.prompt({ + type: "select", + message: title + "\n" + message, + options: [ + { id: "yes", label: "Yes" }, + { id: "no", label: "No" }, + ], + signal: opts?.signal, + })) === "yes", + input: (title, placeholder, opts) => + interaction.prompt({ + type: "text", + message: title, + placeholder, + signal: opts?.signal, + }), + editor: (title, prefill) => + interaction.prompt({ + type: "text", + message: title, + placeholder: prefill, + }), + notify: (message) => this.emit(current, { type: "notice", message }), + setStatus: (key, message) => { + if (message) + this.emit(current, { + type: "notice", + message: key + ": " + message, + }); + }, + custom: async () => { + throw new Error( + "Executable UI factories cannot cross a socket; install the component in the dimcode terminal renderer.", + ); + }, + }, + onError: (error) => + this.emit(current, { type: "notice", message: error.error }), + }); + this.entries.set(handle.session.sessionId, current); + if (saved) + notices.push( + "Resumed saved history. Interrupted external actions are not replayed; inspect DimOS status before continuing.", + ); + for (const message of notices) + handle.session.sessionManager.appendCustomEntry( + "dimcode.notice", + message, + ); + return current; + } + private snapshot(entry: Entry, socket: Socket): Snapshot { + const session = entry.handle.session; + return { + sessionId: session.sessionId, + cwd: session.sessionManager.getCwd(), + seq: entry.seq, + busy: entry.running, + writable: entry.owner === socket, + messages: session.messages, + text: entry.text, + notices: entry.notices, + tools: [...entry.tools.values()], + }; + } + private detach(socket: Socket): void { + const entry = this.peers.get(socket); + if (!entry) return; + entry.peers.delete(socket); + this.peers.delete(socket); + if (entry.owner === socket) { + entry.owner = undefined; + for (const dialog of entry.dialogs.values()) + dialog.reject(new Error("Input terminal detached")); + entry.dialogs.clear(); + } + } + private attach(entry: Entry, socket: Socket, writable: boolean): Snapshot { + if (writable && entry.owner && entry.owner !== socket) + throw new Error( + "Session has an input owner; attach with --view or detach the other terminal.", + ); + this.detach(socket); + entry.peers.add(socket); + this.peers.set(socket, entry); + if (writable) entry.owner = socket; + return this.snapshot(entry, socket); + } + private connect(socket: Socket): void { + this.sockets.add(socket); + socket.on("error", () => {}); + socket.on("close", () => { + this.detach(socket); + this.sockets.delete(socket); + }); + readLines(socket, (value) => { + const parsed = requestSchema.safeParse(value); + if (!parsed.success) { + socket.destroy(); + return; + } + const { id, command } = parsed.data; + void this.dispatch(socket, command, id).then( + (data) => send(socket, { type: "response", id, data }), + (error) => + send(socket, { + type: "response", + id, + error: error instanceof Error ? error.message : String(error), + }), + ); + }); + } + private interaction(entry: Entry): AuthInteraction { + return { + prompt: (prompt: AuthPrompt) => + new Promise((resolve, reject) => { + if (!entry.owner) { + reject( + new Error("This interaction needs an attached input terminal"), + ); + return; + } + if (entry.dialogs.size) { + reject(new Error("Finish the existing terminal dialog first")); + return; + } + if (prompt.signal?.aborted) { + reject(new Error("Interaction cancelled")); + return; + } + const promptId = randomUUID(); + const { signal, ...wirePrompt } = prompt; + const aborted = () => + entry.dialogs + .get(promptId) + ?.reject(new Error("Interaction cancelled")); + const cleanup = () => { + entry.dialogs.delete(promptId); + signal?.removeEventListener("abort", aborted); + }; + entry.dialogs.set(promptId, { + resolve: (value) => { + cleanup(); + resolve(value); + }, + reject: (error) => { + cleanup(); + reject(error); + }, + }); + signal?.addEventListener("abort", aborted, { once: true }); + send(entry.owner, { + type: "event", + seq: ++entry.seq, + event: { type: "auth_prompt", promptId, prompt: wirePrompt }, + }); + }), + notify: (info) => { + if (entry.owner) + send(entry.owner, { + type: "event", + seq: ++entry.seq, + event: { type: "auth_info", info }, + }); + }, + }; + } + private async dispatch( + socket: Socket, + command: Command, + id: string, + ): Promise { + if (this.closing) throw new Error("Gateway shutting down"); + if (command.type === "shutdown") { + setImmediate(() => void this.close()); + return null; + } + if (command.type === "new_session") + return this.attach( + await this.restore(undefined, command.cwd), + socket, + true, + ); + if (command.type === "attach") + return this.attach( + await this.restore(command.sessionId), + socket, + command.writable, + ); + if (command.type === "list_sessions") { + const saved = await SessionManager.listAll(this.paths.sessions); + const list = new Map( + saved.map((item) => [ + item.id, + { id: item.id, cwd: item.cwd, name: item.name ?? item.firstMessage }, + ]), + ); + for (const [sessionId, entry] of this.entries) + if (!list.has(sessionId)) + list.set(sessionId, { + id: sessionId, + cwd: entry.handle.session.sessionManager.getCwd(), + name: "", + }); + return [...list.values()]; + } + const entry = this.peers.get(socket); + if (!entry) throw new Error("Attach to a session first"); + const session = entry.handle.session; + if (command.type === "get_state") return this.snapshot(entry, socket); + if (command.type === "get_available_models") + return session.modelRuntime.getModels().map((model) => ({ + provider: model.provider, + id: model.id, + name: model.name, + })); + if (entry.owner !== socket) throw new Error("This terminal is read-only"); + switch (command.type) { + case "prompt": { + if (entry.accepted.has(id)) return null; + if (entry.running) + throw new Error("Agent is busy; use steer or follow_up"); + entry.accepted.add(id); + session.sessionManager.appendCustomEntry("dimcode.prompt", id); + entry.running = true; + // Acknowledge acceptance without tying turn lifetime to the connection. + void session + .prompt(command.message, { images: command.images }) + .then(() => { + const last = session.messages.at(-1); + if (last?.role === "assistant" && last.stopReason === "error") + throw new Error(last.errorMessage ?? "Model request failed"); + }) + .catch((error) => + this.emit(entry, { type: "turn_error", message: String(error) }), + ) + .finally(() => { + entry.running = false; + this.emit(entry, { type: "idle" }); + }); + return null; + } + case "steer": + await session.steer(command.message); + return null; + case "follow_up": + await session.followUp(command.message); + return null; + case "abort": + await session.abort(); + this.emit(entry, { + type: "notice", + message: + "Agent turn cancelled. Use the DimOS skill's stop operation to confirm any background robot action stopped.", + }); + return null; + case "ui_response": { + const dialog = entry.dialogs.get(command.promptId); + if (!dialog) throw new Error("Unknown or expired dialog"); + if (command.value === undefined) dialog.reject(new Error("Cancelled")); + else dialog.resolve(command.value); + return null; + } + case "login": + if (command.provider === "anthropic" && command.authType === "oauth") + throw new Error("Use an Anthropic API key for dimcode."); + await session.modelRuntime.login( + command.provider, + command.authType, + this.interaction(entry), + ); + return null; + case "logout": + await session.modelRuntime.logout(command.provider); + return null; + case "set_model": { + if (entry.running) + throw new Error("Change models after the current turn"); + const model = session.modelRuntime.getModel( + command.provider, + command.modelId, + ); + if (!model) throw new Error("Unknown provider/model"); + await session.setModel(model); + return null; + } + case "reload": + if (entry.running) throw new Error("Reload after the current turn"); + await session.reload(); + return null; + } + } + async close(): Promise { + if (this.closing) return; + this.closing = true; + for (const socket of this.sockets) socket.destroy(); + await Promise.allSettled( + [...this.entries.values()].map(async (entry) => { + try { + await entry.handle.session.abort(); + } finally { + await entry.handle.close(); + } + }), + ); + this.entries.clear(); + await new Promise((resolve) => this.server.close(() => resolve())); + await unlink(this.paths.socket).catch(() => {}); + } +} diff --git a/agent/src/main.ts b/agent/src/main.ts new file mode 100644 index 00000000000..9cd646a49bc --- /dev/null +++ b/agent/src/main.ts @@ -0,0 +1,216 @@ +import { spawn } from "node:child_process"; +import { access, mkdir, rm, rmdir, stat } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { parseArgs } from "node:util"; +import { setTimeout as delay } from "node:timers/promises"; +import { paths, loadConfig, saveConfig } from "./config.js"; +import { Gateway } from "./gateway.js"; +import { Connection, reachable } from "./protocol.js"; +import { setup } from "./setup.js"; +import { service, execute } from "./service.js"; +import { terminal } from "./terminal.js"; + +const { values, positionals } = parseArgs({ + allowPositionals: true, + options: { + foreground: { type: "boolean" }, + view: { type: "boolean" }, + session: { type: "string" }, + cwd: { type: "string" }, + provider: { type: "string" }, + oauth: { type: "boolean" }, + "key-env": { type: "string" }, + help: { type: "boolean" }, + }, +}); +const p = paths(), + config = await loadConfig(p); +if (values.cwd) config.workspace = resolve(values.cwd); +async function ensureGateway(): Promise { + if (await reachable(p.socket)) return; + const lock = p.socket + ".starting"; + try { + if (Date.now() - (await stat(lock)).mtimeMs > 30000) + await rm(lock, { recursive: true, force: true }); + } catch {} + await mkdir(dirname(p.socket), { recursive: true, mode: 0o700 }); + try { + await mkdir(lock, { mode: 0o700 }); + try { + if (await reachable(p.socket)) return; + const child = spawn( + process.execPath, + [resolve(process.argv[1]), "gateway"], + { detached: true, stdio: "ignore", env: process.env }, + ); + child.unref(); + for (let attempt = 0; attempt < 100; attempt++) { + if (await reachable(p.socket)) return; + await delay(50); + } + throw new Error( + "Gateway failed to start; run dimcode gateway to inspect the error.", + ); + } finally { + await rmdir(lock); + } + } catch (error) { + if (!(error instanceof Error && "code" in error && error.code === "EEXIST")) + throw error; + for (let attempt = 0; attempt < 100; attempt++) { + if (await reachable(p.socket)) return; + await delay(50); + } + throw new Error("Gateway bootstrap did not complete. Inspect " + lock); + } +} +async function main(): Promise { + const [command, ...args] = positionals; + if (values.help || command === "help") { + console.log( + "dimcode [--session ID] [--cwd DIR] [--view]\ndimcode setup [--provider NAME] [--oauth | --key-env VAR]\ndimcode gateway | --foreground | service install/status/uninstall\ndimcode connect NAME MCP_URL | dimos PATH | python PATH | relay URL [ROBOT]\ndimcode install-dimos [VENV] | sessions | stop | run PROMPT | config", + ); + return; + } + if (command === "setup") { + await setup(p, { + provider: values.provider, + oauth: values.oauth, + keyEnv: values["key-env"], + workspace: values.cwd, + }); + return; + } + if (command === "config") { + console.log(JSON.stringify(config, null, 2)); + return; + } + if (command === "connect") { + const [name, url] = args; + config.mcp = [ + ...config.mcp.filter((endpoint) => endpoint.name !== name), + { name, url }, + ]; + await saveConfig(p, config); + console.log("Saved endpoint; reload sessions to discover tools."); + return; + } + if (command === "dimos" || command === "python") { + if (!args[0]) throw new Error("Executable path required"); + config[command] = resolve(args[0]); + await saveConfig(p, config); + return; + } + if (command === "relay") { + [config.relay, config.robot] = args; + await saveConfig(p, config); + return; + } + if (command === "install-dimos") { + const venv = resolve(args[0] ?? config.workspace + "/.venv"); + let exists = true; + try { + await access(venv); + } catch { + exists = false; + } + if (exists) + throw new Error( + "Destination exists; select its dimos/python executables or choose a new venv.", + ); + await execute("uv", ["venv", "--python", "3.12", venv]); + await execute("uv", [ + "pip", + "install", + "--python", + venv + "/bin/python", + "dimos", + ]); + config.python = venv + "/bin/python"; + config.dimos = venv + "/bin/dimos"; + await saveConfig(p, config); + return; + } + if (command === "service") { + await service(args[0], config, p, process.argv[1]); + return; + } + if (command === "gateway" || values.foreground) { + if (values.foreground) p.socket = p.socket + "." + process.pid; + const gateway = new Gateway(config, p); + await gateway.start(); + if (values.foreground) { + try { + await terminal(p.socket, { cwd: config.workspace }); + } finally { + await gateway.close(); + } + return; + } + let closing = false; + for (const signal of ["SIGTERM", "SIGINT"] as const) + process.on(signal, () => { + if (!closing) { + closing = true; + void gateway.close().catch(console.error); + } + }); + return; + } + if (command === "stop") { + const c = new Connection(p.socket); + try { + await c.call({ type: "shutdown" }); + } finally { + c.close(); + } + return; + } + await ensureGateway(); + if (command === "sessions") { + const c = new Connection(p.socket); + try { + console.log(await c.call({ type: "list_sessions" })); + } finally { + c.close(); + } + return; + } + if (command === "run") { + const c = new Connection(p.socket); + try { + await c.call( + values.session + ? { type: "attach", sessionId: values.session, writable: true } + : { type: "new_session", cwd: config.workspace }, + ); + const done = new Promise((resolve, reject) => { + c.onEvent = (_seq, event) => { + if (event.type === "text_delta") process.stdout.write(event.delta); + if (event.type === "notice") + process.stderr.write(event.message + "\n"); + if (event.type === "turn_error") reject(new Error(event.message)); + if (event.type === "idle") resolve(); + }; + c.onClose = () => + reject(new Error("Gateway disconnected before completion")); + }); + await c.call({ type: "prompt", message: args.join(" ") }); + await done; + process.stdout.write("\n"); + } finally { + c.close(); + } + return; + } + if (command) throw new Error("Unknown command: " + command); + await terminal(p.socket, { + sessionId: values.session, + cwd: config.workspace, + view: values.view, + }); +} +await main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; +}); diff --git a/agent/src/mcp.ts b/agent/src/mcp.ts new file mode 100644 index 00000000000..86f4280589a --- /dev/null +++ b/agent/src/mcp.ts @@ -0,0 +1,213 @@ +import { createHash } from "node:crypto"; +import { + Client, + StreamableHTTPClientTransport, + type CallToolResult, + type ProgressNotification, +} from "@modelcontextprotocol/client"; +import type { ImageContent, TextContent } from "@earendil-works/pi-ai"; +import type { + ExtensionFactory, + ToolDefinition, +} from "@earendil-works/pi-coding-agent"; +import { z } from "zod"; +import { Type, type TUnsafe } from "typebox"; +import type { Endpoint } from "./config.js"; + +export type McpDetails = { + endpoint: string; + tool: string; + result: Omit; +}; +export function resultContent( + content: CallToolResult["content"], +): Array { + return content.map((block) => { + if (block.type === "text") return { type: "text", text: block.text }; + if (block.type === "image") + return { type: "image", data: block.data, mimeType: block.mimeType }; + if (block.type === "resource" && "text" in block.resource) + return { type: "text", text: block.resource.text }; + return { type: "text", text: JSON.stringify(block) }; + }); +} +export const toolName = (endpoint: string, name: string): string => + "dimos_" + + endpoint.slice(0, 12) + + "_" + + name.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 28) + + "_" + + createHash("sha256") + .update(endpoint + "\0" + name) + .digest("hex") + .slice(0, 10); + +type McpTool = ToolDefinition>, McpDetails>; + +export class McpTools { + private readonly clients = new Map(); + private readonly urls = new Map(); + private readonly progress = new Map< + string | number, + (value: ProgressNotification["params"]) => void + >(); + constructor( + private readonly endpoints: () => Promise, + private readonly report: (message: string) => void = () => {}, + ) {} + async tools(): Promise { + const tools: McpTool[] = []; + const endpoints = await this.endpoints(); + for (const [name, client] of this.clients) + if ( + !endpoints.some( + (endpoint) => + endpoint.name === name && endpoint.url === this.urls.get(name), + ) + ) { + await client.close(); + this.clients.delete(name); + } + for (const endpoint of endpoints) { + let client = this.clients.get(endpoint.name); + try { + if (!client) { + client = new Client({ name: "dimcode", version: "0.1.0" }); + client.onerror = (error) => + this.report(endpoint.name + ": " + error.message); + const connected = client; + client.onclose = () => { + if (this.clients.get(endpoint.name) === connected) + this.clients.delete(endpoint.name); + this.report(endpoint.name + ": disconnected"); + }; + client.setNotificationHandler( + "notifications/message", + (notification) => + this.report( + endpoint.name + ": " + JSON.stringify(notification.params.data), + ), + ); + client.setNotificationHandler( + "notifications/progress", + (notification) => { + const progress = notification.params; + const update = this.progress.get(progress.progressToken); + if (update) update(progress); + else + this.report( + endpoint.name + + " background " + + progress.progressToken + + ": " + + (progress.message ?? progress.progress), + ); + }, + ); + client.fallbackNotificationHandler = async (notification) => + this.report(endpoint.name + ": " + JSON.stringify(notification)); + await client.connect( + new StreamableHTTPClientTransport(new URL(endpoint.url)), + ); + this.clients.set(endpoint.name, client); + this.urls.set(endpoint.name, endpoint.url); + } + const connection = client; + const remoteTools = []; + let cursor: string | undefined; + do { + const listing = await connection.listTools({ cursor }); + remoteTools.push(...listing.tools); + cursor = listing.nextCursor; + } while (cursor); + const names = new Set(); + for (const remote of remoteTools) { + if (names.has(remote.name)) + throw new Error("Duplicate remote tool name: " + remote.name); + names.add(remote.name); + const parameters = Type.Unsafe>( + remote.inputSchema, + ); + const tool: ToolDefinition = { + name: toolName(endpoint.name, remote.name), + label: endpoint.name + " / " + remote.name, + description: + (remote.description ?? remote.name) + + "\nDimOS metadata: " + + JSON.stringify(remote._meta ?? {}), + parameters, + execute: async (id, args, signal, update) => { + if (this.clients.get(endpoint.name) !== connection) + throw new Error("MCP endpoint disconnected; refresh tools."); + this.progress.set(id, (progress) => + update?.({ + content: [ + { + type: "text", + text: progress.message ?? String(progress.progress), + }, + ], + details: { + endpoint: endpoint.name, + tool: remote.name, + result: {}, + }, + }), + ); + try { + const result = await connection.callTool( + { + name: remote.name, + arguments: args, + _meta: { progressToken: id }, + }, + { signal }, + ); + const { content, ...metadata } = result; + return { + content: resultContent(content), + details: { + endpoint: endpoint.name, + tool: remote.name, + result: metadata, + }, + }; + } finally { + this.progress.delete(id); + } + }, + }; + tools.push(tool); + } + } catch (error) { + await client?.close(); + this.clients.delete(endpoint.name); + this.report(endpoint.name + ": " + String(error)); + } + } + return tools; + } + extension(): ExtensionFactory { + return async (pi) => { + for (const tool of await this.tools()) pi.registerTool(tool); + pi.on("tool_result", (event) => { + const details = z + .object({ + endpoint: z.string(), + result: z.object({ isError: z.boolean().optional() }), + }) + .safeParse(event.details); + if (details.success && details.data.result.isError) + return { isError: true }; + return undefined; + }); + pi.on("session_shutdown", () => this.close()); + }; + } + async close(): Promise { + await Promise.all( + [...this.clients.values()].map((client) => client.close()), + ); + this.clients.clear(); + } +} diff --git a/agent/src/media.ts b/agent/src/media.ts new file mode 100644 index 00000000000..47db517007d --- /dev/null +++ b/agent/src/media.ts @@ -0,0 +1,91 @@ +import { + connect, + type ConnectOptions, + type Session, + type Slot, + type TransportDeps, +} from "@dimos/sdk"; + +export async function nodeTransport(): Promise { + const { WebTransport } = await import("rwebtransport"); + return { + createWebTransport: (info) => + new WebTransport(new URL("/viewer", info.wtUrl).href, { + serverCertificateHashes: info.certHash + ? [ + { + algorithm: "sha-256", + value: Uint8Array.from(Buffer.from(info.certHash, "base64")) + .buffer, + }, + ] + : [], + }), + }; +} +export type MediaSelection = Pick; +export type Decoder = (slot: Slot) => T; +export interface MediaLease { + current(): T | undefined; + close(): void; +} + +/** Only render consumers open connections; the last consumer closes the SDK session. */ +export class MediaPool { + private readonly connections = new Map< + string, + { session: Session; consumers: number } + >(); + private pending: Promise | undefined; + private closed = false; + constructor( + private readonly transport: () => Promise = nodeTransport, + private readonly options: Omit = {}, + private readonly create: typeof connect = connect, + ) {} + async acquire( + selection: MediaSelection, + channel: string, + decode: Decoder, + changed: () => void, + ): Promise> { + const key = JSON.stringify([selection.url, selection.robot]); + this.pending ??= this.transport(); + const deps = await this.pending; + if (this.closed) throw new Error("Media pool closed"); + let entry = this.connections.get(key); + if (!entry) { + entry = { + session: this.create({ ...this.options, ...selection }, deps), + consumers: 0, + }; + this.connections.set(key, entry); + } + entry.consumers++; + const held = entry; + const interest = held.session.subscribe(channel, changed); + const frames = held.session.store.subscribe(channel, changed); + let closed = false; + return { + current: () => { + const slot = held.session.store.get(channel); + return slot ? decode(slot) : undefined; + }, + close: () => { + if (closed) return; + closed = true; + frames(); + interest(); + if (--held.consumers === 0) { + held.session.close(); + this.connections.delete(key); + } + }, + }; + } + close(): void { + this.closed = true; + for (const { session } of this.connections.values()) session.close(); + this.connections.clear(); + } +} diff --git a/agent/src/protocol.ts b/agent/src/protocol.ts new file mode 100644 index 00000000000..780a7a92808 --- /dev/null +++ b/agent/src/protocol.ts @@ -0,0 +1,206 @@ +import { randomUUID } from "node:crypto"; +import { createConnection, type Socket } from "node:net"; +import { StringDecoder } from "node:string_decoder"; +import type { + AgentSession, + AgentSessionEvent, +} from "@earendil-works/pi-coding-agent"; +import type { AuthEvent, AuthPrompt } from "@earendil-works/pi-ai"; +import { z } from "zod"; + +const image = z.object({ + type: z.literal("image"), + data: z.string(), + mimeType: z.string(), +}); +export const commandSchema = z.discriminatedUnion("type", [ + z.object({ type: z.literal("new_session"), cwd: z.string().optional() }), + z.object({ + type: z.literal("attach"), + sessionId: z.string(), + writable: z.boolean().default(true), + }), + z.object({ type: z.literal("list_sessions") }), + z.object({ type: z.literal("shutdown") }), + z.object({ + type: z.literal("prompt"), + message: z.string(), + images: z.array(image).optional(), + }), + z.object({ type: z.literal("steer"), message: z.string() }), + z.object({ type: z.literal("follow_up"), message: z.string() }), + z.object({ type: z.literal("abort") }), + z.object({ type: z.literal("get_state") }), + z.object({ type: z.literal("get_available_models") }), + z.object({ + type: z.literal("set_model"), + provider: z.string(), + modelId: z.string(), + }), + z.object({ + type: z.literal("login"), + provider: z.string(), + authType: z.enum(["api_key", "oauth"]), + }), + z.object({ type: z.literal("logout"), provider: z.string() }), + z.object({ + type: z.literal("ui_response"), + promptId: z.string(), + value: z.string().optional(), + }), + z.object({ type: z.literal("reload") }), +]); +export const requestSchema = z.object({ id: z.uuid(), command: commandSchema }); +export type Command = z.infer; +export type Request = z.infer; +export type Kind = Command["type"]; +export type SessionEvent = + | Exclude + | { type: "text_delta"; delta: string }; +type WithoutSignal = T extends unknown ? Omit : never; +export type Event = + | SessionEvent + | { type: "notice" | "turn_error"; message: string } + | { type: "auth_prompt"; promptId: string; prompt: WithoutSignal } + | { type: "auth_info"; info: AuthEvent } + | { type: "idle" }; +export interface Snapshot { + sessionId: string; + cwd: string; + seq: number; + busy: boolean; + writable: boolean; + messages: AgentSession["messages"]; + text: string; + notices: string[]; + tools: Array< + Extract< + AgentSessionEvent, + { type: "tool_execution_start" | "tool_execution_update" } + > + >; +} +export interface Results { + new_session: Snapshot; + attach: Snapshot; + get_state: Snapshot; + list_sessions: Array<{ id: string; cwd: string; name: string }>; + shutdown: null; + prompt: null; + steer: null; + follow_up: null; + abort: null; + get_available_models: Array<{ provider: string; id: string; name: string }>; + set_model: null; + login: null; + logout: null; + ui_response: null; + reload: null; +} +export type Packet = + | { type: "response"; id: string; data?: Results[Kind]; error?: string } + | { type: "event"; seq: number; event: Event }; + +const MAX_BYTES = 16 * 1024 * 1024; +export function send(socket: Socket, packet: Packet | Request): void { + const line = JSON.stringify(packet) + "\n"; + if ( + Buffer.byteLength(line) > MAX_BYTES || + socket.writableLength > MAX_BYTES + ) { + socket.destroy( + new Error("Terminal connection exceeded its bounded buffer"), + ); + return; + } + socket.write(line); +} +export function readLines( + socket: Socket, + line: (value: unknown) => void, +): void { + const decoder = new StringDecoder("utf8"); + let pending = ""; + socket.on("data", (chunk: Buffer) => { + pending += decoder.write(chunk); + if (Buffer.byteLength(pending) > MAX_BYTES) { + socket.destroy(new Error("Message too large")); + return; + } + let end: number; + while ((end = pending.indexOf("\n")) >= 0) { + const next = pending.slice(0, end); + pending = pending.slice(end + 1); + try { + line(JSON.parse(next)); + } catch { + socket.destroy(new Error("Invalid protocol message")); + return; + } + } + }); +} +export class Connection { + private readonly socket: Socket; + private readonly pending = new Map< + string, + { resolve: (value: Results[Kind]) => void; reject: (error: Error) => void } + >(); + onEvent: (seq: number, event: Event) => void = () => {}; + onClose: () => void = () => {}; + constructor(path: string) { + this.socket = createConnection(path); + readLines(this.socket, (value) => { + // Both ends share Packet; only the user-owned local socket is accepted. + const packet = value as Packet; + if (packet.type === "event") this.onEvent(packet.seq, packet.event); + else if (packet.type === "response") { + const pending = this.pending.get(packet.id); + this.pending.delete(packet.id); + if (packet.error) pending?.reject(new Error(packet.error)); + else pending?.resolve(packet.data ?? null); + } else throw new Error("Unexpected packet"); + }); + this.socket.on("error", (error) => this.fail(error)); + this.socket.on("close", () => { + this.fail(new Error("Gateway disconnected")); + this.onClose(); + }); + } + call( + command: Extract, + id = randomUUID(), + ): Promise { + if (this.socket.destroyed) + return Promise.reject(new Error("Gateway disconnected")); + return new Promise((resolve, reject) => { + this.pending.set(id, { + resolve: (value) => resolve(value as Results[K]), + reject, + }); + send(this.socket, { id, command }); + }); + } + close(): void { + this.socket.destroy(); + } + private fail(error: Error): void { + for (const pending of this.pending.values()) pending.reject(error); + this.pending.clear(); + } +} + +export function reachable(path: string): Promise { + return new Promise((resolve) => { + const socket = createConnection(path); + socket.once("connect", () => { + socket.destroy(); + resolve(true); + }); + socket.once("error", () => resolve(false)); + socket.setTimeout(500, () => { + socket.destroy(); + resolve(false); + }); + }); +} diff --git a/agent/src/render.ts b/agent/src/render.ts new file mode 100644 index 00000000000..a3fc7455e23 --- /dev/null +++ b/agent/src/render.ts @@ -0,0 +1,338 @@ +import { createHash } from "node:crypto"; +import { + mkdir, + readFile, + readdir, + stat, + unlink, + writeFile, +} from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; +import type { ExtensionFactory } from "@earendil-works/pi-coding-agent"; +import { Container, Image, Text, type Component } from "@earendil-works/pi-tui"; +import { Type } from "typebox"; +import { z } from "zod"; +import sharp from "sharp"; +import type { Paths } from "./config.js"; +import { MediaPool } from "./media.js"; + +const point = z.tuple([ + z.number().finite(), + z.number().finite(), + z.number().finite(), +]); +const sample = z.tuple([z.number().finite(), z.number().finite()]); +const cloud = z + .object({ + points: z.array(point).max(1_000_000), + frame: z.string().optional(), + timestamp: z.number().optional(), + }) + .passthrough(); +const series = z + .object({ + series: z + .array( + z.object({ name: z.string(), values: z.array(sample).max(100_000) }), + ) + .max(20), + }) + .passthrough(); +export const renderDetails = z.object({ + source: z.string(), + sha256: z.string().optional(), + summary: z.string(), + live: z + .object({ + url: z.string(), + robot: z.string().optional(), + channel: z.string(), + }) + .optional(), +}); +export type RenderDetails = z.infer; + +export async function plot( + data: unknown, + kind: "points" | "series", +): Promise<{ png: Buffer; summary: string }> { + let groups: Array<{ name: string; values: Array<[number, number, number?]> }>; + let metadata: Record; + if (kind === "points") { + const { points, ...rest } = cloud.parse(data); + groups = [{ name: "points", values: points }]; + metadata = rest; + } else { + const { series: measured, ...rest } = series.parse(data); + groups = measured; + metadata = rest; + } + const values = groups.flatMap((group) => group.values); + if (!values.length) throw new Error("No points to render"); + const projected = values.map(([x, y, z = 0]) => + kind === "points" ? [x - y * 0.35, z + y * 0.35] : [x, y], + ); + let xmin = Infinity, + xmax = -Infinity, + ymin = Infinity, + ymax = -Infinity; + for (const [x, y] of projected) { + xmin = Math.min(xmin, x); + xmax = Math.max(xmax, x); + ymin = Math.min(ymin, y); + ymax = Math.max(ymax, y); + } + const project = ([x, y]: number[]) => [ + 24 + ((x - xmin) / (xmax - xmin || 1)) * 752, + 424 - ((y - ymin) / (ymax - ymin || 1)) * 400, + ]; + const colors = ["#5eead4", "#fbbf24", "#f472b6", "#60a5fa"]; + const step = Math.max(1, Math.ceil(values.length / 20_000)); + let offset = 0; + const shapes = groups + .map((group, index) => { + const coords = projected + .slice(offset, offset + group.values.length) + .filter((_, i) => i % step === 0) + .map(project); + offset += group.values.length; + const color = colors[index % colors.length]; + return kind === "points" + ? coords + .map( + ([x, y]) => + '', + ) + .join("") + : ''; + }) + .join(""); + const png = await sharp( + Buffer.from( + '' + + shapes + + "", + ), + ) + .png() + .toBuffer(); + return { + png, + summary: + values.length + + " source samples; " + + (kind === "points" + ? "XYZ oblique projection" + : groups.map((group) => group.name).join(", ")) + + "; display every " + + step + + " sample(s); axes " + + JSON.stringify({ xmin, xmax, ymin, ymax }) + + "; source metadata " + + JSON.stringify(metadata), + }; +} +async function cacheImage(paths: Paths, png: Buffer): Promise { + await mkdir(paths.cache, { recursive: true, mode: 0o700 }); + const path = join( + paths.cache, + createHash("sha256").update(png).digest("hex") + ".png", + ); + await writeFile(path, png, { mode: 0o600 }); + const entries = ( + await Promise.allSettled( + (await readdir(paths.cache)) + .filter((name) => /^[a-f0-9]{64}\.png$/.test(name)) + .map(async (name) => ({ + name, + info: await stat(join(paths.cache, name)), + })), + ) + ).flatMap((result) => (result.status === "fulfilled" ? [result.value] : [])); + let bytes = 0; + for (const entry of entries.sort((a, b) => b.info.mtimeMs - a.info.mtimeMs)) { + bytes += entry.info.size; + if (bytes > 128 * 1024 * 1024 && entry.name !== path.split("/").pop()) + await unlink(join(paths.cache, entry.name)).catch(() => {}); + } + return path; +} +export function imageComponent( + data: string, + mimeType: string, + summary = "", +): Component { + const block = new Container(); + if (summary) block.addChild(new Text(summary, 0, 0)); + block.addChild( + new Image( + data, + mimeType, + { fallbackColor: (text) => text }, + { maxHeightCells: 18, maxWidthCells: 80 }, + ), + ); + return block; +} +export const renderExtension = + (paths: Paths): ExtensionFactory => + (pi) => { + const pool = new MediaPool(); + pi.on("session_shutdown", () => pool.close()); + pi.registerTool({ + name: "dimcode_render", + label: "Inspect sensor result", + description: + "Render an actual saved result in the terminal and return a selected image to the model. image: existing image path. points: JSON {points:[[x,y,z]], frame?, timestamp?}. series: JSON {series:[{name,values:[[time,value]]}]}. These are exports of an already evaluated operation: never rerun a query just to render it. live: explicit existing relay URL, robot and channel; the terminal receives live frames directly, and only one final snapshot goes into model context.", + parameters: Type.Object({ + kind: Type.Union([ + Type.Literal("image"), + Type.Literal("points"), + Type.Literal("series"), + Type.Literal("live"), + ]), + path: Type.Optional(Type.String()), + url: Type.Optional(Type.String()), + robot: Type.Optional(Type.String()), + channel: Type.Optional(Type.String()), + seconds: Type.Optional(Type.Number({ minimum: 1, maximum: 30 })), + }), + execute: async (_id, args, signal, update, ctx) => { + let png: Buffer, + source: string, + summary: string, + digest: string | undefined; + if (args.kind === "live") { + if (!args.url || !args.channel) + throw new Error( + "Live rendering requires an existing relay URL and channel.", + ); + source = + args.url + + " / " + + (args.robot ?? "selected robot") + + " / " + + args.channel; + const live = { + url: args.url, + robot: args.robot, + channel: args.channel, + }; + update?.({ + content: [{ type: "text", text: "Inspecting live channel" }], + details: { + source, + summary: "Live context; timestamps may be send time.", + live, + } satisfies RenderDetails, + }); + const lease = await pool.acquire( + live, + args.channel, + (slot) => slot, + () => {}, + ); + try { + const end = Date.now() + (args.seconds ?? 5) * 1000; + while (Date.now() < end) + await delay(Math.min(100, end - Date.now()), undefined, { + signal, + }); + const slot = lease.current(); + if (!slot) + throw new Error("No frame received from selected channel"); + if (!(slot.value instanceof Uint8Array)) + throw new Error( + "This channel needs its domain decoder/renderer; use an exported result for this encoding.", + ); + png = await sharp(slot.value) + .resize({ + width: 1280, + height: 960, + fit: "inside", + withoutEnlargement: true, + }) + .png() + .toBuffer(); + summary = + "Selected live snapshot, sequence " + + slot.seq + + ", header timestamp " + + slot.ts + + ". Header time is not necessarily capture time."; + } finally { + lease.close(); + } + } else { + if (!args.path) throw new Error("Saved rendering requires a path"); + source = resolve(ctx.cwd, args.path); + const info = await stat(source); + if (info.size > 32 * 1024 * 1024) + throw new Error( + "Export exceeds 32 MiB; select a smaller view while retaining the original source.", + ); + const bytes = await readFile(source); + signal?.throwIfAborted(); + digest = createHash("sha256").update(bytes).digest("hex"); + if (args.kind === "image") { + png = await sharp(bytes) + .resize({ + width: 1280, + height: 960, + fit: "inside", + withoutEnlargement: true, + }) + .png() + .toBuffer(); + summary = "Preview of saved image; original file retained."; + } else + ({ png, summary } = await plot( + JSON.parse(bytes.toString("utf8")), + args.kind, + )); + } + const preview = await cacheImage(paths, png); + return { + content: [ + { + type: "text", + text: + summary + + "\nSource: " + + source + + (digest ? "\nSHA-256: " + digest : "") + + "\nPreview: " + + preview, + }, + { + type: "image", + data: png.toString("base64"), + mimeType: "image/png", + }, + ], + details: { source, sha256: digest, summary } satisfies RenderDetails, + }; + }, + renderResult: (result) => { + const image = result.content.find((item) => item.type === "image"); + const text = result.content + .filter((item) => item.type === "text") + .map((item) => item.text) + .join("\n"); + return image + ? imageComponent(image.data, image.mimeType, text) + : new Text(text, 0, 0); + }, + }); + }; diff --git a/agent/src/service.ts b/agent/src/service.ts new file mode 100644 index 00000000000..5a8e7bd31d6 --- /dev/null +++ b/agent/src/service.ts @@ -0,0 +1,104 @@ +import { spawn } from "node:child_process"; +import { mkdir, unlink, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join, resolve } from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; +import { Connection, reachable } from "./protocol.js"; +import type { Config, Paths } from "./config.js"; + +export function execute( + command: string, + args: readonly string[], + options: { cwd?: string; env?: NodeJS.ProcessEnv } = {}, +): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { ...options, stdio: "inherit" }); + child.once("error", reject); + child.once("exit", (code, signal) => + code === 0 + ? resolve() + : reject(new Error(command + " exited " + (code ?? signal))), + ); + }); +} +const quote = (value: string) => + '"' + + value.replace(/%/g, "%%").replace(/\\/g, "\\\\").replace(/"/g, '\\"') + + '"'; +export function serviceUnit( + config: Config, + paths: Paths, + executable: string, +): string { + return [ + "[Unit]", + "Description=dimcode agent gateway", + "", + "[Service]", + "Type=simple", + "ExecStart=" + + quote(process.execPath) + + " " + + quote(executable) + + " gateway", + "WorkingDirectory=" + config.workspace.replace(/%/g, "%%"), + "Environment=" + quote("DIMCODE_HOME=" + paths.config), + "Restart=on-failure", + "RestartSec=2", + "UMask=0077", + "", + "[Install]", + "WantedBy=default.target", + "", + ].join("\n"); +} +export async function service( + action: string, + config: Config, + paths: Paths, + executable: string, +): Promise { + const dir = join( + process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), + "systemd/user", + ); + const unit = join(dir, "dimcode.service"); + if (action === "install") { + const existing = new Connection(paths.socket); + try { + await existing.call({ type: "shutdown" }); + } catch { + } finally { + existing.close(); + } + for (let attempt = 0; await reachable(paths.socket); attempt++) { + if (attempt === 100) + throw new Error( + "Existing gateway did not stop; inspect dimcode service status.", + ); + await delay(50); + } + await mkdir(dir, { recursive: true }); + await writeFile(unit, serviceUnit(config, paths, resolve(executable)), { + mode: 0o600, + }); + await execute("systemctl", ["--user", "daemon-reload"]); + await execute("systemctl", [ + "--user", + "enable", + "--now", + "dimcode.service", + ]); + } else if (action === "uninstall") { + await execute("systemctl", [ + "--user", + "disable", + "--now", + "dimcode.service", + ]); + await unlink(unit); + await execute("systemctl", ["--user", "daemon-reload"]); + } else if (action === "status") + await execute("systemctl", ["--user", "status", "dimcode.service"]); + else throw new Error("Use service install, status or uninstall"); +} diff --git a/agent/src/session.ts b/agent/src/session.ts new file mode 100644 index 00000000000..89ffbe25d32 --- /dev/null +++ b/agent/src/session.ts @@ -0,0 +1,84 @@ +import { mkdir } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + createAgentSessionServices, + createAgentSessionFromServices, + type AgentSession, + SessionManager, + createBashToolDefinition, +} from "@earendil-works/pi-coding-agent"; +import { loadConfig, type Config, type Paths } from "./config.js"; +import { McpTools } from "./mcp.js"; +import { renderExtension } from "./render.js"; + +export interface SessionHandle { + session: AgentSession; + close(): Promise; +} +export async function openSession( + config: Config, + paths: Paths, + manager: SessionManager, + report: (message: string) => void, +): Promise { + await mkdir(paths.config, { recursive: true, mode: 0o700 }); + await mkdir(paths.sessions, { recursive: true, mode: 0o700 }); + const mcp = new McpTools(async () => (await loadConfig(paths)).mcp, report); + const services = await createAgentSessionServices({ + cwd: manager.getCwd(), + agentDir: paths.config, + resourceLoaderOptions: { + additionalSkillPaths: [ + fileURLToPath(new URL("../skills", import.meta.url)), + ], + extensionFactories: [ + mcp.extension(), + renderExtension(paths), + (pi) => { + pi.registerTool( + createBashToolDefinition(manager.getCwd(), { + spawnHook: (context) => ({ + ...context, + env: { + ...context.env, + PATH: [ + config.dimos && dirname(config.dimos), + config.python && dirname(config.python), + context.env.PATH, + ] + .filter(Boolean) + .join(":"), + }, + }), + }), + ); + }, + ], + appendSystemPrompt: [ + "You are dimcode, Dimensional's coding and robotics agent. Use DimOS CLI/public Python APIs and the advertised MCP skills. Do not create a parallel runtime or transport service. Select the intended run and endpoint explicitly; never assume the latest run is the intended one. Render actual saved results with dimcode_render. Live previews are labeled context, not historical query results.", + "Selected configuration: " + JSON.stringify(config), + ], + }, + }); + const { session } = await createAgentSessionFromServices({ + services, + sessionManager: manager, + }); + return { + session, + close: async () => { + try { + await session.extensionRunner.emit({ + type: "session_shutdown", + reason: "quit", + }); + } finally { + session.dispose(); + await mcp.close(); + } + }, + }; +} +export const authPath = (paths: Paths): string => + join(paths.config, "auth.json"); diff --git a/agent/src/setup.ts b/agent/src/setup.ts new file mode 100644 index 00000000000..404fd765135 --- /dev/null +++ b/agent/src/setup.ts @@ -0,0 +1,108 @@ +import { mkdir } from "node:fs/promises"; +import { join } from "node:path"; +import { createInterface } from "node:readline/promises"; +import { stdin, stdout } from "node:process"; +import { ModelRuntime } from "@earendil-works/pi-coding-agent"; +import type { AuthPrompt } from "@earendil-works/pi-ai"; +import { loadConfig, saveConfig, type Paths } from "./config.js"; + +export async function prompt(prompt: AuthPrompt): Promise { + if (!stdin.isTTY) + throw new Error( + "Interactive login requires a terminal; use setup --key-env for an environment API key.", + ); + if (prompt.type === "select") + stdout.write( + prompt.options + .map((option) => option.id + ": " + option.label) + .join("\n") + "\n", + ); + if (prompt.type !== "secret" && prompt.type !== "manual_code") { + const rl = createInterface({ input: stdin, output: stdout }); + try { + return await rl.question(prompt.message + " "); + } finally { + rl.close(); + } + } + stdout.write(prompt.message + " "); + const wasRaw = stdin.isRaw; + stdin.setRawMode(true); + stdin.resume(); + try { + return await new Promise((resolve, reject) => { + let value = ""; + const onData = (bytes: Buffer) => { + for (const character of bytes.toString("utf8")) { + if (character === "\x03" || character === "\x1b") { + cleanup(); + reject(new Error("Cancelled")); + return; + } + if (character === "\r" || character === "\n") { + cleanup(); + resolve(value); + return; + } + if (character === "\x7f" || character === "\b") + value = value.slice(0, -1); + else if (character >= " ") value += character; + } + }; + const cleanup = () => { + stdin.off("data", onData); + stdout.write("\n"); + }; + stdin.on("data", onData); + }); + } finally { + stdin.setRawMode(wasRaw); + stdin.pause(); + } +} +export async function setup( + paths: Paths, + options: { + provider?: string; + oauth?: boolean; + keyEnv?: string; + workspace?: string; + }, +): Promise { + const config = await loadConfig(paths); + if (options.workspace) config.workspace = options.workspace; + await saveConfig(paths, config); + await mkdir(paths.config, { recursive: true, mode: 0o700 }); + const runtime = await ModelRuntime.create({ + authPath: join(paths.config, "auth.json"), + modelsPath: join(paths.config, "models.json"), + }); + const provider = options.provider ?? "openai"; + if (provider === "anthropic" && options.oauth) + throw new Error("Use an Anthropic API key for dimcode."); + if (options.keyEnv) { + const key = process.env[options.keyEnv]; + if (!key) + throw new Error("Requested credential environment variable is empty"); + await runtime.login(provider, "api_key", { + prompt: async () => key, + notify: () => {}, + }); + } else { + await runtime.login(provider, options.oauth ? "oauth" : "api_key", { + prompt, + notify: (event) => { + stdout.write( + (event.type === "auth_url" + ? event.url + : event.type === "device_code" + ? event.verificationUri + " code: " + event.userCode + : event.message) + "\n", + ); + }, + }); + } + stdout.write( + "dimcode configured. Run dimcode to chat, or dimcode service install to start it at login.\n", + ); +} diff --git a/agent/src/terminal.ts b/agent/src/terminal.ts new file mode 100644 index 00000000000..afee1cb670d --- /dev/null +++ b/agent/src/terminal.ts @@ -0,0 +1,546 @@ +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { + Container, + Input, + ProcessTerminal, + Text, + TuiMainScreen, + truncateToWidth, + type Component, +} from "@earendil-works/pi-tui"; +import sharp from "sharp"; +import { + ToolExecutionComponent, + initTheme, + createBashToolDefinition, + createReadToolDefinition, + createEditToolDefinition, + createWriteToolDefinition, +} from "@earendil-works/pi-coding-agent"; +import { z } from "zod"; +import { Connection, type Event, type Snapshot } from "./protocol.js"; +import { imageComponent, renderDetails } from "./render.js"; +import { MediaPool, type MediaLease } from "./media.js"; +import type { Slot } from "@dimos/sdk"; + +const resultSchema = z.object({ + content: z + .array( + z.union([ + z.object({ type: z.literal("text"), text: z.string() }), + z.object({ + type: z.literal("image"), + data: z.string(), + mimeType: z.string(), + }), + ]), + ) + .default([]), + details: z.unknown().optional(), +}); +export class ChatInput implements Component { + private editor = new Input({ + prompt: "dimcode › ", + placeholder: "/help · Ctrl-C detaches", + }); + private masked = false; + focused = false; + onSubmit?: (value: string) => void; + onEscape?: () => void; + get secret(): boolean { + return this.masked; + } + set secret(value: boolean) { + if (value === this.masked) return; + this.masked = value; + // Discard the credential editor and its undo/kill-ring history on mode change. + this.editor = new Input({ prompt: "dimcode › " }); + } + getValue(): string { + return this.editor.getValue(); + } + setValue(value: string): void { + this.editor.setValue(value); + } + invalidate(): void { + this.editor.invalidate(); + } + handleInput(data: string): void { + this.editor.focused = this.focused; + this.editor.onSubmit = (value) => this.onSubmit?.(value); + this.editor.onEscape = () => this.onEscape?.(); + this.editor.handleInput(data); + } + render(width: number): string[] { + return this.secret + ? [ + truncateToWidth( + "credential › " + "*".repeat(this.getValue().length), + width, + ), + ] + : this.editor.render(width); + } +} +export async function terminal( + socket: string, + options: { sessionId?: string; cwd?: string; view?: boolean } = {}, +): Promise { + initTheme("dark", false); + const client = new Connection(socket), + pool = new MediaPool(); + const tui = new TuiMainScreen(new ProcessTerminal()), + transcript = new Container(); + const status = new Text("Connecting…", 0, 0), + input = new ChatInput(); + const cards = new Map< + string, + { + block: Container; + component: ToolExecutionComponent; + output: unknown; + expanded: boolean; + lease?: MediaLease; + close?: () => void; + } + >(); + let current: Snapshot, + assistant = new Text("", 0, 0), + text = "", + stopped = false; + let auth: Extract | undefined; + let restoring = true; + const pending: Array<{ seq: number; event: Event }> = []; + const add = (value: string) => transcript.addChild(new Text(value, 0, 0)); + const notice = (error: unknown) => { + add(String(error)); + tui.requestRender(); + }; + const closeCards = () => { + for (const card of cards.values()) card.close?.(); + cards.clear(); + }; + const draw = ( + id: string, + output: unknown, + toolName = "tool", + args: unknown = {}, + partial = false, + isError = false, + ) => { + let card = cards.get(id); + if (!card) { + const cwd = current?.cwd ?? options.cwd ?? process.cwd(); + const definitions = [ + createBashToolDefinition(cwd), + createReadToolDefinition(cwd), + createEditToolDefinition(cwd), + createWriteToolDefinition(cwd), + ]; + const component = new ToolExecutionComponent( + toolName, + id, + args, + { showImages: true }, + definitions.find((tool) => tool.name === toolName), + tui, + cwd, + ); + component.markExecutionStarted(); + component.setArgsComplete(); + card = { block: new Container(), component, output, expanded: false }; + cards.set(id, card); + transcript.addChild(card.block); + } + card.close?.(); + card.close = undefined; + card.output = output; + card.block.clear(); + card.block.addChild(card.component); + const result = resultSchema.safeParse(output); + if (!result.success) { + card.block.addChild(new Text("Running…", 0, 0)); + return; + } + const details = renderDetails.safeParse(result.data.details); + card.component.updateResult({ ...result.data, isError }, partial); + card.component.setExpanded(card.expanded); + if (details.success && details.data.live && !card.close) { + const live = details.data.live, + held = card; + const preview = new Container(); + held.block.addChild(preview); + let disposed = false, + drawing = false, + changed = true, + previous = -1; + const tick = async () => { + if (disposed || drawing || !changed) return; + const slot = held.lease?.current(); + if ( + !slot || + slot.version === previous || + !(slot.value instanceof Uint8Array) + ) + return; + drawing = true; + changed = false; + try { + const bytes = await sharp(slot.value) + .resize({ width: 960, height: 720, fit: "inside" }) + .png() + .toBuffer(); + if (disposed) return; + previous = slot.version; + preview.clear(); + preview.addChild( + imageComponent( + bytes.toString("base64"), + "image/png", + "LIVE · " + live.channel + " · seq " + slot.seq, + ), + ); + tui.requestRender(); + } catch (error) { + if (!disposed) notice(error); + } finally { + drawing = false; + } + }; + const timer = setInterval(() => void tick(), 100); + held.close = () => { + disposed = true; + clearInterval(timer); + held.lease?.close(); + }; + void pool + .acquire( + live, + live.channel, + (slot) => slot, + () => { + changed = true; + }, + ) + .then((lease) => { + if (disposed) lease.close(); + else held.lease = lease; + }) + .catch(notice); + } + }; + const apply = (event: Event) => { + if (event.type === "auth_prompt") { + auth = event; + input.secret = + event.prompt.type === "secret" || event.prompt.type === "manual_code"; + input.setValue(""); + status.setText( + event.prompt.message + + (event.prompt.type === "select" + ? "\n" + + event.prompt.options + .map((option) => option.id + ": " + option.label) + .join(" · ") + : ""), + ); + } else if (event.type === "auth_info") { + const info = event.info; + status.setText( + info.type === "auth_url" + ? info.url + "\n" + (info.instructions ?? "") + : info.type === "device_code" + ? info.verificationUri + " · " + info.userCode + : info.message, + ); + } else if (event.type === "text_delta") { + text += event.delta; + assistant.setText(text); + } else if ( + event.type === "message_end" && + event.message.role === "assistant" + ) { + text = event.message.content + .filter((item) => item.type === "text") + .map((item) => item.text) + .join("\n"); + assistant.setText(text); + } else if (event.type === "message_start") { + if (event.message.role === "assistant") { + text = ""; + assistant = new Text("", 0, 0); + transcript.addChild(assistant); + } else if (event.message.role === "user") { + const content = event.message.content; + add( + "you: " + + (typeof content === "string" + ? content + : content + .filter((item) => item.type === "text") + .map((item) => item.text) + .join("\n")), + ); + } + } else if (event.type === "tool_execution_start") { + draw( + event.toolCallId, + { content: [{ type: "text", text: "Running…" }] }, + event.toolName, + event.args, + true, + ); + } else if (event.type === "tool_execution_update") + draw( + event.toolCallId, + event.partialResult, + event.toolName, + event.args, + true, + ); + else if (event.type === "tool_execution_end") + draw( + event.toolCallId, + event.result, + event.toolName, + {}, + false, + event.isError, + ); + else if (event.type === "notice" || event.type === "turn_error") + notice(event.message); + else if (event.type === "idle") + status.setText(current.sessionId + " · idle"); + tui.requestRender(); + }; + client.onEvent = (seq, event) => { + if (restoring) pending.push({ seq, event }); + else apply(event); + }; + const restore = (snapshot: Snapshot) => { + closeCards(); + transcript.clear(); + current = snapshot; + add("dimcode · " + current.cwd + (current.writable ? "" : " · read-only")); + const argumentsByCall = new Map(); + for (const message of current.messages) { + if (message.role === "assistant") + for (const block of message.content) { + if (block.type === "toolCall") + argumentsByCall.set(block.id, block.arguments); + } + if ("content" in message) { + if (message.role === "toolResult") + draw( + message.toolCallId, + message, + message.toolName, + argumentsByCall.get(message.toolCallId), + false, + message.isError, + ); + else { + const content = + typeof message.content === "string" + ? message.content + : message.content + .filter((item) => item.type === "text") + .map((item) => item.text) + .join("\n"); + if (content) add(message.role + ": " + content); + } + } + } + for (const message of current.notices) add(message); + text = current.text; + assistant = new Text(text, 0, 0); + transcript.addChild(assistant); + for (const event of current.tools) apply(event); + status.setText( + current.sessionId + " · " + (current.busy ? "running" : "idle"), + ); + restoring = false; + for (const packet of pending.splice(0)) + if (packet.seq > current.seq) apply(packet.event); + tui.requestRender(); + }; + const stop = () => { + if (stopped) return; + stopped = true; + closeCards(); + pool.close(); + tui.stop(); + client.close(); + }; + try { + restore( + await client.call( + options.sessionId + ? { + type: "attach", + sessionId: options.sessionId, + writable: !options.view, + } + : { type: "new_session", cwd: options.cwd }, + ), + ); + tui.addChild(transcript); + tui.addChild(status); + tui.addChild(input); + tui.setFocus(input); + input.onSubmit = (value) => { + input.setValue(""); + if (auth) { + const promptId = auth.promptId; + auth = undefined; + input.secret = false; + void client + .call({ type: "ui_response", promptId, value }) + .catch(notice); + return; + } + void (async () => { + if (value === "/exit") { + stop(); + return; + } + if (value === "/help") { + add( + "/new · /sessions · /resume ID · /model PROVIDER MODEL · /models · /login PROVIDER [oauth] · /logout PROVIDER · /abort · /steer TEXT · /follow TEXT · /reload · /image PATH · /expand · /exit", + ); + return; + } + if (value === "/sessions") { + notice( + await client + .call({ type: "list_sessions" }) + .then((list) => + list.map((item) => item.id + " " + item.name).join("\n"), + ), + ); + return; + } + if (value === "/new" || value.startsWith("/resume ")) { + restoring = true; + try { + restore( + await client.call( + value === "/new" + ? { type: "new_session", cwd: current.cwd } + : { + type: "attach", + sessionId: value.slice(8).trim(), + writable: true, + }, + ), + ); + } catch (error) { + restoring = false; + throw error; + } + return; + } + if (value === "/expand") { + for (const card of cards.values()) { + card.expanded = !card.expanded; + card.component.setExpanded(card.expanded); + } + return; + } + if (value === "/abort") { + await client.call({ type: "abort" }); + return; + } + if (value === "/reload") { + await client.call({ type: "reload" }); + return; + } + if (value === "/models") { + notice( + (await client.call({ type: "get_available_models" })) + .map((model) => model.provider + " " + model.id) + .join("\n"), + ); + return; + } + const [command, provider, option] = value.split(/\s+/); + if (command === "/login") { + await client.call({ + type: "login", + provider, + authType: option === "oauth" ? "oauth" : "api_key", + }); + status.setText("Login complete"); + return; + } + if (command === "/logout") { + await client.call({ type: "logout", provider }); + return; + } + if (command === "/model") { + await client.call({ type: "set_model", provider, modelId: option }); + return; + } + if (command === "/steer" || command === "/follow") { + await client.call({ + type: command === "/steer" ? "steer" : "follow_up", + message: value.slice(command.length + 1), + }); + return; + } + if (command === "/image") { + const bytes = await sharp( + await readFile(resolve(current.cwd, value.slice(7))), + ) + .resize({ width: 1280, height: 960, fit: "inside" }) + .png() + .toBuffer(); + await client.call({ + type: "prompt", + message: "Inspect this attached image.", + images: [ + { + type: "image", + mimeType: "image/png", + data: bytes.toString("base64"), + }, + ], + }); + return; + } + if (value.trim()) { + status.setText(current.sessionId + " · running"); + await client.call({ type: "prompt", message: value }); + } + })() + .catch(notice) + .finally(() => tui.requestRender()); + }; + input.onEscape = () => { + if (auth) { + const promptId = auth.promptId; + auth = undefined; + input.secret = false; + input.setValue(""); + void client.call({ type: "ui_response", promptId }).catch(notice); + } else void client.call({ type: "abort" }).catch(notice); + }; + tui.addInputListener((data) => { + if (data === "\x03") { + stop(); + return { consume: true }; + } + return undefined; + }); + tui.start(); + await new Promise((resolve) => { + client.onClose = () => { + stop(); + resolve(); + }; + }); + } finally { + stop(); + } +} diff --git a/agent/test/fixtures/mcp_server.py b/agent/test/fixtures/mcp_server.py new file mode 100644 index 00000000000..5f97052b902 --- /dev/null +++ b/agent/test/fixtures/mcp_server.py @@ -0,0 +1,50 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Actual DimOS MCP handlers with deterministic tools; no coordinator or robot.""" + +import json +import os + +import numpy as np +import uvicorn + +from dimos.agents.mcp.mcp_server import app +from dimos.agents.skill_result import SkillResult +from dimos.core.module import SkillInfo +from dimos.msgs.sensor_msgs.Image import Image + +app.state.skills = [ + SkillInfo( + class_name="Fixture", + func_name="echo", + args_schema=json.dumps( + {"type": "object", "properties": {"value": {"type": "string"}}, "required": ["value"]} + ), + ) +] +for name in ("image", "failure"): + app.state.skills.append( + SkillInfo( + class_name="Fixture", + func_name=name, + args_schema=json.dumps({"type": "object", "properties": {}}), + ) + ) +app.state.rpc_calls = { + "echo": lambda value, **kwargs: value, + "image": lambda **kwargs: Image(np.zeros((8, 8, 3), dtype=np.uint8)), + "failure": lambda **kwargs: SkillResult.fail("FIXTURE_FAILURE", "Expected failure"), +} +uvicorn.run(app, host="127.0.0.1", port=int(os.environ["DIMCODE_TEST_PORT"]), log_level="error") diff --git a/agent/test/fixtures/session.ts b/agent/test/fixtures/session.ts new file mode 100644 index 00000000000..236e60b5a99 --- /dev/null +++ b/agent/test/fixtures/session.ts @@ -0,0 +1,134 @@ +import { + createAgentSessionServices, + createAgentSessionFromServices, + type ExtensionFactory, +} from "@earendil-works/pi-coding-agent"; +import { + createAssistantMessageEventStream, + type AssistantMessage, +} from "@earendil-works/pi-ai"; +import { Type } from "typebox"; +import type { SessionFactory } from "../../src/gateway.js"; + +export function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} +export function fixtureFactory( + wait: Promise, + started: () => void, +): SessionFactory { + return async (_config, paths, manager) => { + const extension: ExtensionFactory = (pi) => { + pi.registerTool({ + name: "wait_for_test", + label: "Wait", + description: "Deterministic test operation", + parameters: Type.Object({}), + execute: async (_id, _args, signal) => { + started(); + await Promise.race([ + wait, + new Promise((_, reject) => + signal?.addEventListener( + "abort", + () => reject(new Error("aborted")), + { once: true }, + ), + ), + ]); + return { + content: [{ type: "text", text: "operation finished" }], + details: {}, + }; + }, + }); + pi.registerProvider("dimcode-fixture", { + baseUrl: "http://localhost.invalid", + apiKey: "nonsecret-fixture", + api: "openai-completions", + models: [ + { + id: "fixture", + name: "fixture", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 32000, + maxTokens: 100, + }, + ], + streamSimple: (model, context) => { + const stream = createAssistantMessageEventStream(); + queueMicrotask(() => { + const done = context.messages.at(-1)?.role === "toolResult"; + const message: AssistantMessage = { + role: "assistant", + api: model.api, + provider: model.provider, + model: model.id, + content: done + ? [{ type: "text", text: "done" }] + : [ + { + type: "toolCall", + id: "call-" + context.messages.length, + name: "wait_for_test", + arguments: {}, + }, + ], + stopReason: done ? "stop" : "toolUse", + timestamp: Date.now(), + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + total: 0, + }, + }, + }; + stream.push({ type: "start", partial: message }); + stream.push({ + type: "done", + reason: done ? "stop" : "toolUse", + message, + }); + stream.end(); + }); + return stream; + }, + }); + }; + const services = await createAgentSessionServices({ + cwd: manager.getCwd(), + agentDir: paths.config, + resourceLoaderOptions: { + extensionFactories: [extension], + noExtensions: true, + noSkills: true, + }, + }); + const { session } = await createAgentSessionFromServices({ + services, + sessionManager: manager, + model: services.modelRuntime.getModel("dimcode-fixture", "fixture"), + }); + await session.bindExtensions({ mode: "rpc" }); + return { + session, + close: async () => { + session.dispose(); + }, + }; + }; +} diff --git a/agent/test/gateway.test.ts b/agent/test/gateway.test.ts new file mode 100644 index 00000000000..35eefeb2faa --- /dev/null +++ b/agent/test/gateway.test.ts @@ -0,0 +1,168 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { mkdtemp, mkdir, readFile, readdir, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { randomUUID } from "node:crypto"; +import { Gateway } from "../src/gateway.js"; +import { Connection, type Event } from "../src/protocol.js"; +import { configSchema, type Paths } from "../src/config.js"; +import { deferred, fixtureFactory } from "./fixtures/session.js"; + +test( + "detached turn, ownership, deduplicated prompt and persisted resume", + { timeout: 20000 }, + async (t) => { + const root = await mkdtemp(join(tmpdir(), "dimcode-")); + t.after(() => rm(root, { recursive: true, force: true })); + const p: Paths = { + config: join(root, "config"), + state: root, + cache: join(root, "cache"), + sessions: join(root, "sessions"), + socket: join(root, "test.sock"), + }; + await mkdir(p.config); + await mkdir(p.sessions); + const release = deferred(), + started = deferred(); + const config = configSchema.parse({ workspace: root }); + let gateway = new Gateway( + config, + p, + fixtureFactory(release.promise, () => started.resolve()), + ); + await gateway.start(); + t.after(() => gateway.close()); + const first = new Connection(p.socket), + viewer = new Connection(p.socket); + t.after(() => { + first.close(); + viewer.close(); + }); + const snapshot = await first.call({ type: "new_session" }); + await viewer.call({ + type: "attach", + sessionId: snapshot.sessionId, + writable: false, + }); + await assert.rejects( + viewer.call({ type: "prompt", message: "forbidden" }), + /read-only/, + ); + // Provider credentials travel only to the input owner and Pi's credential store. + const auth = deferred>(); + const viewerEvents: Event[] = []; + first.onEvent = (_seq, event) => { + if (event.type === "auth_prompt") auth.resolve(event); + }; + viewer.onEvent = (_seq, event) => viewerEvents.push(event); + const login = first.call({ + type: "login", + provider: "openai", + authType: "api_key", + }); + const dialog = await auth.promise; + assert.equal(dialog.prompt.type, "secret"); + await assert.rejects( + viewer.call({ + type: "ui_response", + promptId: dialog.promptId, + value: "not-owner", + }), + /read-only/, + ); + const credential = "local-fixture-credential"; + await first.call({ + type: "ui_response", + promptId: dialog.promptId, + value: credential, + }); + await login; + assert( + !viewerEvents.some( + (event) => event.type === "auth_prompt" || event.type === "auth_info", + ), + ); + assert.equal((await stat(join(p.config, "auth.json"))).mode & 0o777, 0o600); + const id = randomUUID(); + await first.call({ type: "prompt", message: "wait" }, id); + await started.promise; + await first.call({ type: "prompt", message: "wait" }, id); + const closed = deferred(); + first.onClose = () => closed.resolve(); + first.close(); + await closed.promise; + const state = await viewer.call({ type: "get_state" }); + assert.equal(state.busy, true); + assert(state.tools.some((tool) => tool.type === "tool_execution_start")); + const finished = deferred(); + viewer.onEvent = (_seq, event: Event) => { + if (event.type === "idle") finished.resolve(); + }; + release.resolve(); + await finished.promise; + assert.equal( + (await viewer.call({ type: "get_state" })).messages.filter( + (message) => message.role === "user", + ).length, + 1, + ); + for (const name of await readdir(p.sessions, { recursive: true })) { + if (name.endsWith(".jsonl")) + assert( + !(await readFile(join(p.sessions, name), "utf8")).includes( + credential, + ), + ); + } + viewer.close(); + await gateway.close(); + gateway = new Gateway( + config, + p, + fixtureFactory(Promise.resolve(), () => {}), + ); + await gateway.start(); + const resumed = new Connection(p.socket); + t.after(() => resumed.close()); + const simultaneous = new Connection(p.socket); + t.after(() => simultaneous.close()); + const [restored, observed] = await Promise.all([ + resumed.call({ + type: "attach", + sessionId: snapshot.sessionId, + writable: true, + }), + simultaneous.call({ + type: "attach", + sessionId: snapshot.sessionId, + writable: false, + }), + ]); + assert.equal(restored.sessionId, observed.sessionId); + assert(restored.messages.some((message) => message.role === "toolResult")); + assert.equal(restored.busy, false); + await resumed.call({ type: "prompt", message: "wait" }, id); + assert.equal( + (await resumed.call({ type: "get_state" })).messages.filter( + (message) => message.role === "user", + ).length, + 1, + ); + const second = new Connection(p.socket); + t.after(() => second.close()); + const cwd = join(root, "second"); + await mkdir(cwd); + assert.equal((await second.call({ type: "new_session", cwd })).cwd, cwd); + const unattached = new Connection(p.socket); + const disconnected = deferred(); + unattached.onClose = () => disconnected.resolve(); + await gateway.close(); + await disconnected.promise; + await assert.rejects( + unattached.call({ type: "list_sessions" }), + /disconnected/, + ); + }, +); diff --git a/agent/test/integration.test.ts b/agent/test/integration.test.ts new file mode 100644 index 00000000000..8a1d09bfa6c --- /dev/null +++ b/agent/test/integration.test.ts @@ -0,0 +1,208 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { spawn, type ChildProcess } from "node:child_process"; +import { createServer } from "node:net"; +import { once } from "node:events"; +import { resolve } from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; +import { + Client, + StreamableHTTPClientTransport, +} from "@modelcontextprotocol/client"; +import { WebTransport } from "rwebtransport"; +import sharp from "sharp"; +import { + CONTROL_CHANNEL, + PROTOCOL_VERSION, + encodeDataFrame, + encodeDatagram, +} from "@dimos/shared"; +import { MediaPool } from "../src/media.js"; +import { McpTools, toolName } from "../src/mcp.js"; + +async function stop(child: ChildProcess): Promise { + if (child.exitCode !== null || child.signalCode) return; + child.kill("SIGTERM"); + await once(child, "exit"); +} +async function port(): Promise { + const server = createServer(); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const address = server.address(); + assert(address && typeof address !== "string"); + await new Promise((resolve) => server.close(() => resolve())); + return address.port; +} +test( + "official MCP client consumes actual DimOS handlers, images and errors", + { timeout: 30000, skip: !process.env.DIMCODE_TEST_PYTHON }, + async (t) => { + const endpoint = await port(); + const child = spawn( + process.env.DIMCODE_TEST_PYTHON!, + ["test/fixtures/mcp_server.py"], + { + env: { + ...process.env, + PYTHONPATH: resolve(".."), + DIMCODE_TEST_PORT: String(endpoint), + }, + stdio: ["ignore", "ignore", "pipe"], + }, + ); + t.after(() => stop(child)); + const url = "http://127.0.0.1:" + endpoint + "/mcp"; + const deadline = Date.now() + 15000; + while (true) { + try { + await fetch(url, { + method: "POST", + body: JSON.stringify({ + jsonrpc: "2.0", + method: "notifications/initialized", + }), + }); + break; + } catch { + if (Date.now() > deadline) + throw new Error("MCP server startup deadline"); + await delay(50); + } + } + const client = new Client({ name: "dimcode-test", version: "0.1.0" }); + t.after(() => client.close()); + await client.connect(new StreamableHTTPClientTransport(new URL(url))); + const listing = await client.listTools(); + assert(listing.tools.some((tool) => tool.name === "image")); + assert.deepEqual( + (await client.callTool({ name: "echo", arguments: { value: "exact" } })) + .content, + [{ type: "text", text: "exact" }], + ); + assert.equal( + (await client.callTool({ name: "image", arguments: {} })).content[0].type, + "image", + ); + const failed = await client.callTool({ name: "failure", arguments: {} }); + assert.equal(failed.isError, true); + assert.match(JSON.stringify(failed.content), /FIXTURE_FAILURE/); + let endpoints = [{ name: "fixture", url }]; + const registry = new McpTools(async () => endpoints); + t.after(() => registry.close()); + const tools = await registry.tools(); + assert.equal(tools.length, listing.tools.length); + assert.notEqual(toolName("a", "tool.name"), toolName("a", "tool_name")); + assert( + toolName("long-endpoint".repeat(10), "long-tool".repeat(20)).length <= 64, + ); + endpoints = []; + assert.deepEqual(await registry.tools(), []); + endpoints = [{ name: "fixture", url }]; + assert.equal((await registry.tools()).length, listing.tools.length); + assert.deepEqual( + tools.find((tool) => tool.label.endsWith("/ echo"))?.parameters, + listing.tools.find((tool) => tool.name === "echo")?.inputSchema, + ); + }, +); + +test( + "Node Web SDK receives real JPEG over the existing Deno QUIC relay", + { timeout: 30000, skip: !process.env.DIMCODE_TEST_DENO }, + async (t) => { + const child = spawn( + process.env.DIMCODE_TEST_DENO!, + [ + "run", + "--allow-net", + "--allow-read", + "../web/relay/main.ts", + "--port", + "0", + ], + { stdio: ["ignore", "pipe", "pipe"] }, + ); + t.after(() => stop(child)); + const info = await new Promise<{ + httpPort: number; + wtUrl: string; + certHash: string; + }>((resolve, reject) => { + let buffer = ""; + child.stdout.on("data", (chunk) => { + buffer += chunk.toString(); + for (const line of buffer.split("\n").slice(0, -1)) { + if (line.startsWith('{"event":"ready"')) resolve(JSON.parse(line)); + } + buffer = buffer.slice(buffer.lastIndexOf("\n") + 1); + }); + child.once("error", reject); + child.once("exit", (code) => reject(new Error("Relay exited " + code))); + }); + const robot = new WebTransport(info.wtUrl + "/robot", { + serverCertificateHashes: [ + { + algorithm: "sha-256", + value: Uint8Array.from(Buffer.from(info.certHash, "base64")).buffer, + }, + ], + }); + t.after(() => robot.close()); + await robot.ready; + const send = async (ch: string, seq: number, bytes: Uint8Array) => { + const stream = await robot.createBidirectionalStream(); + const writer = stream.writable.getWriter(); + await writer.write( + encodeDataFrame({ ch, seq, ts: seq, delivery: "latest" }, bytes), + ); + await writer.close(); + }; + await send( + CONTROL_CHANNEL, + 0, + encodeDatagram({ + t: "hello", + v: PROTOCOL_VERSION, + role: "robot", + robot: { id: "fixture", name: "Fixture", model: "test" }, + manifest: { + version: 1, + channels: [ + { + ch: "camera", + encoding: "jpeg.v1", + delivery: "latest", + maxHz: 30, + }, + ], + }, + }), + ); + const reader = robot.datagrams.readable.getReader(); + await reader.read(); + reader.releaseLock(); + const pool = new MediaPool(); + t.after(() => pool.close()); + const lease = await pool.acquire( + { url: "http://127.0.0.1:" + info.httpPort, robot: "fixture" }, + "camera", + (slot) => slot, + () => {}, + ); + t.after(() => lease.close()); + const jpeg = await sharp({ + create: { width: 64, height: 32, channels: 3, background: "#40d0a0" }, + }) + .jpeg() + .toBuffer(); + const deadline = Date.now() + 10000; + let seq = 1; + while (!lease.current()) { + if (Date.now() > deadline) throw new Error("JPEG reception deadline"); + await send("camera", seq++, jpeg); + await delay(25); + } + assert.deepEqual(lease.current()?.value, new Uint8Array(jpeg)); + }, +); diff --git a/agent/test/lifecycle.test.ts b/agent/test/lifecycle.test.ts new file mode 100644 index 00000000000..ad115b748c1 --- /dev/null +++ b/agent/test/lifecycle.test.ts @@ -0,0 +1,94 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import { configSchema, type Paths } from "../src/config.js"; +import { Gateway } from "../src/gateway.js"; +import { Connection, reachable } from "../src/protocol.js"; +import { serviceUnit } from "../src/service.js"; +import { deferred, fixtureFactory } from "./fixtures/session.js"; + +test( + "recover a stale socket and interrupted turn without replaying the operation", + { timeout: 20000 }, + async (t) => { + const root = await mkdtemp(join(tmpdir(), "dimcode-recovery-")); + t.after(() => rm(root, { recursive: true, force: true })); + const p: Paths = { + config: join(root, "config"), + state: root, + sessions: join(root, "sessions"), + cache: join(root, "cache"), + socket: join(root, "gateway.sock"), + }; + const stale = spawn(process.execPath, [ + "--input-type=module", + "-e", + 'import {createServer} from "node:net";createServer().listen(process.argv[1],()=>process.stdout.write("ready"));', + p.socket, + ]); + t.after(() => { + stale.kill(); + }); + await once(stale.stdout, "data"); + stale.kill("SIGKILL"); + await once(stale, "exit"); + const started = deferred(), + release = deferred(); + let calls = 0; + const config = configSchema.parse({ workspace: root }); + let gateway = new Gateway( + config, + p, + fixtureFactory(release.promise, () => { + calls++; + started.resolve(); + }), + ); + await gateway.start(); + t.after(() => gateway.close()); + await assert.rejects(new Gateway(config, p).start(), /EADDRINUSE/); + assert.equal(await reachable(p.socket), true); + const client = new Connection(p.socket); + t.after(() => client.close()); + const snapshot = await client.call({ type: "new_session" }); + await client.call({ type: "prompt", message: "wait" }); + await started.promise; + await gateway.close(); + gateway = new Gateway( + config, + p, + fixtureFactory(Promise.resolve(), () => { + calls++; + }), + ); + await gateway.start(); + const resumed = new Connection(p.socket); + t.after(() => resumed.close()); + const state = await resumed.call({ + type: "attach", + sessionId: snapshot.sessionId, + writable: true, + }); + assert.equal(state.busy, false); + assert.equal(calls, 1); + assert(state.notices.some((notice) => notice.includes("not replayed"))); + assert(state.messages.some((message) => message.role === "user")); + const unit = serviceUnit( + config, + p, + join(root, "path with spaces", "100%.js"), + ); + assert( + unit.includes( + '"' + join(root, "path with spaces", "100%%.js") + '" gateway', + ), + ); + assert(unit.includes('Environment="DIMCODE_HOME=' + p.config + '"')); + assert(unit.includes("WorkingDirectory=" + root + "\n")); + assert(!unit.includes("api_key")); + }, +); diff --git a/agent/test/media.test.ts b/agent/test/media.test.ts new file mode 100644 index 00000000000..4721de0fafe --- /dev/null +++ b/agent/test/media.test.ts @@ -0,0 +1,105 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { connect, createDecoderRegistry } from "@dimos/sdk"; +import { + FakeRelayEnd, + INFO, + ROBOT_A, + manifest, + spec, + until, +} from "../../web/sdk/src/testing/fakeRelay.ts"; +import { MediaPool } from "../src/media.js"; + +test("generic media is lazy, shared, byte exact and released by its last consumer", async () => { + const relay = new FakeRelayEnd(); + let opens = 0, + loads = 0; + const decoders = createDecoderRegistry(); + decoders.register("test.xyz.v1", (bytes, header) => ({ + value: { bytes, meta: header.meta }, + })); + const pool = new MediaPool( + async () => { + loads++; + return { + fetchInfo: async () => INFO, + createWebTransport: () => relay.wt, + }; + }, + { decoders }, + (options, deps) => { + opens++; + return connect(options, deps); + }, + ); + assert.equal(loads, 0); + const first = await pool.acquire( + { robot: ROBOT_A.id }, + "cloud", + (slot) => slot, + () => {}, + ); + const second = await pool.acquire( + { robot: ROBOT_A.id }, + "cloud", + (slot) => slot, + () => {}, + ); + try { + assert.equal(opens, 1); + assert.equal(loads, 1); + relay.push({ t: "welcome", v: INFO.v }); + relay.push({ t: "robots", robots: [ROBOT_A] }); + relay.pushManifest( + ROBOT_A.id, + manifest([spec({ ch: "cloud", encoding: "test.xyz.v1" })]), + ); + await until(() => relay.subs().length === 1, "one wire subscription"); + const bytes = new Uint8Array(new Float32Array([1, 2, 3, -1, 0, 4]).buffer); + relay.pushRaw(1, bytes, "cloud", { + observation: "actual-selection", + frame: "map", + }); + await until(() => first.current() !== undefined, "decoded cloud"); + assert.deepEqual(first.current()?.value, { + bytes, + meta: { observation: "actual-selection", frame: "map" }, + }); + first.close(); + first.close(); + assert(second.current()); + second.close(); + assert.equal(opens, 1); + } finally { + first.close(); + second.close(); + pool.close(); + } +}); + +test("closing a pool during lazy initialization cannot create a leaked connection", async () => { + let complete!: (value: {}) => void; + const deps = new Promise<{}>((resolve) => { + complete = resolve; + }); + let opens = 0; + const pool = new MediaPool( + () => deps, + {}, + (options, transport) => { + opens++; + return connect(options, transport); + }, + ); + const pending = pool.acquire( + {}, + "image", + (slot) => slot, + () => {}, + ); + pool.close(); + complete({}); + await assert.rejects(pending, /closed/); + assert.equal(opens, 0); +}); diff --git a/agent/test/render.test.ts b/agent/test/render.test.ts new file mode 100644 index 00000000000..a9ef00b1547 --- /dev/null +++ b/agent/test/render.test.ts @@ -0,0 +1,48 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import sharp from "sharp"; +import { plot } from "../src/render.js"; +import { ChatInput } from "../src/terminal.js"; + +test("point projection and measured series produce real images without mutating inputs", async () => { + const input = { + points: [ + [0, 0, 0], + [1, 0, 2], + [1, 2, 3], + ], + frame: "map", + }; + const before = JSON.stringify(input); + const result = await plot(input, "points"); + assert.equal(JSON.stringify(input), before); + assert.match(result.summary, /3 source samples/); + assert.equal((await sharp(result.png).metadata()).width, 800); + const compare = await plot( + { + series: [ + { + name: "duration_ms", + values: [ + [1, 12], + [2, 17], + [3, 4], + ], + }, + ], + }, + "series", + ); + assert.match(compare.summary, /duration_ms/); + await assert.rejects(plot({ points: [[NaN, 1, 2]] }, "points")); +}); +test("credentials cannot return through input undo after auth", () => { + const input = new ChatInput(); + input.secret = true; + input.handleInput("fixture-secret"); + assert(!input.render(80).join("").includes("fixture-secret")); + input.secret = false; + input.handleInput("\x1f"); + input.handleInput("\x19"); + assert.equal(input.getValue(), ""); +}); diff --git a/agent/test/run.mjs b/agent/test/run.mjs new file mode 100644 index 00000000000..284a1240fdd --- /dev/null +++ b/agent/test/run.mjs @@ -0,0 +1,29 @@ +import { build } from "esbuild"; +import { readdir } from "node:fs/promises"; +import { resolve } from "node:path"; +import { spawn } from "node:child_process"; +const tests = (await readdir("test")).filter((name) => + name.endsWith(".test.ts"), +); +await build({ + entryPoints: tests.map((name) => "test/" + name), + outdir: ".test", + bundle: true, + format: "esm", + platform: "node", + target: "node24", + packages: "external", + alias: { + "@dimos/sdk": resolve("../web/sdk/src/index.ts"), + "@dimos/shared/manifest": resolve("../web/shared/manifest.ts"), + "@dimos/shared": resolve("../web/shared/protocol.ts"), + }, +}); +const child = spawn( + process.execPath, + ["--test", ...tests.map((name) => ".test/" + name.replace(/\.ts$/, ".js"))], + { stdio: "inherit" }, +); +child.on("exit", (code) => { + process.exitCode = code ?? 1; +}); diff --git a/agent/tsconfig.json b/agent/tsconfig.json new file mode 100644 index 00000000000..e2a71fb44b3 --- /dev/null +++ b/agent/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "skipLibCheck": true, + "allowImportingTsExtensions": true, + "noEmit": true, + "types": [ + "node" + ], + "paths": { + "@dimos/sdk": [ + "../web/sdk/src/index.ts" + ], + "@dimos/shared": [ + "../web/shared/protocol.ts" + ], + "@dimos/shared/manifest": [ + "../web/shared/manifest.ts" + ] + }, + "customConditions": [ + "node" + ] + }, + "include": [ + "src/**/*.ts", + "test/**/*.ts" + ] +} diff --git a/dimos/agents/mcp/mcp_server.py b/dimos/agents/mcp/mcp_server.py index 61d7572af8c..cb9b3a4dc7f 100644 --- a/dimos/agents/mcp/mcp_server.py +++ b/dimos/agents/mcp/mcp_server.py @@ -32,6 +32,7 @@ from dimos.agents.annotation import skill from dimos.agents.capabilities import CapabilityRegistry from dimos.agents.mcp import tool_stream +from dimos.agents.skill_result import SkillResult from dimos.core.core import rpc from dimos.core.module import Module from dimos.core.rpc_client import RpcCall, RPCClient @@ -72,8 +73,26 @@ def _jsonrpc_result(req_id: Any, result: Any) -> dict[str, Any]: return {"jsonrpc": "2.0", "id": req_id, "result": result} -def _jsonrpc_result_text(req_id: Any, text: str) -> dict[str, Any]: - return _jsonrpc_result(req_id, {"content": [{"type": "text", "text": text}]}) +def _jsonrpc_result_text(req_id: Any, text: str, *, is_error: bool = False) -> dict[str, Any]: + return _jsonrpc_result( + req_id, {"content": [{"type": "text", "text": text}], "isError": is_error} + ) + + +def _mcp_content(blocks: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Translate the existing agent image encoding at the MCP boundary.""" + content = [] + for block in blocks: + if block.get("type") == "image_url": + url = block["image_url"]["url"] + if not url.startswith("data:image/") or ";base64," not in url: + content.append({"type": "resource_link", "name": "image", "uri": url}) + continue + header, data = url.split(";base64,", 1) + content.append({"type": "image", "mimeType": header[5:], "data": data}) + else: + content.append(block) + return content def _jsonrpc_error(req_id: Any, code: int, message: str) -> dict[str, Any]: @@ -122,7 +141,7 @@ async def _handle_tools_call( rpc_call = rpc_calls.get(name) if rpc_call is None: logger.warning("MCP tool not found", tool=name) - return _jsonrpc_result_text(req_id, f"Tool not found: {name}") + return _jsonrpc_result_text(req_id, f"Tool not found: {name}", is_error=True) skill_info = app.state.skills_by_name.get(name) uses: list[str] = list(skill_info.uses) if skill_info is not None else [] @@ -173,6 +192,7 @@ def _can_wait(holder: str) -> bool: return _jsonrpc_result_text( req_id, f"Cannot start '{name}': capability '{cap}' is held by '{holder}'. {advice}", + is_error=True, ) logger.info("MCP tool call", tool=name, args=args, progress_token=progress_token) @@ -201,7 +221,7 @@ def _can_wait(holder: str) -> bool: ) except Exception as e: logger.exception("MCP tool error", tool=name, duration=f"{time.monotonic() - t0:.3f}s") - return _jsonrpc_result_text(req_id, f"Error running tool '{name}': {e}") + return _jsonrpc_result_text(req_id, f"Error running tool '{name}': {e}", is_error=True) if lifecycle == "background": # Hand ownership of the caps off to the tool-stream lifecycle. @@ -215,7 +235,13 @@ def _can_wait(holder: str) -> bool: if hasattr(result, "agent_encode"): logger.info("MCP tool done", tool=name, duration=duration, response=response) - return _jsonrpc_result(req_id, {"content": result.agent_encode()}) + return _jsonrpc_result( + req_id, + { + "content": _mcp_content(result.agent_encode()), + "isError": isinstance(result, SkillResult) and not result.success, + }, + ) logger.info("MCP tool done", tool=name, duration=duration, response=response) return _jsonrpc_result_text(req_id, str(result)) diff --git a/dimos/agents/mcp/test_mcp_server.py b/dimos/agents/mcp/test_mcp_server.py index 0e2a74925b9..024ed70a641 100644 --- a/dimos/agents/mcp/test_mcp_server.py +++ b/dimos/agents/mcp/test_mcp_server.py @@ -20,7 +20,8 @@ from unittest.mock import MagicMock from dimos.agents.capabilities import CapabilityRegistry -from dimos.agents.mcp.mcp_server import app, handle_request +from dimos.agents.mcp.mcp_server import _mcp_content, app, handle_request +from dimos.agents.skill_result import SkillResult from dimos.core.module import SkillInfo @@ -334,3 +335,18 @@ def test_instant_holder_conflict_waits_then_runs() -> None: app.state.skills_by_name = saved_skills app.state.cap_registry = saved_registry app.state.cap_acquire_timeout = saved_timeout + + +def test_mcp_image_and_structured_failure() -> None: + encoded = [{"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,YWJj"}}] + assert _mcp_content(encoded) == [{"type": "image", "mimeType": "image/jpeg", "data": "YWJj"}] + result = asyncio.run( + handle_request( + {"id": 5, "method": "tools/call", "params": {"name": "fail", "arguments": {}}}, + [], + {"fail": lambda: SkillResult.fail("EXPECTED", "fixture")}, + ) + ) + assert result is not None + assert result["result"]["isError"] is True + assert "EXPECTED" in result["result"]["content"][0]["text"] diff --git a/dimos/cli/commands/lifecycle.py b/dimos/cli/commands/lifecycle.py index 63abf44a09b..86b44098ff5 100644 --- a/dimos/cli/commands/lifecycle.py +++ b/dimos/cli/commands/lifecycle.py @@ -37,7 +37,7 @@ write_daemon_status, ) from dimos.core.global_config import global_config -from dimos.core.run_registry import get_most_recent, is_pid_alive, stop_entry +from dimos.core.run_registry import get_most_recent, get_run, is_pid_alive, stop_entry from dimos.utils.cache import cache_usage_guard, cache_usage_locked from dimos.utils.logging_config import setup_logger @@ -286,7 +286,7 @@ def run( log_dir=str(log_dir), cli_args=list(blueprint_names), config_overrides=global_option_overrides, - original_argv=sys.argv, + original_argv=[sys.executable, *sys.orig_argv[1:]], ) entry.save() spawn_watchdog(run_id, log_dir=log_dir) @@ -324,7 +324,7 @@ def run( log_dir=str(log_dir), cli_args=list(blueprint_names), config_overrides=global_option_overrides, - original_argv=sys.argv, + original_argv=[sys.executable, *sys.orig_argv[1:]], ) entry.save() spawn_watchdog(run_id, log_dir=log_dir) @@ -342,9 +342,9 @@ def run( entry.remove() -def status() -> None: +def status(run_id: str = typer.Option("", "--run", "-r", help="Exact run ID")) -> None: """Show the running DimOS instance.""" - entry = get_most_recent(alive_only=True) + entry = get_run(run_id) if run_id else get_most_recent(alive_only=True) if not entry: typer.echo("No running DimOS instance") return @@ -367,18 +367,21 @@ def status() -> None: def stop( force: bool = typer.Option(False, "--force", "-f", help="Force kill (SIGKILL)"), + run_id: str = typer.Option("", "--run", "-r", help="Exact run ID"), ) -> None: """Stop the running DimOS instance.""" - entry = get_most_recent(alive_only=True) + entry = get_run(run_id) if run_id else get_most_recent(alive_only=True) if not entry: typer.echo("No running DimOS instance", err=True) raise typer.Exit(1) sig_name = "SIGKILL" if force else "SIGTERM" typer.echo(f"Stopping {entry.run_id} (PID {entry.pid}) with {sig_name}...") - msg, _ok = stop_entry(entry, force=force) + msg, ok = stop_entry(entry, force=force) typer.echo(f" {msg}") + if not ok: + raise typer.Exit(1) def log_cmd( @@ -417,9 +420,10 @@ def _on_sigint(_sig: int, _frame: object) -> None: def restart( force: bool = typer.Option(False, "--force", "-f", help="Force kill before restarting"), + run_id: str = typer.Option("", "--run", "-r", help="Exact run ID"), ) -> None: """Restart the running DimOS instance with the same arguments.""" - entry = get_most_recent(alive_only=True) + entry = get_run(run_id) if run_id else get_most_recent(alive_only=True) if not entry: typer.echo("No running DimOS instance to restart", err=True) raise typer.Exit(1) @@ -433,8 +437,10 @@ def restart( old_pid = entry.pid typer.echo(f"Restarting {entry.run_id} ({entry.blueprint})...") - msg, _ok = stop_entry(entry, force=force) + msg, ok = stop_entry(entry, force=force) typer.echo(f" {msg}") + if not ok: + raise typer.Exit(1) # Wait for the old process to fully exit so ports are released. for _ in range(20): # up to 2s diff --git a/dimos/core/run_registry.py b/dimos/core/run_registry.py index 119ac4b1fd6..6491190911b 100644 --- a/dimos/core/run_registry.py +++ b/dimos/core/run_registry.py @@ -148,6 +148,13 @@ def get_most_recent(alive_only: bool = True) -> RunEntry | None: return runs[-1] if runs else None +def get_run(run_id: str, alive_only: bool = True) -> RunEntry | None: + """Select an exact run ID; never fall back to another running instance.""" + return next( + (entry for entry in list_runs(alive_only=alive_only) if entry.run_id == run_id), None + ) + + def stop_entry(entry: RunEntry, force: bool = False) -> tuple[str, bool]: """Stop a DimOS instance by registry entry. diff --git a/dimos/core/test_daemon.py b/dimos/core/test_daemon.py index d95f6a00a0b..8fefabd8d2b 100644 --- a/dimos/core/test_daemon.py +++ b/dimos/core/test_daemon.py @@ -23,7 +23,10 @@ from unittest import mock import pytest +import typer +from typer.testing import CliRunner +from dimos.cli.commands import lifecycle from dimos.core import run_registry from dimos.core.run_registry import ( RunEntry, @@ -489,3 +492,36 @@ def test_get_most_recent_returns_latest(self, tmp_path, monkeypatch): latest = get_most_recent(alive_only=True) assert latest is not None assert latest.run_id == "20260306-110000-second" + + +@pytest.mark.parametrize("command", ["status", "stop", "restart"]) +def test_lifecycle_selects_exact_run( + command: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(run_registry, "REGISTRY_DIR", tmp_path / "runs") + monkeypatch.setattr(run_registry, "is_pid_alive", lambda pid: True) + first = _make_entry("first", 17321) + second = _make_entry("second", 28543) + first.original_argv = ["dimos", "run", "first", "--daemon"] + first.save() + second.save() + stopped: list[str] = [] + monkeypatch.setattr( + lifecycle, + "stop_entry", + lambda entry, force=False: (stopped.append(entry.run_id) or "stopped", True), + ) + monkeypatch.setattr(lifecycle, "is_pid_alive", lambda pid: False) + monkeypatch.setattr(lifecycle.os, "execvp", lambda file, args: None) + app = typer.Typer() + app.command()(getattr(lifecycle, command)) + result = CliRunner().invoke(app, ["--run", "first"]) + assert result.exit_code == 0, result.output + if command == "status": + assert "17321" in result.output and "28543" not in result.output + else: + assert stopped == ["first"] + stopped.clear() + missing = CliRunner().invoke(app, ["--run", "missing"]) + assert not stopped + assert "28543" not in missing.output From 46a4237afc4de58786567cab4c563241e6b6720e Mon Sep 17 00:00:00 2001 From: stash Date: Sat, 12 Sep 2026 02:52:23 -0700 Subject: [PATCH 2/9] fix(agent): add dimcode onboarding and Go2 media e2e coverage --- README.md | 1 + agent/README.md | 73 ++++-- agent/package.json | 9 + agent/src/gateway.ts | 45 +++- agent/src/input.ts | 46 ++++ agent/src/install.ts | 28 +++ agent/src/main.ts | 55 ++--- agent/src/protocol.ts | 7 +- agent/src/setup.ts | 403 ++++++++++++++++++++++++------- agent/src/terminal.ts | 128 +++++----- agent/test/blueprint.test.ts | 268 ++++++++++++++++++++ agent/test/cli.test.ts | 174 +++++++++++++ agent/test/fixtures/go2_media.py | 62 +++++ agent/test/gateway.test.ts | 79 ++++++ agent/test/render.test.ts | 2 +- agent/test/run.mjs | 8 +- agent/test/setup.test.ts | 67 +++++ 17 files changed, 1243 insertions(+), 212 deletions(-) create mode 100644 agent/src/input.ts create mode 100644 agent/src/install.ts create mode 100644 agent/test/blueprint.test.ts create mode 100644 agent/test/cli.test.ts create mode 100644 agent/test/fixtures/go2_media.py create mode 100644 agent/test/setup.test.ts diff --git a/README.md b/README.md index fa0944c3a4c..92c08d0581b 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ [Hardware](#hardware) • [Installation](#installation) • [Agent CLI & MCP](#agent-cli-and-mcp) • +[dimcode terminal agent](agent/README.md) • [Blueprints](#blueprints) • [dimTELE: Remote Teleop](#dimtele-remote-teleop) • [Development](#development) diff --git a/agent/README.md b/agent/README.md index b9f172ce6e7..2db7755a5d1 100644 --- a/agent/README.md +++ b/agent/README.md @@ -4,25 +4,46 @@ Persistent terminal agent for DimOS, built on Pi. The gateway runs independently ## Install -Requires Node 24 or 26. From this checkout: +The local executable is ready to use: + +```sh +dimcode setup +dimcode +``` + +`dimcode setup` walks through: + +1. Provider and authentication: paste an API key into a masked field, use existing credentials, or sign in with a ChatGPT subscription. +2. Model selection. +3. Workspace directory. +4. DimOS: choose an existing installation, create a new environment, or connect later. +5. An existing DimOS MCP endpoint, if available. +6. Daemon setup: select **Start at login** to install and enable the systemd user service, or start the gateway when you open dimcode. + +To install the packaged local build on another machine, use Node **24 or 26** and the supplied tarball: ```sh -cd agent -npm ci -npm run check -npm pack npm install -g ./dimensional-dimcode-0.1.0.tgz -dimcode setup --provider openai dimcode ``` -The package name is `@dimensional/dimcode`; npm publication is separate from building/installing the tarball. DimOS is optional at harness startup. To install its base package in a **new** environment: +This prerelease is distributed locally; it is not published to npm yet. No source build or test commands are needed to install the tarball. + +A fresh `dimcode` launch opens setup automatically. Repeating `dimcode setup` lets you change the choices; configured providers offer **Use configured credentials** so you do not need to paste a key again. Credentials never enter chat. ```sh -dimcode install-dimos /path/to/new/venv +dimcode setup # repeat interactive setup +dimcode tui # open the terminal (same as dimcode) +dimcode --help # all launch commands ``` -Use DimOS's existing installation instructions for robot extras/system packages. Existing installations and editable checkouts can be selected with `dimcode python /path/to/python` and `dimcode dimos /path/to/dimos`. Executable paths are local configuration, and each session has its own workspace. +The setup can create a new DimOS environment using `uv` (Python 3.12), or select a `dimos` executable from a pip environment or editable checkout. Install [uv](https://docs.astral.sh/uv/getting-started/installation/) first if you choose a new environment. Use DimOS's installation instructions for robot extras and system packages. The harness can run before DimOS is installed. + +## What is installed / where is the source? + +This package uses **upstream Pi 0.85.1**, pinned as npm dependencies. It is not a Pi fork or a native binary. `dimcode` is a Node CLI with a Dimensional gateway, terminal frontend and extensions. Pi supplies the agent runtime, provider login, coding tools, message components and tool cards. A fork is unnecessary for these customizations. + +All source is in this repository's [`agent/src/`](src): [`main.ts`](src/main.ts) launches the CLI; [`setup.ts`](src/setup.ts) handles onboarding; [`terminal.ts`](src/terminal.ts) renders chat; [`gateway.ts`](src/gateway.ts) owns persistent sessions; [`media.ts`](src/media.ts) lazily connects the existing Web SDK. [`skills/dimensional`](skills/dimensional) supplies DimOS instructions. ## Authentication and service @@ -34,7 +55,7 @@ dimcode service status dimcode service uninstall ``` -This is a Linux **user** service. Without it, first attach starts a detached gateway. `dimcode stop` stops that gateway; `dimcode gateway` runs it in the foreground for diagnostics. `dimcode --foreground` runs gateway and terminal in one foreground process. +This is a Linux **user** service. Without it, first attach starts a detached gateway. `dimcode stop` stops that gateway; `dimcode gateway` runs it in the foreground for diagnostics, or reports that it is already running. `dimcode --foreground` runs gateway and terminal in one foreground process. Config/auth live in `$XDG_CONFIG_HOME/dimcode` (or `~/.config/dimcode`), sessions in `$XDG_STATE_HOME/dimcode`, previews in `$XDG_CACHE_HOME/dimcode`. `DIMCODE_HOME` overrides the config directory. The runtime socket is user-only. Service removal preserves sessions, credentials and DimOS data. @@ -52,7 +73,7 @@ dimcode run "inspect the app and explain its blueprint" Endpoints are explicit; ports are examples, not instance identities. Use `/reload` after changing endpoints. Every advertised skill is registered with its original schema, metadata and remote routing. Tool names are bounded and collision-resistant. There is no static copy of the robot's tools or a parallel lifecycle service. -Terminal commands: `/new`, `/sessions`, `/resume ID`, `/models`, `/model PROVIDER MODEL`, `/login PROVIDER [oauth]`, `/logout PROVIDER`, `/abort`, `/steer TEXT`, `/follow TEXT`, `/reload`, `/image PATH`, `/expand`, `/exit`. Ctrl-C detaches. One terminal owns input; other viewers may observe. Detaching keeps the turn running. Restart restores Pi history and never automatically replays external actions. +Terminal commands: `/new`, `/sessions`, `/resume ID`, `/models`, `/model PROVIDER MODEL`, `/login PROVIDER [oauth]`, `/logout PROVIDER`, `/abort`, `/steer TEXT`, `/follow TEXT`, `/reload`, `/image PATH`, `/expand`, `/exit`. Ctrl-C detaches. One terminal owns input; other viewers may observe. Detaching keeps the turn running. Restart restores Pi history and never automatically replays external actions. Very large histories show a bounded recent transcript with an omission notice; the complete agent history remains on disk. Session events carry their source identity so switching sessions cannot mix transcripts. Pi owns context loading, skills, compaction, models and coding-tool behavior. Workspace instructions and configured Pi extensions load normally. Attached terminals support serialized dialogs/notifications; executable extension UI factories belong in the terminal renderer and cannot be sent through a socket. @@ -64,13 +85,35 @@ Live tools select an existing relay/robot/channel. The terminal receives frames Graphics use Pi terminal-image support with text fallback. Derived PNGs have a bounded 128 MiB cache; original DimOS recordings stay with DimOS. No recording, raw continuous video, new transport protocol or new DimOS gateway is introduced. -## Validation +## Contributor development and validation + +These commands are for working on the source, not the user installation: ```sh +cd agent +npm ci npm run check -DIMCODE_TEST_PYTHON=/path/to/dimos/python DIMCODE_TEST_DENO=deno npm test +npm link +``` + +`npm pack` produces the distributable tarball; `npm publish --access public` requires access to the `@dimensional` npm scope. Building alone does not publish it. + +To run the complete integration suite with an existing DimOS environment and Go2 recording: + +```sh +DIMCODE_TEST_PYTHON=/path/to/dimos/.venv/bin/python \ +DIMCODE_TEST_DENO=/path/to/deno \ +DIMCODE_TEST_GO2_DB=/path/to/go2_bigoffice.db \ +npm test ``` -The second command adds actual DimOS MCP-handler and Node-to-Deno QUIC integration tests. Other tests cover session ownership/detach/recovery, duplicate prompts, renderer provenance and lazy media disposal. Fixture tests do not claim a physical-robot run. +The Go2 E2E test starts the standard `unitree-go2` blueprint in **recorded-data replay**, composed with the existing cockpit relay bridge. It starts its own relay and isolates transport discovery. No robot hardware or model API key is required. It verifies: + +- Multiple distinct, successfully decoded JPEG video frames and timestamped odometry. +- Full XYZ float32 point clouds, every coordinate finite, source timestamps/frame metadata, and byte-for-byte source SHA-256 matches. A test-only encoder/SDK decoder uses the existing codec registry; it does not mislabel the default XY lidar projection as XYZ. +- One shared SDK connection, lazy subscriptions, continued cloud reception after video closes, then zero viewers/subscriptions after the last renderer closes. +- Cleanup of the blueprint and relay, including failure paths. + +Set `DIMCODE_TEST_REPORT=/absolute/path/report.json` to save measured counts. Without `DIMCODE_TEST_GO2_DB`, the large recording test is explicitly skipped. The Python and Deno variables separately enable the MCP-handler and QUIC relay tests. Unit/CLI tests also cover onboarding, private credentials, cancellation, `tui`, existing-gateway handling, session ownership, detach/recovery and renderer provenance. -Use `dimos --replay run unitree-go2 --daemon` for standard recorded-data validation. External apps are selected as `distribution-name.blueprint-name`. Lifecycle commands in this change support exact `--run ID` targeting. A Python-launched coordinator can instead be inspected with existing public Python APIs. +A video stream here means consecutive JPEG frames over WebTransport. This does not claim an H.264/WebCodecs decoder, browser UI coverage, or a physical robot test. The harness's initial live terminal renderer displays JPEG; saved point clouds and series render from exact exported results. Other live types can plug into the generic SDK codec registry. diff --git a/agent/package.json b/agent/package.json index f72ea10e77b..7b6c108946f 100644 --- a/agent/package.json +++ b/agent/package.json @@ -37,5 +37,14 @@ "esbuild": "^0.25.0", "prettier": "^3.6.0", "typescript": "5.9.3" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/dimensionalOS/dimos.git", + "directory": "agent" + }, + "homepage": "https://github.com/dimensionalOS/dimos/tree/main/agent#readme", + "publishConfig": { + "access": "public" } } diff --git a/agent/src/gateway.ts b/agent/src/gateway.ts index 4c297180729..0d0bef5f80a 100644 --- a/agent/src/gateway.ts +++ b/agent/src/gateway.ts @@ -85,7 +85,12 @@ export class Gateway { entry.notices.push(event.message); entry.notices = entry.notices.slice(-10); } - const packet: Packet = { type: "event", seq: ++entry.seq, event }; + const packet: Packet = { + type: "event", + sessionId: entry.handle.session.sessionId, + seq: ++entry.seq, + event, + }; for (const peer of entry.peers) send(peer, packet); } private async restore( @@ -240,17 +245,43 @@ export class Gateway { } private snapshot(entry: Entry, socket: Socket): Snapshot { const session = entry.handle.session; - return { + const snapshot: Snapshot = { sessionId: session.sessionId, cwd: session.sessionManager.getCwd(), seq: entry.seq, busy: entry.running, writable: entry.owner === socket, - messages: session.messages, - text: entry.text, - notices: entry.notices, - tools: [...entry.tools.values()], + messages: [], + text: entry.text.slice(-256_000), + notices: entry.notices.map((message) => message.slice(-4096)), + tools: [], }; + // Bound only the terminal transfer; Pi retains the complete session and model context. + let budget = + 8 * 1024 * 1024 - Buffer.byteLength(JSON.stringify(snapshot)) - 1024; + for (const tool of entry.tools.values()) { + const size = Buffer.byteLength(JSON.stringify(tool)) + 1; + if (size > budget) continue; + snapshot.tools.push(tool); + budget -= size; + } + for (let index = session.messages.length - 1; index >= 0; index--) { + const message = session.messages[index]; + const size = Buffer.byteLength(JSON.stringify(message)) + 1; + if (size > budget) break; + snapshot.messages.unshift(message); + budget -= size; + } + const omitted = session.messages.length - snapshot.messages.length; + if (omitted) + snapshot.notices.push( + `${omitted} earlier messages omitted from this terminal view. Full agent history is retained on disk.`, + ); + if (snapshot.tools.length < entry.tools.size) + snapshot.notices.push( + "Oversized active tool previews omitted from this terminal view.", + ); + return snapshot; } private detach(socket: Socket): void { const entry = this.peers.get(socket); @@ -341,6 +372,7 @@ export class Gateway { signal?.addEventListener("abort", aborted, { once: true }); send(entry.owner, { type: "event", + sessionId: entry.handle.session.sessionId, seq: ++entry.seq, event: { type: "auth_prompt", promptId, prompt: wirePrompt }, }); @@ -349,6 +381,7 @@ export class Gateway { if (entry.owner) send(entry.owner, { type: "event", + sessionId: entry.handle.session.sessionId, seq: ++entry.seq, event: { type: "auth_info", info }, }); diff --git a/agent/src/input.ts b/agent/src/input.ts new file mode 100644 index 00000000000..d31116104cb --- /dev/null +++ b/agent/src/input.ts @@ -0,0 +1,46 @@ +import { Input, truncateToWidth, type Component } from "@earendil-works/pi-tui"; + +export class ChatInput implements Component { + private editor = new Input({ + prompt: "dimcode › ", + placeholder: "Ask about your robot or app…", + }); + private masked = false; + focused = false; + onSubmit?: (value: string) => void; + onEscape?: () => void; + get secret(): boolean { + return this.masked; + } + set secret(value: boolean) { + if (value === this.masked) return; + this.masked = value; + // Discard the credential editor and its undo/kill-ring history on mode change. + this.editor = new Input({ prompt: "dimcode › " }); + } + getValue(): string { + return this.editor.getValue(); + } + setValue(value: string): void { + this.editor.setValue(value); + } + invalidate(): void { + this.editor.invalidate(); + } + handleInput(data: string): void { + this.editor.focused = this.focused; + this.editor.onSubmit = (value) => this.onSubmit?.(value); + this.editor.onEscape = () => this.onEscape?.(); + this.editor.handleInput(data); + } + render(width: number): string[] { + return this.secret + ? [ + truncateToWidth( + "credential › " + "*".repeat(this.getValue().length), + width, + ), + ] + : this.editor.render(width); + } +} diff --git a/agent/src/install.ts b/agent/src/install.ts new file mode 100644 index 00000000000..57ca79bb9e2 --- /dev/null +++ b/agent/src/install.ts @@ -0,0 +1,28 @@ +import { access } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { saveConfig, type Config, type Paths } from "./config.js"; +import { execute } from "./service.js"; + +export async function installDimos( + destination: string, + config: Config, + paths: Paths, +): Promise { + const venv = resolve(destination); + try { + await access(venv); + } catch (error) { + if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) + throw error; + await execute("uv", ["venv", "--python", "3.12", venv]); + const python = join(venv, "bin/python"); + await execute("uv", ["pip", "install", "--python", python, "dimos"]); + config.python = python; + config.dimos = join(venv, "bin/dimos"); + await saveConfig(paths, config); + return; + } + throw new Error( + "Destination exists; select its dimos/python executables or choose a new environment.", + ); +} diff --git a/agent/src/main.ts b/agent/src/main.ts index 9cd646a49bc..ee1b23da7d4 100644 --- a/agent/src/main.ts +++ b/agent/src/main.ts @@ -1,13 +1,14 @@ import { spawn } from "node:child_process"; -import { access, mkdir, rm, rmdir, stat } from "node:fs/promises"; +import { mkdir, rm, rmdir, stat } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import { parseArgs } from "node:util"; import { setTimeout as delay } from "node:timers/promises"; import { paths, loadConfig, saveConfig } from "./config.js"; import { Gateway } from "./gateway.js"; import { Connection, reachable } from "./protocol.js"; -import { setup } from "./setup.js"; -import { service, execute } from "./service.js"; +import { setup, needsSetup } from "./setup.js"; +import { service } from "./service.js"; +import { installDimos } from "./install.js"; import { terminal } from "./terminal.js"; const { values, positionals } = parseArgs({ @@ -23,8 +24,8 @@ const { values, positionals } = parseArgs({ help: { type: "boolean" }, }, }); -const p = paths(), - config = await loadConfig(p); +const p = paths(); +let config = await loadConfig(p); if (values.cwd) config.workspace = resolve(values.cwd); async function ensureGateway(): Promise { if (await reachable(p.socket)) return; @@ -68,7 +69,7 @@ async function main(): Promise { const [command, ...args] = positionals; if (values.help || command === "help") { console.log( - "dimcode [--session ID] [--cwd DIR] [--view]\ndimcode setup [--provider NAME] [--oauth | --key-env VAR]\ndimcode gateway | --foreground | service install/status/uninstall\ndimcode connect NAME MCP_URL | dimos PATH | python PATH | relay URL [ROBOT]\ndimcode install-dimos [VENV] | sessions | stop | run PROMPT | config", + "dimcode [tui] [--session ID] [--cwd DIR] [--view]\ndimcode setup [--provider NAME] [--oauth | --key-env VAR]\ndimcode gateway | --foreground | service install/status/uninstall\ndimcode connect NAME MCP_URL | dimos PATH | python PATH | relay URL [ROBOT]\ndimcode install-dimos [VENV] | sessions | stop | run PROMPT | config", ); return; } @@ -107,35 +108,32 @@ async function main(): Promise { return; } if (command === "install-dimos") { - const venv = resolve(args[0] ?? config.workspace + "/.venv"); - let exists = true; - try { - await access(venv); - } catch { - exists = false; - } - if (exists) - throw new Error( - "Destination exists; select its dimos/python executables or choose a new venv.", - ); - await execute("uv", ["venv", "--python", "3.12", venv]); - await execute("uv", [ - "pip", - "install", - "--python", - venv + "/bin/python", - "dimos", - ]); - config.python = venv + "/bin/python"; - config.dimos = venv + "/bin/dimos"; - await saveConfig(p, config); + await installDimos(args[0] ?? config.workspace + "/.venv", config, p); return; } if (command === "service") { await service(args[0], config, p, process.argv[1]); return; } + if ( + command && + !["gateway", "stop", "sessions", "run", "tui"].includes(command) + ) + throw new Error("Unknown command: " + command + ". Run dimcode --help."); + if ( + (!command || command === "tui" || values.foreground) && + (await needsSetup(p)) + ) { + await setup(p, { workspace: values.cwd }); + config = await loadConfig(p); + } if (command === "gateway" || values.foreground) { + if (!values.foreground && (await reachable(p.socket))) { + console.log( + "Gateway already running. Open the terminal with dimcode tui.", + ); + return; + } if (values.foreground) p.socket = p.socket + "." + process.pid; const gateway = new Gateway(config, p); await gateway.start(); @@ -203,7 +201,6 @@ async function main(): Promise { } return; } - if (command) throw new Error("Unknown command: " + command); await terminal(p.socket, { sessionId: values.session, cwd: config.workspace, diff --git a/agent/src/protocol.ts b/agent/src/protocol.ts index 780a7a92808..2fc12f3091a 100644 --- a/agent/src/protocol.ts +++ b/agent/src/protocol.ts @@ -99,7 +99,7 @@ export interface Results { } export type Packet = | { type: "response"; id: string; data?: Results[Kind]; error?: string } - | { type: "event"; seq: number; event: Event }; + | { type: "event"; sessionId: string; seq: number; event: Event }; const MAX_BYTES = 16 * 1024 * 1024; export function send(socket: Socket, packet: Packet | Request): void { @@ -146,14 +146,15 @@ export class Connection { string, { resolve: (value: Results[Kind]) => void; reject: (error: Error) => void } >(); - onEvent: (seq: number, event: Event) => void = () => {}; + onEvent: (seq: number, event: Event, sessionId: string) => void = () => {}; onClose: () => void = () => {}; constructor(path: string) { this.socket = createConnection(path); readLines(this.socket, (value) => { // Both ends share Packet; only the user-owned local socket is accepted. const packet = value as Packet; - if (packet.type === "event") this.onEvent(packet.seq, packet.event); + if (packet.type === "event") + this.onEvent(packet.seq, packet.event, packet.sessionId); else if (packet.type === "response") { const pending = this.pending.get(packet.id); this.pending.delete(packet.id); diff --git a/agent/src/setup.ts b/agent/src/setup.ts index 404fd765135..9b38b1dbcdf 100644 --- a/agent/src/setup.ts +++ b/agent/src/setup.ts @@ -1,108 +1,337 @@ -import { mkdir } from "node:fs/promises"; -import { join } from "node:path"; -import { createInterface } from "node:readline/promises"; +import { access, constants, stat } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; import { stdin, stdout } from "node:process"; -import { ModelRuntime } from "@earendil-works/pi-coding-agent"; -import type { AuthPrompt } from "@earendil-works/pi-ai"; +import { + ModelRuntime, + SettingsManager, + getSelectListTheme, + initTheme, +} from "@earendil-works/pi-coding-agent"; +import { + Container, + ProcessTerminal, + SelectList, + Text, + TuiMainScreen, +} from "@earendil-works/pi-tui"; +import type { AuthInteraction, AuthPrompt } from "@earendil-works/pi-ai"; import { loadConfig, saveConfig, type Paths } from "./config.js"; +import { ChatInput } from "./input.js"; +import { installDimos } from "./install.js"; +import { service } from "./service.js"; -export async function prompt(prompt: AuthPrompt): Promise { +/** Pi's login callbacks, with credentials confined to a disposable masked input. */ +function setupUI(): AuthInteraction & { close(): void } { if (!stdin.isTTY) throw new Error( - "Interactive login requires a terminal; use setup --key-env for an environment API key.", + "Setup requires a terminal. For automation use dimcode setup --provider NAME --key-env VAR.", ); - if (prompt.type === "select") - stdout.write( - prompt.options - .map((option) => option.id + ": " + option.label) - .join("\n") + "\n", - ); - if (prompt.type !== "secret" && prompt.type !== "manual_code") { - const rl = createInterface({ input: stdin, output: stdout }); - try { - return await rl.question(prompt.message + " "); - } finally { - rl.close(); + initTheme("dark", false); + const tui = new TuiMainScreen(new ProcessTerminal()); + const body = new Container(), + info = new Text("", 1, 1); + tui.addChild( + new Text("\x1b[1;36mdimcode\x1b[0m · Set up your Dimensional agent", 1, 1), + ); + tui.addChild(info); + tui.addChild(body); + tui.addChild( + new Text("\x1b[2m↑ ↓ choose · Enter continue · Esc cancel\x1b[0m", 1, 1), + ); + const abort = new AbortController(); + tui.addInputListener((data) => { + if (data === "\x03") { + abort.abort(); + return { consume: true }; } - } - stdout.write(prompt.message + " "); - const wasRaw = stdin.isRaw; - stdin.setRawMode(true); - stdin.resume(); - try { - return await new Promise((resolve, reject) => { - let value = ""; - const onData = (bytes: Buffer) => { - for (const character of bytes.toString("utf8")) { - if (character === "\x03" || character === "\x1b") { - cleanup(); - reject(new Error("Cancelled")); - return; - } - if (character === "\r" || character === "\n") { - cleanup(); - resolve(value); - return; - } - if (character === "\x7f" || character === "\b") - value = value.slice(0, -1); - else if (character >= " ") value += character; + return undefined; + }); + tui.start(); + return { + signal: abort.signal, + prompt: async (question: AuthPrompt) => { + abort.signal.throwIfAborted(); + question.signal?.throwIfAborted(); + body.clear(); + body.addChild(new Text(question.message, 1, 1)); + return new Promise((resolve, reject) => { + const cancel = () => finish(undefined); + const input = new ChatInput(); + const finish = (value: string | undefined) => { + abort.signal.removeEventListener("abort", cancel); + question.signal?.removeEventListener("abort", cancel); + input.secret = false; + input.setValue(""); + body.clear(); + tui.requestRender(); + if (value === undefined) + reject( + new Error("Setup cancelled. Run dimcode setup to continue."), + ); + else resolve(value); + }; + abort.signal.addEventListener("abort", cancel, { once: true }); + question.signal?.addEventListener("abort", cancel, { once: true }); + if (question.type === "select") { + const select = new SelectList( + question.options.map((item) => ({ + value: item.id, + label: item.label, + description: item.description, + })), + 10, + getSelectListTheme(), + ); + select.onSelect = (item) => finish(item.value); + select.onCancel = cancel; + body.addChild(select); + tui.setFocus(select); + } else { + input.secret = + question.type === "secret" || question.type === "manual_code"; + input.onSubmit = finish; + input.onEscape = cancel; + body.addChild(input); + tui.setFocus(input); } - }; - const cleanup = () => { - stdin.off("data", onData); - stdout.write("\n"); - }; - stdin.on("data", onData); - }); - } finally { - stdin.setRawMode(wasRaw); - stdin.pause(); + tui.requestRender(); + }); + }, + notify: (event) => { + info.setText( + event.type === "auth_url" + ? event.url + + "\n" + + (event.instructions ?? "Open this link to sign in.") + : event.type === "device_code" + ? event.verificationUri + " · " + event.userCode + : event.message, + ); + tui.requestRender(); + }, + close: () => tui.stop(), + }; +} +export async function needsSetup(paths: Paths): Promise { + try { + await access(join(paths.config, "config.json")); + return false; + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") + return true; + throw error; } } +export interface SetupOptions { + provider?: string; + oauth?: boolean; + keyEnv?: string; + workspace?: string; +} export async function setup( paths: Paths, - options: { - provider?: string; - oauth?: boolean; - keyEnv?: string; - workspace?: string; - }, + options: SetupOptions = {}, + interaction?: AuthInteraction, ): Promise { const config = await loadConfig(paths); - if (options.workspace) config.workspace = options.workspace; - await saveConfig(paths, config); - await mkdir(paths.config, { recursive: true, mode: 0o700 }); + if (options.workspace) config.workspace = resolve(options.workspace); const runtime = await ModelRuntime.create({ authPath: join(paths.config, "auth.json"), modelsPath: join(paths.config, "models.json"), }); - const provider = options.provider ?? "openai"; - if (provider === "anthropic" && options.oauth) - throw new Error("Use an Anthropic API key for dimcode."); - if (options.keyEnv) { - const key = process.env[options.keyEnv]; - if (!key) - throw new Error("Requested credential environment variable is empty"); - await runtime.login(provider, "api_key", { - prompt: async () => key, - notify: () => {}, - }); - } else { - await runtime.login(provider, options.oauth ? "oauth" : "api_key", { - prompt, - notify: (event) => { - stdout.write( - (event.type === "auth_url" - ? event.url - : event.type === "device_code" - ? event.verificationUri + " code: " + event.userCode - : event.message) + "\n", + const wizard = !options.provider && !options.keyEnv && !options.oauth; + const ui = interaction ?? (options.keyEnv ? undefined : setupUI()); + const ask = (question: AuthPrompt) => { + if (!ui) throw new Error("Interactive setup requires a terminal"); + return ui.prompt(question); + }; + const choose = ( + message: string, + items: readonly { id: string; label: string; description?: string }[], + ) => ask({ type: "select", message, options: items }); + let install: string | undefined, + atLogin = false; + try { + const providers = runtime + .getProviders() + .filter( + (p) => p.auth.apiKey?.login || (p.id !== "anthropic" && p.auth.oauth), + ); + const preferred = ["openai", "openai-codex", "anthropic"]; + providers.sort( + (a, b) => + (preferred.indexOf(a.id) + 1 || 99) - + (preferred.indexOf(b.id) + 1 || 99), + ); + const providerId = + options.provider ?? + (wizard + ? await choose( + "1. Choose your model provider", + providers.map((p) => ({ + id: p.id, + label: + p.id === "openai-codex" + ? "ChatGPT subscription (sign in)" + : p.name, + description: + p.id === "anthropic" + ? "Claude API key" + : p.id === "openai" + ? "OpenAI API key" + : p.id, + })), + ) + : "openai"); + const provider = runtime.getProvider(providerId); + if (!provider) throw new Error("Unknown provider: " + providerId); + if (providerId === "anthropic" && options.oauth) + throw new Error("Use an Anthropic API key for dimcode."); + const methods = [ + ...(wizard && runtime.hasConfiguredAuth(providerId) + ? [{ id: "existing", label: "Use configured credentials" }] + : []), + ...(provider.auth.apiKey?.login + ? [{ id: "api_key", label: provider.auth.apiKey.name }] + : []), + ...(providerId !== "anthropic" && provider.auth.oauth + ? [{ id: "oauth", label: provider.auth.oauth.name }] + : []), + ]; + const method = options.keyEnv + ? "api_key" + : options.oauth + ? "oauth" + : wizard && methods.length > 1 + ? await choose("Sign in", methods) + : methods[0]?.id; + if (!method) throw new Error("Provider has no supported login method"); + if (method !== "existing") { + if (options.keyEnv) { + const key = process.env[options.keyEnv]; + if (!key) + throw new Error("Requested credential environment variable is empty"); + await runtime.login(providerId, "api_key", { + prompt: async () => key, + notify: () => {}, + }); + } else { + if (method !== "api_key" && method !== "oauth") + throw new Error("Unknown login method"); + await runtime.login(providerId, method, ui!); + } + } + const models = await runtime.getAvailable(providerId); + if (!models.length) + throw new Error("No available models for " + providerId); + const settings = SettingsManager.create(config.workspace, paths.config); + const currentModel = + settings.getDefaultProvider() === providerId + ? settings.getDefaultModel() + : undefined; + const ordered = [...models].sort( + (a, b) => Number(b.id === currentModel) - Number(a.id === currentModel), + ); + const model = wizard + ? await choose( + "2. Choose a model", + ordered.map((m) => ({ id: m.id, label: m.id, description: m.name })), + ) + : ordered[0].id; + if (wizard) { + config.workspace = resolve( + ( + await ask({ + type: "text", + message: + "3. Workspace directory (Enter for " + config.workspace + ")", + }) + ).trim() || config.workspace, + ); + if (!(await stat(config.workspace)).isDirectory()) + throw new Error("Workspace must be a directory"); + await access(config.workspace, constants.R_OK | constants.X_OK); + const mode = await choose("4. Connect DimOS", [ + { + id: "later", + label: "Set up later", + description: + "Start coding now; connect a local or remote runtime later", + }, + { + id: "existing", + label: "Use an existing DimOS installation", + description: "pip environment or editable checkout", + }, + { + id: "install", + label: "Install DimOS", + description: "Create a new Python environment using uv", + }, + ]); + if (mode === "existing") { + const executable = resolve( + ( + await ask({ + type: "text", + message: + "Path to the DimOS executable (e.g. /path/to/.venv/bin/dimos)", + }) + ).trim(), ); - }, - }); + if (!(await stat(executable)).isFile()) + throw new Error("Select a dimos executable file"); + await access(executable, constants.X_OK); + config.dimos = executable; + const python = join(dirname(executable), "python"); + try { + await access(python, constants.X_OK); + config.python = python; + } catch { + config.python = undefined; + } + } else if (mode === "install") { + install = resolve( + ( + await ask({ + type: "text", + message: + "New environment directory (Enter for " + + join(config.workspace, ".venv") + + ")", + }) + ).trim() || join(config.workspace, ".venv"), + ); + } + const url = ( + await ask({ + type: "text", + message: "5. Existing DimOS MCP URL (Enter to connect later)", + }) + ).trim(); + if (url) + config.mcp = [ + ...config.mcp.filter((e) => e.name !== "default"), + { name: "default", url: new URL(url).href }, + ]; + if (process.platform === "linux") + atLogin = + (await choose("6. Start the gateway at login?", [ + { id: "no", label: "Start when I open dimcode" }, + { + id: "yes", + label: "Start at login", + description: "Install a systemd user service", + }, + ])) === "yes"; + } + settings.setDefaultModelAndProvider(providerId, model); + await settings.flush(); + await saveConfig(paths, config); + } finally { + if (ui && "close" in ui && typeof ui.close === "function") ui.close(); } - stdout.write( - "dimcode configured. Run dimcode to chat, or dimcode service install to start it at login.\n", - ); + if (install) await installDimos(install, config, paths); + if (atLogin) await service("install", config, paths, process.argv[1]); + stdout.write("dimcode configured. Run dimcode (or dimcode tui) to chat.\n"); } diff --git a/agent/src/terminal.ts b/agent/src/terminal.ts index afee1cb670d..362153cf027 100644 --- a/agent/src/terminal.ts +++ b/agent/src/terminal.ts @@ -2,16 +2,18 @@ import { readFile } from "node:fs/promises"; import { resolve } from "node:path"; import { Container, - Input, + Markdown, + Spacer, ProcessTerminal, Text, TuiMainScreen, - truncateToWidth, - type Component, } from "@earendil-works/pi-tui"; import sharp from "sharp"; import { ToolExecutionComponent, + AssistantMessageComponent, + UserMessageComponent, + getMarkdownTheme, initTheme, createBashToolDefinition, createReadToolDefinition, @@ -22,6 +24,7 @@ import { z } from "zod"; import { Connection, type Event, type Snapshot } from "./protocol.js"; import { imageComponent, renderDetails } from "./render.js"; import { MediaPool, type MediaLease } from "./media.js"; +import { ChatInput } from "./input.js"; import type { Slot } from "@dimos/sdk"; const resultSchema = z.object({ @@ -39,50 +42,6 @@ const resultSchema = z.object({ .default([]), details: z.unknown().optional(), }); -export class ChatInput implements Component { - private editor = new Input({ - prompt: "dimcode › ", - placeholder: "/help · Ctrl-C detaches", - }); - private masked = false; - focused = false; - onSubmit?: (value: string) => void; - onEscape?: () => void; - get secret(): boolean { - return this.masked; - } - set secret(value: boolean) { - if (value === this.masked) return; - this.masked = value; - // Discard the credential editor and its undo/kill-ring history on mode change. - this.editor = new Input({ prompt: "dimcode › " }); - } - getValue(): string { - return this.editor.getValue(); - } - setValue(value: string): void { - this.editor.setValue(value); - } - invalidate(): void { - this.editor.invalidate(); - } - handleInput(data: string): void { - this.editor.focused = this.focused; - this.editor.onSubmit = (value) => this.onSubmit?.(value); - this.editor.onEscape = () => this.onEscape?.(); - this.editor.handleInput(data); - } - render(width: number): string[] { - return this.secret - ? [ - truncateToWidth( - "credential › " + "*".repeat(this.getValue().length), - width, - ), - ] - : this.editor.render(width); - } -} export async function terminal( socket: string, options: { sessionId?: string; cwd?: string; view?: boolean } = {}, @@ -106,13 +65,22 @@ export async function terminal( } >(); let current: Snapshot, - assistant = new Text("", 0, 0), + assistant = new Container(), text = "", stopped = false; let auth: Extract | undefined; let restoring = true; - const pending: Array<{ seq: number; event: Event }> = []; + const pending: Array<{ sessionId: string; seq: number; event: Event }> = []; const add = (value: string) => transcript.addChild(new Text(value, 0, 0)); + const setAssistant = (value: string) => { + assistant.clear(); + assistant.addChild(new Markdown(value, 1, 1, getMarkdownTheme())); + }; + const ready = () => + " " + + (current.writable ? "Ready" : "Read-only") + + " · " + + current.sessionId.slice(0, 8); const notice = (error: unknown) => { add(String(error)); tui.requestRender(); @@ -256,7 +224,7 @@ export async function terminal( ); } else if (event.type === "text_delta") { text += event.delta; - assistant.setText(text); + setAssistant(text); } else if ( event.type === "message_end" && event.message.role === "assistant" @@ -265,22 +233,24 @@ export async function terminal( .filter((item) => item.type === "text") .map((item) => item.text) .join("\n"); - assistant.setText(text); + assistant.clear(); + assistant.addChild(new AssistantMessageComponent(event.message, true)); } else if (event.type === "message_start") { if (event.message.role === "assistant") { text = ""; - assistant = new Text("", 0, 0); + assistant = new Container(); transcript.addChild(assistant); } else if (event.message.role === "user") { const content = event.message.content; - add( - "you: " + - (typeof content === "string" + transcript.addChild( + new UserMessageComponent( + typeof content === "string" ? content : content .filter((item) => item.type === "text") .map((item) => item.text) - .join("\n")), + .join("\n"), + ), ); } } else if (event.type === "tool_execution_start") { @@ -310,19 +280,27 @@ export async function terminal( ); else if (event.type === "notice" || event.type === "turn_error") notice(event.message); - else if (event.type === "idle") - status.setText(current.sessionId + " · idle"); + else if (event.type === "idle") status.setText(ready()); tui.requestRender(); }; - client.onEvent = (seq, event) => { - if (restoring) pending.push({ seq, event }); - else apply(event); + client.onEvent = (seq, event, sessionId) => { + if (restoring) pending.push({ sessionId, seq, event }); + else if (sessionId === current.sessionId) apply(event); }; const restore = (snapshot: Snapshot) => { closeCards(); transcript.clear(); current = snapshot; - add("dimcode · " + current.cwd + (current.writable ? "" : " · read-only")); + add("\x1b[1;36m dimcode\x1b[0m · Dimensional agent"); + add("\x1b[2m " + current.cwd + "\x1b[0m"); + if (!current.messages.length) { + transcript.addChild(new Spacer(1)); + add(" Build apps. Run blueprints. Explore sensor memory."); + add( + "\x1b[2m Try: inspect this workspace, or show available DimOS blueprints.\x1b[0m", + ); + transcript.addChild(new Spacer(1)); + } const argumentsByCall = new Map(); for (const message of current.messages) { if (message.role === "assistant") @@ -348,21 +326,25 @@ export async function terminal( .filter((item) => item.type === "text") .map((item) => item.text) .join("\n"); - if (content) add(message.role + ": " + content); + if (message.role === "assistant") + transcript.addChild(new AssistantMessageComponent(message, true)); + else if (message.role === "user") + transcript.addChild(new UserMessageComponent(content)); + else if (content) add(content); } } } for (const message of current.notices) add(message); text = current.text; - assistant = new Text(text, 0, 0); + assistant = new Container(); + if (text) setAssistant(text); transcript.addChild(assistant); for (const event of current.tools) apply(event); - status.setText( - current.sessionId + " · " + (current.busy ? "running" : "idle"), - ); + status.setText(current.busy ? " Running · Esc to cancel" : ready()); restoring = false; for (const packet of pending.splice(0)) - if (packet.seq > current.seq) apply(packet.event); + if (packet.sessionId === current.sessionId && packet.seq > current.seq) + apply(packet.event); tui.requestRender(); }; const stop = () => { @@ -386,8 +368,16 @@ export async function terminal( ), ); tui.addChild(transcript); + tui.addChild(new Spacer(1)); tui.addChild(status); tui.addChild(input); + tui.addChild( + new Text( + "\x1b[2m /help commands · /models choose model · Esc cancel · Ctrl-C detach\x1b[0m", + 0, + 0, + ), + ); tui.setFocus(input); input.onSubmit = (value) => { input.setValue(""); @@ -510,7 +500,7 @@ export async function terminal( return; } if (value.trim()) { - status.setText(current.sessionId + " · running"); + status.setText(" Running · Esc to cancel"); await client.call({ type: "prompt", message: value }); } })() diff --git a/agent/test/blueprint.test.ts b/agent/test/blueprint.test.ts new file mode 100644 index 00000000000..a3e7f637175 --- /dev/null +++ b/agent/test/blueprint.test.ts @@ -0,0 +1,268 @@ +import assert from "node:assert/strict"; +import { spawn, type ChildProcess } from "node:child_process"; +import { createHash } from "node:crypto"; +import { createSocket } from "node:dgram"; +import { once } from "node:events"; +import { writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; +import { test } from "node:test"; +import { connect, createDecoderRegistry, type Slot } from "@dimos/sdk"; +import sharp from "sharp"; +import { z } from "zod"; +import { MediaPool, nodeTransport } from "../src/media.js"; + +const statsSchema = z.object({ + robots: z.array(z.object({ id: z.string() })), + viewers: z.number(), + perRobot: z.record(z.string(), z.object({ subs: z.array(z.string()) })), +}); +const cloudSchema = z.object({ + bytes: z.instanceof(Uint8Array), + meta: z.object({ + count: z.number().int().positive(), + frame: z.string().min(1), + capture_ts: z.number().positive(), + sha256: z.string(), + }), +}); +async function until( + check: () => boolean | Promise, + label: string, + timeout = 30000, +): Promise { + const deadline = Date.now() + timeout; + while (!(await check())) { + if (Date.now() > deadline) throw new Error("Timed out: " + label); + await delay(50); + } +} +async function stop(child: ChildProcess): Promise { + if (child.exitCode !== null || child.signalCode || !child.pid) return; + const exited = once(child, "exit"); + child.kill("SIGINT"); + const timer = setTimeout(() => { + try { + process.kill(-child.pid!, "SIGKILL"); + } catch {} + }, 10000); + try { + await exited; + } finally { + clearTimeout(timer); + } +} + +test( + "standard Go2 blueprint streams video, full XYZ and odometry through the Node Web SDK", + { + timeout: 150000, + skip: !( + process.env.DIMCODE_TEST_PYTHON && + process.env.DIMCODE_TEST_DENO && + process.env.DIMCODE_TEST_GO2_DB + ), + }, + async (t) => { + const relay = spawn( + process.env.DIMCODE_TEST_DENO!, + [ + "run", + "--allow-net", + "--allow-read", + "../web/relay/main.ts", + "--port", + "0", + ], + { detached: true, stdio: ["ignore", "pipe", "pipe"] }, + ); + t.after(() => stop(relay)); + let relayLog = "", + httpPort = 0; + relay.stderr.on("data", (chunk) => { + relayLog = (relayLog + chunk).slice(-8000); + }); + relay.on("error", (error) => { + relayLog += error.message; + }); + let buffer = ""; + relay.stdout.on("data", (chunk) => { + buffer += chunk; + const lines = buffer.split("\n"); + buffer = lines.pop()!; + for (const line of lines) + if (line.startsWith('{"event":"ready"')) + httpPort = z + .object({ httpPort: z.number() }) + .parse(JSON.parse(line)).httpPort; + }); + await until(() => { + assert.equal(relay.exitCode, null, relayLog); + return httpPort > 0; + }, "relay startup"); + const url = "http://127.0.0.1:" + httpPort; + const stats = async () => + statsSchema.parse(await (await fetch(url + "/api/stats")).json()); + const scout = createSocket("udp4"); + scout.bind(0, "127.0.0.1"); + await once(scout, "listening"); + const scoutPort = scout.address().port; + scout.close(); + const robot = "dimcode-e2e-" + process.pid; + const blueprint = spawn( + process.env.DIMCODE_TEST_PYTHON!, + ["-c", "from go2_media import main; main()"], + { + detached: true, + env: { + ...process.env, + PYTHONPATH: resolve("test/fixtures") + ":" + resolve(".."), + REPLAY: "true", + REPLAY_DB: resolve(process.env.DIMCODE_TEST_GO2_DB!), + VIEWER: "none", + RELAY_URL: url, + ROBOT_ID: robot, + ZENOH_SCOUT_ADDR: "239.255.79.22:" + scoutPort, + }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + t.after(() => stop(blueprint)); + let blueprintLog = ""; + const record = (chunk: Buffer) => { + blueprintLog = (blueprintLog + chunk).slice(-16000); + }; + blueprint.stdout.on("data", record); + blueprint.stderr.on("data", record); + blueprint.on("error", (error) => { + blueprintLog += error.message; + }); + try { + await until( + async () => { + assert.equal(blueprint.exitCode, null, blueprintLog); + return (await stats()).robots.some((item) => item.id === robot); + }, + "Go2 blueprint registration", + 90000, + ); + assert.equal((await stats()).viewers, 0); + assert.deepEqual((await stats()).perRobot[robot].subs, []); + const decoders = createDecoderRegistry(); + decoders.register("dimcode-test.xyz.v1", (bytes, header) => ({ + value: cloudSchema.parse({ bytes, meta: header.meta }), + })); + let opens = 0; + const pool = new MediaPool( + nodeTransport, + { decoders }, + (options, deps) => { + opens++; + return connect(options, deps); + }, + ); + t.after(() => pool.close()); + assert.equal(opens, 0); + const frames = new Map(), + clouds = new Map(), + poses = new Map(); + const capture = async (channel: string, values: Map) => { + const lease = await pool.acquire( + { url, robot }, + channel, + (slot) => slot, + () => { + const slot = lease.current(); + if (slot) values.set(slot.seq, slot); + }, + ); + t.after(() => lease.close()); + return lease; + }; + const camera = await capture("color_image", frames), + cloud = await capture("lidar", clouds), + pose = await capture("odom", poses); + assert.equal(opens, 1); + await until( + () => frames.size >= 12 && clouds.size >= 5 && poses.size >= 5, + "video, XYZ and pose frames", + ); + const hashes = new Set(); + for (const slot of frames.values()) { + assert(slot.value instanceof Uint8Array); + const decoded = await sharp(slot.value) + .raw() + .toBuffer({ resolveWithObject: true }); + assert(decoded.info.width > 100 && decoded.info.height > 100); + hashes.add(createHash("sha256").update(decoded.data).digest("hex")); + } + assert(hashes.size > 1, "video contains distinct decoded frames"); + const counts: number[] = []; + for (const slot of clouds.values()) { + const { bytes, meta } = cloudSchema.parse(slot.value); + assert.equal(bytes.byteLength, meta.count * 3 * 4); + assert.equal( + createHash("sha256").update(bytes).digest("hex"), + meta.sha256, + ); + const view = new DataView( + bytes.buffer, + bytes.byteOffset, + bytes.byteLength, + ); + let nonzeroZ = 0; + for (let i = 0; i < bytes.byteLength; i += 4) { + const value = view.getFloat32(i, true); + assert(Number.isFinite(value)); + if (i % 12 === 8 && value !== 0) nonzeroZ++; + } + assert(nonzeroZ > 0, "actual Z coordinates, not an XY projection"); + counts.push(meta.count); + } + for (const slot of poses.values()) + z.object({ + x: z.number(), + y: z.number(), + z: z.number(), + ts: z.number().positive(), + }).parse(slot.value); + camera.close(); + pose.close(); + await until( + async () => (await stats()).perRobot[robot].subs.join() === "lidar", + "unused channels unsubscribe", + ); + const previous = clouds.size; + await until( + () => clouds.size > previous, + "point clouds continue after camera closes", + ); + cloud.close(); + await until(async () => { + const value = await stats(); + return value.viewers === 0 && value.perRobot[robot].subs.length === 0; + }, "last renderer releases viewer and subscriptions"); + const report = { + blueprint: "unitree-go2 + cockpit", + videoFrames: frames.size, + distinctVideoFrames: hashes.size, + pointClouds: counts.length, + pointsPerCloud: counts, + odometryFrames: poses.size, + sourceHashesMatch: true, + finalViewers: 0, + finalSubscriptions: [], + }; + t.diagnostic(JSON.stringify(report)); + if (process.env.DIMCODE_TEST_REPORT) + await writeFile( + process.env.DIMCODE_TEST_REPORT, + JSON.stringify(report, null, 2) + "\n", + ); + } catch (error) { + t.diagnostic(blueprintLog); + t.diagnostic(relayLog); + throw error; + } + }, +); diff --git a/agent/test/cli.test.ts b/agent/test/cli.test.ts new file mode 100644 index 00000000000..90dd68f423e --- /dev/null +++ b/agent/test/cli.test.ts @@ -0,0 +1,174 @@ +import assert from "node:assert/strict"; +import { execFile, spawn } from "node:child_process"; +import { once } from "node:events"; +import { mkdtemp, rm } from "node:fs/promises"; +import { createServer, type Socket } from "node:net"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { test } from "node:test"; +import { promisify } from "node:util"; +import { paths, saveConfig, configSchema } from "../src/config.js"; +import { reachable, readLines, requestSchema, send } from "../src/protocol.js"; + +const exec = promisify(execFile); +test( + "CLI opens tui, reports an existing gateway and rejects typos without starting one", + { timeout: 30000 }, + async (t) => { + const home = await mkdtemp(join(tmpdir(), "dimcode-cli-")); + const env = { + ...process.env, + DIMCODE_HOME: join(home, "config"), + XDG_STATE_HOME: join(home, "state"), + XDG_CACHE_HOME: join(home, "cache"), + XDG_RUNTIME_DIR: home, + }; + const cli = resolve(".test/src/main.js"); + const run = (...args: string[]) => + exec(process.execPath, [cli, ...args], { env }); + t.after(async () => { + try { + await run("stop"); + } catch {} + await rm(home, { recursive: true, force: true }); + }); + assert.match((await run("--help")).stdout, /dimcode \[tui\]/); + await assert.rejects(run("typo"), /Unknown command/); + assert(!(await reachable(paths(env).socket))); + const key = "cli-fixture-key"; + const configured = await exec( + process.execPath, + [ + cli, + "setup", + "--provider", + "openai", + "--key-env", + "DIMCODE_FIXTURE_KEY", + "--cwd", + home, + ], + { env: { ...env, DIMCODE_FIXTURE_KEY: key } }, + ); + assert(!configured.stdout.includes(key)); + const child = spawn(process.execPath, [cli, "tui"], { + env, + stdio: ["pipe", "pipe", "pipe"], + }); + t.after(() => { + child.kill(); + }); + let output = "", + sent = false, + exited = false, + stderr = ""; + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + child.stdout.on("data", (chunk) => { + output += chunk; + if (!sent && output.includes("Ready")) { + sent = true; + child.stdin.write("/help\r"); + } + if (sent && !exited && output.includes("/resume ID")) { + exited = true; + child.stdin.end("/exit\r"); + } + }); + const timer = setTimeout(() => child.kill(), 15000); + const [code] = await once(child, "exit"); + clearTimeout(timer); + assert.equal(code, 0, stderr + output); + assert.match(output, /Build apps/); + assert.match(output, /\/resume ID/); + assert.match((await run("gateway")).stdout, /Gateway already running/); + }, +); + +test( + "terminal drops old-session events queued during a session switch", + { timeout: 15000 }, + async (t) => { + const home = await mkdtemp(join(tmpdir(), "dimcode-switch-")); + const env = { ...process.env, DIMCODE_HOME: home, XDG_RUNTIME_DIR: home }; + const p = paths(env); + await saveConfig(p, configSchema.parse({ workspace: home })); + const sockets = new Set(); + let sessions = 0; + const server = createServer((socket) => { + sockets.add(socket); + socket.on("error", () => {}); + socket.on("close", () => sockets.delete(socket)); + readLines(socket, (value) => { + const request = requestSchema.parse(value); + if (request.command.type !== "new_session") return; + sessions++; + if (sessions === 2) + send(socket, { + type: "event", + sessionId: "session-a", + seq: 100, + event: { type: "notice", message: "STALE_A_EVENT" }, + }); + send(socket, { + type: "response", + id: request.id, + data: { + sessionId: sessions === 1 ? "session-a" : "session-b", + seq: 0, + cwd: home, + busy: false, + writable: true, + messages: [], + text: "", + notices: [], + tools: [], + }, + }); + if (sessions === 2) + send(socket, { + type: "event", + sessionId: "session-b", + seq: 1, + event: { type: "notice", message: "CURRENT_B_EVENT" }, + }); + }); + }); + t.after(async () => { + for (const socket of sockets) socket.destroy(); + await new Promise((resolve) => server.close(() => resolve())); + await rm(home, { recursive: true, force: true }); + }); + server.listen(p.socket); + await once(server, "listening"); + const child = spawn( + process.execPath, + [resolve(".test/src/main.js"), "tui"], + { env, stdio: ["pipe", "pipe", "pipe"] }, + ); + t.after(() => { + child.kill(); + }); + let output = "", + switched = false, + exited = false; + child.stdout.on("data", (chunk) => { + output += chunk; + if (!switched && output.includes("Ready")) { + switched = true; + child.stdin.write("/new\r"); + } + if (!exited && output.includes("CURRENT_B_EVENT")) { + exited = true; + child.stdin.end("/exit\r"); + } + }); + const timer = setTimeout(() => child.kill(), 10000); + const [code] = await once(child, "exit"); + clearTimeout(timer); + assert.equal(code, 0, output); + assert(!output.includes("STALE_A_EVENT")); + assert(output.includes("CURRENT_B_EVENT")); + }, +); diff --git a/agent/test/fixtures/go2_media.py b/agent/test/fixtures/go2_media.py new file mode 100644 index 00000000000..a25524e186a --- /dev/null +++ b/agent/test/fixtures/go2_media.py @@ -0,0 +1,62 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Standard Go2 replay + cockpit; the test codec preserves every XYZ float.""" + +import hashlib + +from dimos.core.coordination.blueprints import autoconnect +from dimos.core.coordination.module_coordinator import ModuleCoordinator +from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped +from dimos.msgs.sensor_msgs.Image import Image +from dimos.msgs.sensor_msgs.PointCloud2 import PointCloud2 +from dimos.robot.unitree.go2.blueprints.smart.unitree_go2 import unitree_go2 +from dimos.web.cockpit import Channel, cockpit +from dimos.web.codecs import EncodedPayload, web_encoder +from dimos.web.websocket_vis.websocket_vis_module import WebsocketVisModule + + +@web_encoder("dimcode-test.xyz.v1") +def encode_xyz(msg: PointCloud2) -> EncodedPayload: + points = msg.points_f32().astype(" None: + ModuleCoordinator.build(go2_media).loop() + + +if __name__ == "__main__": + main() diff --git a/agent/test/gateway.test.ts b/agent/test/gateway.test.ts index 35eefeb2faa..6e7e784e038 100644 --- a/agent/test/gateway.test.ts +++ b/agent/test/gateway.test.ts @@ -166,3 +166,82 @@ test( ); }, ); + +test( + "large image history remains attachable and events identify their originating session", + { timeout: 15000 }, + async (t) => { + const root = await mkdtemp(join(tmpdir(), "dimcode-history-")); + t.after(() => rm(root, { recursive: true, force: true })); + const p: Paths = { + config: join(root, "config"), + state: root, + cache: join(root, "cache"), + sessions: join(root, "sessions"), + socket: join(root, "test.sock"), + }; + await mkdir(p.config); + await mkdir(p.sessions); + const factory = fixtureFactory(Promise.resolve(), () => {}); + let completeHistory = 0; + const gateway = new Gateway( + configSchema.parse({ workspace: root }), + p, + async (...args) => { + const manager = args[2]; + for (let i = 0; i < 3; i++) + manager.appendMessage({ + role: "user", + content: [ + { + type: "image", + mimeType: "image/png", + data: "A".repeat(6 * 1024 * 1024), + }, + ], + timestamp: Date.now(), + }); + manager.appendMessage({ + role: "user", + content: "most recent message", + timestamp: Date.now(), + }); + const handle = await factory(...args); + completeHistory = handle.session.messages.length; + return handle; + }, + ); + await gateway.start(); + t.after(() => gateway.close()); + const client = new Connection(p.socket); + t.after(() => client.close()); + const first = await client.call({ type: "new_session" }); + assert.equal(completeHistory, 4); + assert(first.messages.length < completeHistory); + const latest = first.messages.at(-1); + assert(latest?.role === "user"); + assert.equal(latest.content, "most recent message"); + assert( + first.notices.some((text) => text.includes("earlier messages omitted")), + ); + const origin = deferred(); + client.onEvent = (_seq, _event, sessionId) => origin.resolve(sessionId); + await client.call({ type: "abort" }); + assert.equal(await origin.promise, first.sessionId); + const second = await client.call({ type: "new_session" }); + assert.notEqual(second.sessionId, first.sessionId); + const next = deferred(); + client.onEvent = (_seq, _event, sessionId) => next.resolve(sessionId); + await client.call({ type: "abort" }); + assert.equal(await next.promise, second.sessionId); + const resumed = await client.call({ + type: "attach", + sessionId: first.sessionId, + writable: true, + }); + const restored = resumed.messages.at(-1); + assert(restored?.role === "user"); + assert.equal(restored.content, "most recent message"); + assert.equal(completeHistory, 4); + }, +); diff --git a/agent/test/render.test.ts b/agent/test/render.test.ts index a9ef00b1547..78ce829d60b 100644 --- a/agent/test/render.test.ts +++ b/agent/test/render.test.ts @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import { test } from "node:test"; import sharp from "sharp"; import { plot } from "../src/render.js"; -import { ChatInput } from "../src/terminal.js"; +import { ChatInput } from "../src/input.js"; test("point projection and measured series produce real images without mutating inputs", async () => { const input = { diff --git a/agent/test/run.mjs b/agent/test/run.mjs index 284a1240fdd..0b0e2a6f30f 100644 --- a/agent/test/run.mjs +++ b/agent/test/run.mjs @@ -6,7 +6,8 @@ const tests = (await readdir("test")).filter((name) => name.endsWith(".test.ts"), ); await build({ - entryPoints: tests.map((name) => "test/" + name), + entryPoints: [...tests.map((name) => "test/" + name), "src/main.ts"], + outbase: ".", outdir: ".test", bundle: true, format: "esm", @@ -21,7 +22,10 @@ await build({ }); const child = spawn( process.execPath, - ["--test", ...tests.map((name) => ".test/" + name.replace(/\.ts$/, ".js"))], + [ + "--test", + ...tests.map((name) => ".test/test/" + name.replace(/\.ts$/, ".js")), + ], { stdio: "inherit" }, ); child.on("exit", (code) => { diff --git a/agent/test/setup.test.ts b/agent/test/setup.test.ts new file mode 100644 index 00000000000..b1093b7aab0 --- /dev/null +++ b/agent/test/setup.test.ts @@ -0,0 +1,67 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import { SettingsManager } from "@earendil-works/pi-coding-agent"; +import { loadConfig, paths } from "../src/config.js"; +import { needsSetup, setup } from "../src/setup.js"; + +for (const cancel of [false, true]) + test( + "onboarding " + + (cancel + ? "can cancel and resume" + : "saves provider, model and workspace with private credentials"), + async (t) => { + const home = await mkdtemp(join(tmpdir(), "dimcode-setup-")); + t.after(() => rm(home, { recursive: true, force: true })); + const p = paths({ DIMCODE_HOME: home }); + assert(await needsSetup(p)); + let asked = 0; + const configure = setup( + p, + {}, + { + prompt: async (question) => { + asked++; + if (cancel && asked === 2) throw new Error("Cancelled"); + if (question.type === "select") { + if (question.options.some((item) => item.id === "openai")) + return "openai"; + if (question.options.some((item) => item.id === "api_key")) + return "api_key"; + if (question.options.some((item) => item.id === "gpt-5.6-luna")) + return "gpt-5.6-luna"; + return question.options[0].id; + } + if (question.type === "secret") return "onboarding-fixture-key"; + return question.message.includes("Workspace") ? home : ""; + }, + notify: () => {}, + }, + ); + if (cancel) { + await assert.rejects(configure, /Cancelled/); + assert(await needsSetup(p)); + return; + } + await configure; + assert(!(await needsSetup(p))); + assert.equal((await loadConfig(p)).workspace, home); + assert.equal( + SettingsManager.create(home, p.config).getDefaultProvider(), + "openai", + ); + assert.equal( + SettingsManager.create(home, p.config).getDefaultModel(), + "gpt-5.6-luna", + ); + assert.equal((await stat(join(home, "auth.json"))).mode & 0o777, 0o600); + assert( + !(await readFile(join(home, "config.json"), "utf8")).includes( + "fixture-key", + ), + ); + }, + ); From 34e757079df606355ed397cf7320f6ace8135af4 Mon Sep 17 00:00:00 2001 From: stash Date: Sat, 12 Sep 2026 03:55:29 -0700 Subject: [PATCH 3/9] feat(dimcode): render native memory views in Pi terminal cards --- agent/README.md | 27 ++- agent/skills/dimensional/SKILL.md | 4 +- agent/src/gateway.ts | 3 + agent/src/input.ts | 77 ++++++- agent/src/protocol.ts | 1 + agent/src/render.ts | 122 ++++++++--- agent/src/spatial.ts | 300 ++++++++++++++++++++++++++++ agent/src/terminal-style.ts | 32 +++ agent/src/terminal.ts | 278 +++++++++++++++++++++++--- agent/src/tool-images.ts | 162 +++++++++++++++ agent/test/fixtures/memory_views.py | 79 ++++++++ agent/test/memory.test.ts | 146 ++++++++++++++ agent/test/terminal.test.ts | 76 +++++++ 13 files changed, 1244 insertions(+), 63 deletions(-) create mode 100644 agent/src/spatial.ts create mode 100644 agent/src/terminal-style.ts create mode 100644 agent/src/tool-images.ts create mode 100644 agent/test/fixtures/memory_views.py create mode 100644 agent/test/memory.test.ts create mode 100644 agent/test/terminal.test.ts diff --git a/agent/README.md b/agent/README.md index 2db7755a5d1..a7d1c95b1c7 100644 --- a/agent/README.md +++ b/agent/README.md @@ -11,6 +11,21 @@ dimcode setup dimcode ``` +`dimcode` opens the fullscreen Pi-based terminal: scrollable chat, a multiline editor with command/file completion, compact tool output, and a persistent model/status footer. Shift+Enter adds a line; Ctrl-C detaches from the running gateway. + +Memory results appear **inside the agent's tool card**, with an overview combining DimOS's timeline, spatial map and selected frames. Click a panel label or enter `/panel 1`, `/panel 2`, etc. to see a larger individual view; `/panel 0` returns to the overview. `/expand` shows the latest tool's full output and source hashes. Use a terminal with image support (such as Kitty) for the SVG/PNG views; other terminals show image metadata. + +To inspect a saved cloud without a model call, enter these commands **inside dimcode**: + +```text +/inspect /absolute/path/to/cloud.json +/view +``` + +Cloud exports use `{ "points": [[x, y, z]], "frame": "world", "timestamp": 123.45 }`. In the inspector, **←/→** rotates, **+/-** zooms, **g** switches graphics/Braille, **d** shows source/hash, **0** resets the view, and **Esc** returns to chat. An export can supply `selectedIndices` to highlight the exact selection produced by its owning operation. Rotation and zoom change the view only; memory analysis and filtering stay in DimOS. + +Point-cloud results from `dimcode_render` open the same inspector through `/view`. The terminal verifies the source hash before loading interactive geometry; if the file changed, the original tool preview remains available. `/expand` toggles details for the latest tool. Images use supported terminal graphics; Braille supports point-cloud inspection without image support. + `dimcode setup` walks through: 1. Provider and authentication: paste an API key into a masked field, use existing credentials, or sign in with a ChatGPT subscription. @@ -73,13 +88,21 @@ dimcode run "inspect the app and explain its blueprint" Endpoints are explicit; ports are examples, not instance identities. Use `/reload` after changing endpoints. Every advertised skill is registered with its original schema, metadata and remote routing. Tool names are bounded and collision-resistant. There is no static copy of the robot's tools or a parallel lifecycle service. -Terminal commands: `/new`, `/sessions`, `/resume ID`, `/models`, `/model PROVIDER MODEL`, `/login PROVIDER [oauth]`, `/logout PROVIDER`, `/abort`, `/steer TEXT`, `/follow TEXT`, `/reload`, `/image PATH`, `/expand`, `/exit`. Ctrl-C detaches. One terminal owns input; other viewers may observe. Detaching keeps the turn running. Restart restores Pi history and never automatically replays external actions. Very large histories show a bounded recent transcript with an omission notice; the complete agent history remains on disk. Session events carry their source identity so switching sessions cannot mix transcripts. +Terminal commands: `/new`, `/sessions`, `/resume ID`, `/models`, `/model PROVIDER MODEL`, `/login PROVIDER [oauth]`, `/logout PROVIDER`, `/abort`, `/steer TEXT`, `/follow TEXT`, `/reload`, `/image PATH`, `/panel N`, `/inspect PATH`, `/view`, `/expand`, `/exit`. Ctrl-C detaches. One terminal owns input; other viewers may observe. Detaching keeps the turn running. Restart restores Pi history and never automatically replays external actions. Very large histories show a bounded recent transcript with an omission notice; the complete agent history remains on disk. Session events carry their source identity so switching sessions cannot mix transcripts. Pi owns context loading, skills, compaction, models and coding-tool behavior. Workspace instructions and configured Pi extensions load normally. Attached terminals support serialized dialogs/notifications; executable extension UI factories belong in the terminal renderer and cannot be sent through a socket. ## Tool rendering -`dimcode_render` displays existing image, point-cloud or numeric-series exports and returns the selected PNG to the model. Point clouds use XYZ rows; series use timestamp/value pairs. See the bundled Dimensional skill for formats. The original file, SHA-256, selection metadata and display decimation remain explicit. The renderer never repeats a memory query. +DimOS memory owns video/frame/point-cloud analysis. Evaluate the memory operation once with `materialize()`, then use its existing `Space.to_svg()` / `Plot.to_svg()` and selected-frame exports. Pass those exports together to `dimcode_render`: + +```json +{"kind":"image","title":"Memory · plant search","views":[{"label":"Timeline","path":"timeline.svg"},{"label":"Spatial","path":"space.svg"},{"label":"Frames","path":"frames.png"}]} +``` + +The tool rasterizes the original SVG/PNG views for terminal display and model context. Every view reaches the model; the terminal presents a selectable overview. Each export retains its source path and SHA-256. MCP tool results containing multiple images also appear together automatically. No query or filter runs inside the renderer. Existing skills that return only a pose or JSON still require an explicit export; the harness does not invent missing views. + +`dimcode_render` also accepts single `image` paths and saved `points` / `series` JSON. Point clouds use XYZ rows; series use timestamp/value pairs. Selection metadata and display decimation remain explicit. See the bundled Dimensional skill for formats. Live tools select an existing relay/robot/channel. The terminal receives frames directly through the Web SDK and coalesces drawing to 10 Hz. The gateway retains one final snapshot for model context. Closing/cancelling the tool releases consumers; the last consumer closes the connection. MediaPool is generic over decoded SDK slots and accepts existing decoder registries. The initial live image renderer handles JPEG; other channel types use their owning decoder/renderer or saved exports. diff --git a/agent/skills/dimensional/SKILL.md b/agent/skills/dimensional/SKILL.md index d2681b01373..c7110b5e256 100644 --- a/agent/skills/dimensional/SKILL.md +++ b/agent/skills/dimensional/SKILL.md @@ -15,7 +15,9 @@ Use the selected DimOS executable/Python environment and workspace from the sess For memory, use existing stream operations. Evaluate a query once with materialize(), then derive scores, selected peaks, images and comparisons from that same cache. Preserve timestamps, observation IDs and coordinate frames in exports. The docs reconstruction example includes a skipped block/API TODO; verify the chosen callable operation on actual data. -Use dimcode_render for an existing saved image or JSON export: points come from the existing point cloud extraction API as XYZ rows; series are numeric [timestamp,value] rows derived from the evaluated operation. Retain original exports and their metadata. The renderer labels display projection/decimation and hashes the source file. It must never rerun a query to obtain a prettier display. +All analysis of point-cloud frames and video belongs in DimOS memory. Prefer its existing `Space.to_svg()` and `Plot.to_svg()` renderers, alongside selected-frame exports from the same materialized query. Display the views together with `dimcode_render({kind:"image", title:"Memory · QUERY", views:[{label:"Timeline",path:"timeline.svg"},{label:"Spatial",path:"space.svg"},{label:"Frames",path:"frames.png"}]})`. Supply only views actually produced by that operation. Preserve the native colors, timestamps, peaks, coordinate frames and selection metadata; don't reconstruct them in the harness. The terminal displays an overview and individually selectable panels, and the model receives every image. MCP tools returning multiple `agent_encode` images are displayed together automatically; a skill returning only a pose does not provide these views. + +Single saved images and JSON exports remain supported: points use existing point-cloud extraction as XYZ rows with optional `selectedIndices`; series use numeric [timestamp,value] rows from the evaluated operation. Retain original exports and metadata. The renderer labels display projection/decimation and hashes every source. It must never repeat a memory query to obtain a prettier display. For live rendering, explicitly choose the existing relay, robot and channel. Web SDK subscriptions open only for the tool/view's lifetime. Label the view live: it is not a historical selection, and relay header timestamps may be send time. Only a selected final snapshot belongs in model context. Non-image encodings need their existing domain decoder/renderer or an explicit saved export. diff --git a/agent/src/gateway.ts b/agent/src/gateway.ts index 0d0bef5f80a..c78c60d1631 100644 --- a/agent/src/gateway.ts +++ b/agent/src/gateway.ts @@ -246,6 +246,9 @@ export class Gateway { private snapshot(entry: Entry, socket: Socket): Snapshot { const session = entry.handle.session; const snapshot: Snapshot = { + model: session.model + ? { provider: session.model.provider, id: session.model.id } + : undefined, sessionId: session.sessionId, cwd: session.sessionManager.getCwd(), seq: entry.seq, diff --git a/agent/src/input.ts b/agent/src/input.ts index d31116104cb..83995098dc9 100644 --- a/agent/src/input.ts +++ b/agent/src/input.ts @@ -1,4 +1,79 @@ -import { Input, truncateToWidth, type Component } from "@earendil-works/pi-tui"; +import { + CombinedAutocompleteProvider, + Editor, + Input, + matchesKey, + truncateToWidth, + type Component, + type TUI, +} from "@earendil-works/pi-tui"; +import { getSelectListTheme } from "@earendil-works/pi-coding-agent"; +import { accent } from "./terminal-style.js"; + +export const commands = [ + "help", + "new", + "sessions", + "resume", + "models", + "model", + "login", + "logout", + "abort", + "steer", + "follow", + "reload", + "image", + "inspect", + "view", + "panel", + "expand", + "exit", +]; + +/** Pi owns editing, multiline paste, history and file/command completion. */ +export class PromptEditor extends Editor { + private credential = new ChatInput(); + secret = false; + onEscape?: () => void; + constructor(tui: TUI) { + super( + tui, + { selectList: getSelectListTheme(), borderColor: accent }, + { paddingX: 1 }, + ); + } + setWorkspace(cwd: string): void { + this.setAutocompleteProvider( + new CombinedAutocompleteProvider( + commands.map((name) => ({ name })), + cwd, + ), + ); + } + getValue(): string { + return this.secret ? this.credential.getValue() : this.getExpandedText(); + } + setValue(value: string): void { + this.credential.secret = this.secret; + this.credential.setValue(value); + if (!this.secret) this.setText(value); + } + override handleInput(data: string): void { + if (matchesKey(data, "escape") && !this.isShowingAutocomplete()) { + this.onEscape?.(); + return; + } + if (this.secret) { + this.credential.onSubmit = (value) => this.onSubmit?.(value); + this.credential.handleInput(data); + } else super.handleInput(data); + } + override render(width: number): string[] { + if (this.secret) return this.credential.render(width); + return super.render(width); + } +} export class ChatInput implements Component { private editor = new Input({ diff --git a/agent/src/protocol.ts b/agent/src/protocol.ts index 2fc12f3091a..03329dce9af 100644 --- a/agent/src/protocol.ts +++ b/agent/src/protocol.ts @@ -65,6 +65,7 @@ export type Event = | { type: "auth_info"; info: AuthEvent } | { type: "idle" }; export interface Snapshot { + model?: { provider: string; id: string }; sessionId: string; cwd: string; seq: number; diff --git a/agent/src/render.ts b/agent/src/render.ts index a3fc7455e23..3d6aeb4e2a7 100644 --- a/agent/src/render.ts +++ b/agent/src/render.ts @@ -7,9 +7,10 @@ import { unlink, writeFile, } from "node:fs/promises"; -import { join, resolve } from "node:path"; +import { basename, join, resolve } from "node:path"; import { setTimeout as delay } from "node:timers/promises"; import type { ExtensionFactory } from "@earendil-works/pi-coding-agent"; +import type { ImageContent, TextContent } from "@earendil-works/pi-ai"; import { Container, Image, Text, type Component } from "@earendil-works/pi-tui"; import { Type } from "typebox"; import { z } from "zod"; @@ -43,6 +44,12 @@ export const renderDetails = z.object({ source: z.string(), sha256: z.string().optional(), summary: z.string(), + title: z.string().optional(), + views: z + .array( + z.object({ label: z.string(), source: z.string(), sha256: z.string() }), + ) + .optional(), live: z .object({ url: z.string(), @@ -185,6 +192,63 @@ export function imageComponent( ); return block; } + +/** Rasterize DimOS's own exports; never evaluate or reinterpret a memory query. */ +export async function renderFiles( + paths: Paths, + cwd: string, + views: Array<{ path: string; label: string }>, + title?: string, + signal?: AbortSignal, +): Promise<{ + content: Array; + details: RenderDetails; +}> { + if (!views.length || views.length > 6) + throw new Error("Choose 1–6 saved views from the same operation."); + const content: Array = []; + const sources: NonNullable = []; + for (const view of views) { + signal?.throwIfAborted(); + const source = resolve(cwd, view.path); + if ((await stat(source)).size > 32 * 1024 * 1024) + throw new Error( + "Export exceeds 32 MiB; select a smaller view while retaining the original source.", + ); + const bytes = await readFile(source); + const sha256 = createHash("sha256").update(bytes).digest("hex"); + const png = await sharp(bytes, { density: 144 }) + .resize({ + width: 1600, + height: 1200, + fit: "inside", + withoutEnlargement: true, + }) + .flatten({ background: "#101b21" }) + .png() + .toBuffer(); + signal?.throwIfAborted(); + const preview = await cacheImage(paths, png); + sources.push({ label: view.label, source, sha256 }); + content.push( + { + type: "text", + text: `${view.label}\nSource: ${source}\nSHA-256: ${sha256}\nPreview: ${preview}`, + }, + { type: "image", data: png.toString("base64"), mimeType: "image/png" }, + ); + } + return { + content, + details: { + source: sources[0].source, + sha256: sources[0].sha256, + title, + views: sources, + summary: `${sources.length} saved views · original exports retained`, + }, + }; +} export const renderExtension = (paths: Paths): ExtensionFactory => (pi) => { @@ -194,7 +258,7 @@ export const renderExtension = name: "dimcode_render", label: "Inspect sensor result", description: - "Render an actual saved result in the terminal and return a selected image to the model. image: existing image path. points: JSON {points:[[x,y,z]], frame?, timestamp?}. series: JSON {series:[{name,values:[[time,value]]}]}. These are exports of an already evaluated operation: never rerun a query just to render it. live: explicit existing relay URL, robot and channel; the terminal receives live frames directly, and only one final snapshot goes into model context.", + "Display an existing operation's results in its terminal tool card. image: path or views [{path,label}] for up to 6 related SVG/PNG exports, with an optional title. For memory queries prefer DimOS Space/Plot SVGs and selected frames from the same materialized query; all views are returned to the model together. points: JSON {points:[[x,y,z]], frame?, timestamp?, selectedIndices?}. series: JSON {series:[{name,values:[[time,value]]}]}. Never rerun or reimplement memory analysis just to render it. live: explicit existing relay URL, robot and channel; terminal frames stream directly and only one final snapshot enters model context.", parameters: Type.Object({ kind: Type.Union([ Type.Literal("image"), @@ -203,12 +267,30 @@ export const renderExtension = Type.Literal("live"), ]), path: Type.Optional(Type.String()), + title: Type.Optional(Type.String()), + views: Type.Optional( + Type.Array( + Type.Object({ path: Type.String(), label: Type.String() }), + { minItems: 1, maxItems: 6 }, + ), + ), url: Type.Optional(Type.String()), robot: Type.Optional(Type.String()), channel: Type.Optional(Type.String()), seconds: Type.Optional(Type.Number({ minimum: 1, maximum: 30 })), }), execute: async (_id, args, signal, update, ctx) => { + if (args.kind === "image") + return renderFiles( + paths, + ctx.cwd, + args.views ?? + (args.path + ? [{ path: args.path, label: basename(args.path) }] + : []), + args.title, + signal, + ); let png: Buffer, source: string, summary: string, @@ -285,22 +367,10 @@ export const renderExtension = const bytes = await readFile(source); signal?.throwIfAborted(); digest = createHash("sha256").update(bytes).digest("hex"); - if (args.kind === "image") { - png = await sharp(bytes) - .resize({ - width: 1280, - height: 960, - fit: "inside", - withoutEnlargement: true, - }) - .png() - .toBuffer(); - summary = "Preview of saved image; original file retained."; - } else - ({ png, summary } = await plot( - JSON.parse(bytes.toString("utf8")), - args.kind, - )); + ({ png, summary } = await plot( + JSON.parse(bytes.toString("utf8")), + args.kind, + )); } const preview = await cacheImage(paths, png); return { @@ -325,14 +395,14 @@ export const renderExtension = }; }, renderResult: (result) => { - const image = result.content.find((item) => item.type === "image"); - const text = result.content - .filter((item) => item.type === "text") - .map((item) => item.text) - .join("\n"); - return image - ? imageComponent(image.data, image.mimeType, text) - : new Text(text, 0, 0); + const block = new Container(); + for (const item of result.content) + block.addChild( + item.type === "image" + ? imageComponent(item.data, item.mimeType) + : new Text(item.text, 0, 0), + ); + return block; }, }); }; diff --git a/agent/src/spatial.ts b/agent/src/spatial.ts new file mode 100644 index 00000000000..d362529948f --- /dev/null +++ b/agent/src/spatial.ts @@ -0,0 +1,300 @@ +import { createHash } from "node:crypto"; +import { readFile, stat } from "node:fs/promises"; +import { basename } from "node:path"; +import { + getCapabilities, + Image, + matchesKey, + Text, + truncateToWidth, + type Component, +} from "@earendil-works/pi-tui"; +import sharp from "sharp"; +import { z } from "zod"; +import { accent, amber, clean, muted, StatusLine } from "./terminal-style.js"; + +export const cloudSchema = z + .object({ + points: z + .array( + z.tuple([ + z.number().finite(), + z.number().finite(), + z.number().finite(), + ]), + ) + .min(1) + .max(1_000_000), + frame: z.string().optional(), + timestamp: z.number().finite().optional(), + selectedIndices: z.array(z.number().int().nonnegative()).optional(), + }) + .refine( + (cloud) => + cloud.selectedIndices?.every((i) => i < cloud.points.length) ?? true, + "Selection index outside point cloud", + ); +type Cloud = z.infer; +type XYZ = Cloud["points"][number]; + +export async function readCloud( + path: string, + digest?: string, +): Promise<{ cloud: Cloud; sha256: string }> { + if ((await stat(path)).size > 32 * 1024 * 1024) + throw new Error("Point-cloud export exceeds 32 MiB"); + const bytes = await readFile(path); + const sha256 = createHash("sha256").update(bytes).digest("hex"); + if (digest && digest !== sha256) + throw new Error( + "Source changed since the agent inspected it; showing the saved result instead", + ); + return { + cloud: cloudSchema.parse(JSON.parse(bytes.toString("utf8"))), + sha256, + }; +} + +/** A presentation of one immutable observation. View controls never execute a DimOS query. */ +export class SpatialView implements Component { + private angle = -0.58; + private zoom = 1; + private graphics = true; + private details = false; + private image?: Image; + private revision = 0; + private disposed = false; + private animation?: ReturnType; + private readonly center: XYZ; + private readonly extent: number; + private readonly floor: number; + private readonly height: number; + private readonly selected: Set; + private readonly step: number; + private readonly heading: StatusLine; + expanded = false; + constructor( + readonly cloud: Cloud, + readonly source: string, + readonly sha256: string, + private readonly changed: () => void, + ) { + const low: XYZ = [Infinity, Infinity, Infinity], + high: XYZ = [-Infinity, -Infinity, -Infinity]; + for (const point of cloud.points) + for (let i = 0; i < 3; i++) { + low[i] = Math.min(low[i], point[i]); + high[i] = Math.max(high[i], point[i]); + } + this.center = low.map((n, i) => (n + high[i]) / 2) as XYZ; + this.floor = low[2]; + this.height = Math.max(high[2] - low[2], 0.001); + this.extent = Math.max(...high.map((n, i) => n - low[i]), 0.001); + this.selected = new Set(cloud.selectedIndices); + this.step = Math.max(1, Math.ceil(cloud.points.length / 20_000)); + this.heading = new StatusLine(() => [ + accent("⠿ PointCloud") + muted(" " + clean(basename(source))), + muted( + `${cloud.points.length.toLocaleString()} points · ${clean(cloud.frame ?? "frame unspecified")}`, + ), + ]); + this.refresh(); + } + invalidate(): void { + this.image?.invalidate(); + } + close(): void { + this.disposed = true; + this.revision++; + clearInterval(this.animation); + } + private retained(i: number): boolean { + return this.selected.has(i); + } + private project(point: XYZ, width: number, height: number): [number, number] { + const [x, y, z] = point.map((v, i) => v - this.center[i]); + const c = Math.cos(this.angle), + s = Math.sin(this.angle); + const scale = + (Math.min(width / 1.65, height / 1.2) / this.extent) * this.zoom; + return [ + width / 2 + (x * c - y * s) * scale, + height / 2 + (x * s + y * c) * scale * 0.5 - z * scale, + ]; + } + private async raster(revision: number): Promise { + const masked = !!this.cloud.selectedIndices; + const circles: string[] = []; + for (let i = 0; i < this.cloud.points.length; i += this.step) { + const [x, y] = this.project(this.cloud.points[i], 900, 400); + const keep = this.retained(i); + const elevation = (this.cloud.points[i][2] - this.floor) / this.height; + circles.push( + ``, + ); + } + const grid: string[] = []; + for (let i = -3; i <= 3; i++) + for (const axis of [0, 1]) { + const a = [...this.center] as XYZ, + b = [...this.center] as XYZ; + a[axis] += (i * this.extent) / 6; + b[axis] = a[axis]; + a[1 - axis] -= this.extent / 2; + b[1 - axis] += this.extent / 2; + a[2] = this.floor; + b[2] = a[2]; + const start = this.project(a, 900, 400), + end = this.project(b, 900, 400); + grid.push( + ``, + ); + } + const png = await sharp( + Buffer.from( + `${grid.join("")}${circles.join("")}xzy`, + ), + ) + .removeAlpha() + .png() + .toBuffer(); + if (this.disposed || revision !== this.revision) return; + this.image = new Image( + png.toString("base64"), + "image/png", + { fallbackColor: muted }, + { + maxHeightCells: this.expanded ? 30 : 20, + maxWidthCells: this.expanded ? 150 : 100, + }, + ); + this.changed(); + } + private refresh(): void { + const revision = ++this.revision; + if (this.graphics && getCapabilities().images) + void this.raster(revision).catch(() => { + if (revision === this.revision) { + this.graphics = false; + this.changed(); + } + }); + this.changed(); + } + handleInput(key: string): void { + if (key === "r" || matchesKey(key, "right") || matchesKey(key, "left")) { + clearInterval(this.animation); + const direction = matchesKey(key, "left") ? -1 : 1; + let frames = 0; + this.animation = setInterval(() => { + this.angle += (direction * Math.PI) / 16; + this.refresh(); + if (++frames === 4) clearInterval(this.animation); + }, 80); + } else { + if (key === "+" || key === "=") this.zoom = Math.min(4, this.zoom * 1.2); + else if (key === "-") this.zoom = Math.max(0.25, this.zoom / 1.2); + else if (key === "g") { + this.graphics = !this.graphics; + this.image = undefined; + } else if (key === "d") this.details = !this.details; + else if (key === "0") { + this.zoom = 1; + this.angle = -0.58; + } else return; + this.refresh(); + } + } + setExpanded(value: boolean): void { + this.expanded = value; + this.image = undefined; + this.refresh(); + } + private braille(width: number): string[] { + const rows = this.expanded ? 26 : 14, + cols = Math.max(1, width - 2); + const cells = Array.from({ length: rows }, () => + Array.from({ length: cols }, () => ({ dots: 0, selected: false })), + ); + const bits = [ + [1, 8], + [2, 16], + [4, 32], + [64, 128], + ]; + for (let i = 0; i < this.cloud.points.length; i += this.step) { + const [px, py] = this.project(this.cloud.points[i], cols * 2, rows * 4); + const x = Math.floor(px), + y = Math.floor(py); + if (x < 0 || y < 0 || x >= cols * 2 || y >= rows * 4) continue; + const cell = cells[Math.floor(y / 4)][Math.floor(x / 2)]; + cell.dots |= bits[y % 4][x % 2]; + cell.selected ||= this.retained(i); + } + const masked = !!this.cloud.selectedIndices; + return cells.map( + (row) => + " " + + row + .map((cell) => + cell.dots + ? (cell.selected ? amber : masked ? muted : accent)( + String.fromCharCode(0x2800 + cell.dots), + ) + : " ", + ) + .join(""), + ); + } + render(width: number): string[] { + const lines = [ + "", + ...this.heading.render(width), + muted("─".repeat(Math.max(0, width))), + ]; + lines.push( + ...(this.graphics && getCapabilities().images && this.image + ? this.image.render(width) + : this.braille(width)), + ); + lines.push( + truncateToWidth( + " " + + (this.cloud.selectedIndices + ? amber( + `${this.selected.size.toLocaleString()} source-selected points`, + ) + : muted("Source geometry")), + width, + ), + ); + lines.push( + truncateToWidth( + muted( + ` ${this.cloud.timestamp ?? "timestamp unspecified"} · display 1/${this.step} · ${this.graphics && getCapabilities().images ? "graphics" : "Braille"}`, + ), + width, + ), + ); + lines.push( + ...new Text( + muted( + this.expanded + ? " ←/→ rotate · +/- zoom · g graphics · d source · 0 reset · Esc close" + : " /view rotate, zoom and inspect · /expand tool details", + ), + 0, + 0, + ).render(width), + ); + if (this.details) + lines.push( + ...new Text( + clean(this.source) + "\nSHA-256: " + this.sha256, + 1, + 0, + ).render(width), + ); + return lines; + } +} diff --git a/agent/src/terminal-style.ts b/agent/src/terminal-style.ts new file mode 100644 index 00000000000..5ae0066dcb9 --- /dev/null +++ b/agent/src/terminal-style.ts @@ -0,0 +1,32 @@ +import { + truncateToWidth, + visibleWidth, + type Component, +} from "@earendil-works/pi-tui"; + +export const accent = (s: string) => `\x1b[38;2;106;229;207m${s}\x1b[39m`; +export const muted = (s: string) => `\x1b[38;2;139;164;168m${s}\x1b[39m`; +export const amber = (s: string) => `\x1b[38;2;245;192;120m${s}\x1b[39m`; +export const clean = (s: string): string => + s.replace(/[\x00-\x1f\x7f-\x9f]/g, " "); + +/** Width-aware chrome; contents come from the active session, never placeholder metrics. */ +export class StatusLine implements Component { + constructor(private readonly content: () => [string, string?]) {} + invalidate(): void {} + render(width: number): string[] { + const [left, right = ""] = this.content(); + const available = Math.max(0, width - visibleWidth(right) - 4); + if (available < 20) return [truncateToWidth(" " + left, width)]; + const clipped = truncateToWidth(left, available); + return [ + " " + + clipped + + " ".repeat( + Math.max(1, width - visibleWidth(clipped) - visibleWidth(right) - 2), + ) + + right + + " ", + ]; + } +} diff --git a/agent/src/terminal.ts b/agent/src/terminal.ts index 362153cf027..d4cbe522c02 100644 --- a/agent/src/terminal.ts +++ b/agent/src/terminal.ts @@ -1,12 +1,16 @@ import { readFile } from "node:fs/promises"; -import { resolve } from "node:path"; +import { basename, resolve } from "node:path"; import { Container, Markdown, Spacer, ProcessTerminal, Text, - TuiMainScreen, + TuiAltScreen, + ScrollView, + VStack, + matchesKey, + type Component, } from "@earendil-works/pi-tui"; import sharp from "sharp"; import { @@ -24,7 +28,10 @@ import { z } from "zod"; import { Connection, type Event, type Snapshot } from "./protocol.js"; import { imageComponent, renderDetails } from "./render.js"; import { MediaPool, type MediaLease } from "./media.js"; -import { ChatInput } from "./input.js"; +import { PromptEditor, commands } from "./input.js"; +import { SpatialView, readCloud } from "./spatial.js"; +import { ResultImages } from "./tool-images.js"; +import { accent, clean, muted, StatusLine } from "./terminal-style.js"; import type { Slot } from "@dimos/sdk"; const resultSchema = z.object({ @@ -49,10 +56,51 @@ export async function terminal( initTheme("dark", false); const client = new Connection(socket), pool = new MediaPool(); - const tui = new TuiMainScreen(new ProcessTerminal()), + const tui = new TuiAltScreen(new ProcessTerminal()), transcript = new Container(); const status = new Text("Connecting…", 0, 0), - input = new ChatInput(); + input = new PromptEditor(tui); + let lastSpatial: SpatialView | undefined; + let lastImages: ResultImages | undefined; + let inspectorOpen = false; + let layout: VStack; + const localViews = new Set(); + const inspect = (view = lastSpatial) => { + if (!view) { + notice( + "No point cloud yet. Use /inspect PATH or ask the agent to render one.", + ); + return; + } + inspectorOpen = true; + view.setExpanded(true); + const expanded = { + render: (width) => view.render(width), + invalidate: () => view.invalidate(), + handleInput: (data) => { + if (matchesKey(data, "escape")) { + view.setExpanded(false); + inspectorOpen = false; + tui.setLayoutRoot(layout); + tui.setFocus(input); + } else view.handleInput(data); + }, + } satisfies Component; + tui.setLayoutRoot( + new VStack([ + new StatusLine(() => [ + accent("⠿ dimcode / spatial inspector"), + muted("Esc returns to chat"), + ]), + { + component: new ScrollView(expanded, { primary: true }), + basis: 0, + grow: 1, + }, + ]), + ); + tui.setFocus(expanded); + }; const cards = new Map< string, { @@ -60,6 +108,11 @@ export async function terminal( component: ToolExecutionComponent; output: unknown; expanded: boolean; + toolName: string; + args: unknown; + spatial?: SpatialView; + images?: ResultImages; + loading?: boolean; lease?: MediaLease; close?: () => void; } @@ -77,16 +130,21 @@ export async function terminal( assistant.addChild(new Markdown(value, 1, 1, getMarkdownTheme())); }; const ready = () => - " " + - (current.writable ? "Ready" : "Read-only") + - " · " + - current.sessionId.slice(0, 8); + " " + (current.writable ? "Ready" : "Read-only") + " · /help commands"; const notice = (error: unknown) => { add(String(error)); tui.requestRender(); }; const closeCards = () => { - for (const card of cards.values()) card.close?.(); + for (const card of cards.values()) { + card.close?.(); + card.spatial?.close(); + card.images?.close(); + } + for (const view of localViews) view.close(); + localViews.clear(); + lastSpatial = undefined; + lastImages = undefined; cards.clear(); }; const draw = ( @@ -117,7 +175,14 @@ export async function terminal( ); component.markExecutionStarted(); component.setArgsComplete(); - card = { block: new Container(), component, output, expanded: false }; + card = { + block: new Container(), + component, + output, + expanded: false, + toolName, + args, + }; cards.set(id, card); transcript.addChild(card.block); } @@ -125,15 +190,99 @@ export async function terminal( card.close = undefined; card.output = output; card.block.clear(); - card.block.addChild(card.component); const result = resultSchema.safeParse(output); + const details = renderDetails.safeParse( + result.success ? result.data.details : undefined, + ); + const named = z + .object({ tool: z.string() }) + .safeParse(result.success ? result.data.details : undefined); + const title = + (details.success && details.data.title) || + (named.success && named.data.tool) || + card.toolName; + const builtin = ["bash", "read", "edit", "write"].includes(card.toolName); + if (builtin || card.expanded) card.block.addChild(card.component); + else { + card.block.addChild(new Spacer(1)); + card.block.addChild( + new Text( + accent(" ↳ ") + + clean(title) + + muted(isError ? " · failed" : partial ? " · running" : " · done"), + 0, + 0, + ), + ); + } if (!result.success) { card.block.addChild(new Text("Running…", 0, 0)); return; } - const details = renderDetails.safeParse(result.data.details); card.component.updateResult({ ...result.data, isError }, partial); card.component.setExpanded(card.expanded); + const images = result.data.content.filter((item) => item.type === "image"); + if ( + images.length && + !card.spatial && + (!card.images || + images.length !== card.images.images.length || + images.some((image, i) => image.data !== card.images?.images[i].data)) + ) { + card.images?.close(); + card.images = new ResultImages( + images.map((image, i) => ({ + ...image, + label: + (details.success && details.data.views?.[i].label) || + `Image ${i + 1}`, + })), + () => tui.requestRender(), + ); + lastImages = card.images; + } + if (!builtin && !card.expanded) { + const summary = details.success + ? details.data.summary.split(";")[0] + : result.data.content + .filter((item) => item.type === "text") + .map((item) => item.text) + .join("\n"); + card.block.addChild( + new Text(muted(" " + clean(summary.slice(0, 220))), 0, 0), + ); + if (card.spatial) card.block.addChild(card.spatial); + else if (card.images) card.block.addChild(card.images); + } + const pointArgs = z + .object({ kind: z.literal("points") }) + .safeParse(card.args); + if ( + details.success && + pointArgs.success && + !partial && + !isError && + !card.spatial && + !card.loading + ) { + card.loading = true; + const held = card; + void readCloud(details.data.source, details.data.sha256) + .then(({ cloud, sha256 }) => { + if (stopped || cards.get(id) !== held) return; + held.spatial = new SpatialView( + cloud, + details.data.source, + sha256, + () => tui.requestRender(), + ); + lastSpatial = held.spatial; + draw(id, held.output, held.toolName, held.args); + }) + .catch((error) => { + if (!stopped && cards.get(id) === held) notice(error); + }); + } if (details.success && details.data.live && !card.close) { const live = details.data.live, held = card; @@ -280,7 +429,10 @@ export async function terminal( ); else if (event.type === "notice" || event.type === "turn_error") notice(event.message); - else if (event.type === "idle") status.setText(ready()); + else if (event.type === "idle") { + current.busy = false; + status.setText(ready()); + } tui.requestRender(); }; client.onEvent = (seq, event, sessionId) => { @@ -291,13 +443,19 @@ export async function terminal( closeCards(); transcript.clear(); current = snapshot; - add("\x1b[1;36m dimcode\x1b[0m · Dimensional agent"); - add("\x1b[2m " + current.cwd + "\x1b[0m"); + input.setWorkspace(current.cwd); if (!current.messages.length) { transcript.addChild(new Spacer(1)); - add(" Build apps. Run blueprints. Explore sensor memory."); + add(accent(" ⠿ Build apps. Run blueprints. Explore sensor memory.")); + add( + muted( + " Ask about this workspace, or show available DimOS blueprints.", + ), + ); add( - "\x1b[2m Try: inspect this workspace, or show available DimOS blueprints.\x1b[0m", + muted( + " Memory results appear inside tool cards · /panel selects a view", + ), ); transcript.addChild(new Spacer(1)); } @@ -367,19 +525,36 @@ export async function terminal( : { type: "new_session", cwd: options.cwd }, ), ); - tui.addChild(transcript); - tui.addChild(new Spacer(1)); - tui.addChild(status); - tui.addChild(input); - tui.addChild( - new Text( - "\x1b[2m /help commands · /models choose model · Esc cancel · Ctrl-C detach\x1b[0m", - 0, - 0, - ), - ); + layout = new VStack([ + new Spacer(1), + new StatusLine(() => [ + accent("⠿ dimcode") + " " + clean(basename(current.cwd)), + muted(current.writable ? "GATEWAY ATTACHED" : "VIEW ONLY"), + ]), + new StatusLine(() => [muted(clean(current.cwd)), ""]), + new Spacer(1), + { + component: new ScrollView(transcript, { + follow: "end", + primary: true, + }), + basis: 0, + grow: 1, + minSize: 1, + }, + status, + input, + new StatusLine(() => [ + current.model + ? clean(current.model.provider + " / " + current.model.id) + : muted("/models choose model"), + muted("Shift+Enter newline · Ctrl-C detach"), + ]), + ]); + tui.setLayoutRoot(layout); tui.setFocus(input); input.onSubmit = (value) => { + if (!auth && value.trim()) input.addToHistory(value); input.setValue(""); if (auth) { const promptId = auth.promptId; @@ -396,8 +571,10 @@ export async function terminal( return; } if (value === "/help") { + add(commands.map((command) => "/" + command).join(" · ")); + add("/resume ID · /model PROVIDER MODEL · /login PROVIDER [oauth]"); add( - "/new · /sessions · /resume ID · /model PROVIDER MODEL · /models · /login PROVIDER [oauth] · /logout PROVIDER · /abort · /steer TEXT · /follow TEXT · /reload · /image PATH · /expand · /exit", + "/panel N: result view (0 overview) · /inspect PATH: saved XYZ · /view: rotate/zoom · /expand: latest tool details", ); return; } @@ -432,12 +609,44 @@ export async function terminal( return; } if (value === "/expand") { - for (const card of cards.values()) { - card.expanded = !card.expanded; - card.component.setExpanded(card.expanded); + const card = [...cards.entries()].at(-1); + if (card) { + const [id, tool] = card; + tool.expanded = !tool.expanded; + draw(id, tool.output, tool.toolName, tool.args); } return; } + if (value === "/view") { + inspect(); + return; + } + if (value.startsWith("/panel ")) { + const index = Number(value.slice(7).trim()); + if (!lastImages) + throw new Error( + "No image result yet. Ask the agent to display the memory query's existing exports.", + ); + if ( + !Number.isInteger(index) || + index < 0 || + index > lastImages.images.length + ) + throw new Error(`Choose /panel 0–${lastImages.images.length}.`); + lastImages.select(index); + return; + } + if (value.startsWith("/inspect ")) { + const source = resolve(current.cwd, value.slice(9).trim()); + const { cloud, sha256 } = await readCloud(source); + const view = new SpatialView(cloud, source, sha256, () => + tui.requestRender(), + ); + lastSpatial = view; + localViews.add(view); + transcript.addChild(view); + return; + } if (value === "/abort") { await client.call({ type: "abort" }); return; @@ -470,6 +679,7 @@ export async function terminal( } if (command === "/model") { await client.call({ type: "set_model", provider, modelId: option }); + current.model = { provider, id: option }; return; } if (command === "/steer" || command === "/follow") { @@ -500,6 +710,7 @@ export async function terminal( return; } if (value.trim()) { + current.busy = true; status.setText(" Running · Esc to cancel"); await client.call({ type: "prompt", message: value }); } @@ -517,6 +728,7 @@ export async function terminal( } else void client.call({ type: "abort" }).catch(notice); }; tui.addInputListener((data) => { + if (inspectorOpen && data !== "\x03") return undefined; if (data === "\x03") { stop(); return { consume: true }; diff --git a/agent/src/tool-images.ts b/agent/src/tool-images.ts new file mode 100644 index 00000000000..4b9b2e58310 --- /dev/null +++ b/agent/src/tool-images.ts @@ -0,0 +1,162 @@ +import { + Image, + Text, + truncateToWidth, + visibleWidth, + type Component, + type TuiMouseEvent, +} from "@earendil-works/pi-tui"; +import sharp from "sharp"; +import { accent, clean, muted } from "./terminal-style.js"; + +export interface ResultImage { + data: string; + mimeType: string; + label: string; +} + +/** Presentation only: preserves the images produced by the owning memory operation. */ +export class ResultImages implements Component { + private selected = 0; + private panels: Image[] = []; + private overview?: Image; + private disposed = false; + private error?: string; + private tabs: Array<{ start: number; end: number; index: number }> = []; + constructor( + readonly images: ResultImage[], + private readonly changed: () => void, + ) { + void this.prepare().catch((error) => { + if (!this.disposed) { + this.error = clean(String(error)); + this.changed(); + } + }); + } + close(): void { + this.disposed = true; + } + invalidate(): void { + this.overview?.invalidate(); + for (const panel of this.panels) panel.invalidate(); + } + select(index: number): void { + this.selected = Math.max(0, Math.min(this.images.length, index)); + this.changed(); + } + private component(png: Buffer): Image { + return new Image( + png.toString("base64"), + "image/png", + { fallbackColor: muted }, + { maxWidthCells: 120, maxHeightCells: 26 }, + ); + } + private async prepare(): Promise { + const bytes = await Promise.all( + this.images.map(async (image) => + sharp(Buffer.from(image.data, "base64"), { density: 144 }) + .resize({ + width: 1600, + height: 1200, + fit: "inside", + withoutEnlargement: true, + }) + .flatten({ background: "#101b21" }) + .png() + .toBuffer(), + ), + ); + if (this.disposed) return; + this.panels = bytes.map((png) => this.component(png)); + if (bytes.length === 1) this.overview = this.panels[0]; + else { + const shown = bytes.slice(0, 6); + const columns = 2; + const top = shown.length >= 3; + const height = + (top ? 230 : 0) + + Math.ceil((shown.length - Number(top)) / columns) * 270; + const tiles = await Promise.all( + shown.map(async (png, i) => { + const first = top && i === 0, + n = i - Number(top); + const width = first ? 1000 : 500; + const imageHeight = first ? 220 : 260; + const tile = await sharp(png) + .resize(width - 12, imageHeight - 12, { + fit: "contain", + background: "#101b21", + }) + .png() + .toBuffer(); + return { + input: tile, + left: first ? 6 : (n % columns) * 500 + 6, + top: first + ? 6 + : (top ? 230 : 0) + Math.floor(n / columns) * 270 + 6, + }; + }), + ); + const png = await sharp({ + create: { width: 1000, height, channels: 3, background: "#101b21" }, + }) + .composite(tiles) + .png() + .toBuffer(); + if (this.disposed) return; + this.overview = this.component(png); + } + this.changed(); + } + handleMouse( + event: TuiMouseEvent, + ): { handled: boolean; render: boolean } | undefined { + if (event.type !== "press" || event.y !== 0) return; + const tab = this.tabs.find( + (tab) => event.x >= tab.start && event.x < tab.end, + ); + if (!tab) return; + this.select(tab.index); + return { handled: true, render: true }; + } + render(width: number): string[] { + const labels = ["Overview", ...this.images.map((image) => image.label)]; + let line = " "; + this.tabs = []; + for (const [index, label] of labels.entries()) { + const text = `${index} ${clean(label).slice(0, 16)} `; + const start = visibleWidth(line), + end = start + visibleWidth(text); + if (end > width) break; + line += (index === this.selected ? accent : muted)(text); + this.tabs.push({ start, end, index }); + } + const panel = this.selected + ? this.panels[this.selected - 1] + : this.overview; + return [ + truncateToWidth(line, width), + ...(panel + ? panel.render(width) + : new Text( + muted( + this.error + ? ` Preview unavailable: ${this.error}` + : " Preparing result views…", + ), + 0, + 0, + ).render(width)), + ...new Text( + muted( + ` /panel 0–${this.images.length} selects a view · original tool output${this.images.length > 6 && !this.selected ? " · overview shows first 6" : ""}`, + ), + 0, + 0, + ).render(width), + ]; + } +} diff --git a/agent/test/fixtures/memory_views.py b/agent/test/fixtures/memory_views.py new file mode 100644 index 00000000000..8f41f1f0b84 --- /dev/null +++ b/agent/test/fixtures/memory_views.py @@ -0,0 +1,79 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Recorded Go2 memory query; all analysis and exports use existing DimOS APIs.""" + +import json +import os +from pathlib import Path +import sys + +from dimos.memory.store.sqlite import SqliteStore +from dimos.memory.transform import peaks, significant +from dimos.memory.vis.plot.elements import Series, VLine +from dimos.memory.vis.plot.plot import Plot +from dimos.memory.vis.space.space import Space +from dimos.memory.vis.utils import mosaic +from dimos.models.embedding.clip import CLIPModel + + +def main() -> None: + out = Path(sys.argv[1]) + out.mkdir(parents=True, exist_ok=True) + store = SqliteStore(path=os.environ["DIMCODE_TEST_GO2_DB"], must_exist=True) + try: + vector = CLIPModel(device="cpu").embed_text("plant") + query = store.streams.color_image_embedded.search(vector).order_by("ts").materialize() + rows = list(query) + selected = query.transform(peaks(key=lambda obs: obs.similarity, distance=1.0)) + selected = selected.transform(significant(method="mad")).materialize() + matches = list(selected) + assert len(rows) > 10 and matches, "Recording must contain scored images and peaks" + plot = Plot().add( + Series( + ts=[row.ts for row in rows], + values=[row.similarity for row in rows], + color="#43bda7", + label="plant similarity", + connect=7.5, + ) + ) + for row in matches: + plot.add(VLine(row.ts, color="#e87c59")) + plot.to_svg(str(out / "timeline.svg")) + # The context cloud and selected image poses both come from this recording. + context = [store.streams.lidar.at(row.ts).last() for row in matches] + assert all(row is not None for row in context) + Space().add([row.data for row in context]).add(selected).to_svg(str(out / "space.svg")) + mosaic(selected, cols=min(3, len(matches)), cell_height=300).data.save( + str(out / "frames.png") + ) + report = { + "query": "plant", + "source": os.environ["DIMCODE_TEST_GO2_DB"], + "operation": "search(CLIP plant).order_by(ts).materialize() → peaks(distance=1) → significant(mad)", + "observations": len(rows), + "context_cloud_timestamps": [row.ts for row in context], + "selected": [ + {"id": row.id, "timestamp": row.ts, "similarity": row.similarity} for row in matches + ], + } + (out / "query.json").write_text(json.dumps(report, indent=2) + "\n") + print(json.dumps(report)) + finally: + store.stop() + + +if __name__ == "__main__": + main() diff --git a/agent/test/memory.test.ts b/agent/test/memory.test.ts new file mode 100644 index 00000000000..a9a5fbfd1fd --- /dev/null +++ b/agent/test/memory.test.ts @@ -0,0 +1,146 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { test } from "node:test"; +import { promisify } from "node:util"; +import { setCapabilities, visibleWidth } from "@earendil-works/pi-tui"; +import sharp from "sharp"; +import { z } from "zod"; +import { paths } from "../src/config.js"; +import { renderFiles } from "../src/render.js"; +import { ResultImages } from "../src/tool-images.js"; + +test("memory tool keeps all native exports, provenance and selectable panels", async (t) => { + const dir = await mkdtemp(join(tmpdir(), "dimcode-memory-")); + t.after(() => rm(dir, { recursive: true, force: true })); + const p = paths({ XDG_CACHE_HOME: dir }); + const views = [ + { label: "Timeline", path: "plot_plantness_marked.svg" }, + { label: "Spatial", path: "embedding_focused.svg" }, + { label: "Frames", path: "grid.png" }, + ]; + const cwd = resolve("../docs/capabilities/memory/assets"); + const result = await renderFiles( + p, + cwd, + views, + "Memory · documentation fixture", + ); + const images = result.content.filter((item) => item.type === "image"); + assert.equal(images.length, 3, "every view reaches model context"); + assert.deepEqual( + result.details.views?.map((view) => view.label), + views.map((view) => view.label), + ); + for (const [i, view] of views.entries()) { + const source = resolve(cwd, view.path); + const bytes = await readFile(source); + assert.equal( + result.details.views?.[i].sha256, + createHash("sha256").update(bytes).digest("hex"), + ); + const metadata = await sharp( + Buffer.from(images[i].data, "base64"), + ).metadata(); + assert.equal(metadata.format, "png"); + assert(metadata.width > 100 && metadata.width <= 1600); + assert( + result.content.some( + (item) => item.type === "text" && item.text.includes(source), + ), + ); + } + setCapabilities({ images: null, trueColor: true, hyperlinks: false }); + let updated!: () => void; + const ready = new Promise((resolve) => { + updated = resolve; + }); + const gallery = new ResultImages( + images.map((image, i) => ({ ...image, label: views[i].label })), + updated, + ); + t.after(() => gallery.close()); + await ready; + const overview = gallery.render(100).join("\n"); + assert.match(overview, /Overview/); + assert.match(overview, /3 Frames/); + assert.doesNotMatch(overview, /Preparing|unavailable/); + gallery.select(3); + assert.notEqual(gallery.render(100).join("\n"), overview); + assert( + gallery.handleMouse({ + type: "press", + button: "left", + x: 2, + y: 0, + screenX: 2, + screenY: 0, + width: 100, + height: 30, + shift: false, + alt: false, + ctrl: false, + })?.handled, + ); + assert.equal(gallery.render(100).join("\n"), overview); + for (const width of [20, 40, 100]) + assert(gallery.render(width).every((line) => visibleWidth(line) <= width)); + await assert.rejects(renderFiles(p, cwd, []), /Choose/); + await assert.rejects( + renderFiles(p, cwd, [{ label: "Missing", path: "missing.png" }]), + /ENOENT/, + ); +}); + +test( + "recorded Go2 memory query exports its timeline, spatial selection and matching frames", + { + timeout: 90000, + skip: !(process.env.DIMCODE_TEST_PYTHON && process.env.DIMCODE_TEST_GO2_DB), + }, + async (t) => { + const dir = await mkdtemp(join(tmpdir(), "dimcode-memory-go2-")); + t.after(() => rm(dir, { recursive: true, force: true })); + await promisify(execFile)( + process.env.DIMCODE_TEST_PYTHON!, + ["test/fixtures/memory_views.py", dir], + { + env: { ...process.env, PYTHONPATH: resolve("..") }, + timeout: 80000, + }, + ); + const report = z + .object({ + observations: z.number().gt(10), + selected: z + .array( + z.object({ + timestamp: z.number().positive(), + similarity: z.number().finite(), + }), + ) + .min(1), + context_cloud_timestamps: z.array(z.number().positive()).min(1), + }) + .parse(JSON.parse(await readFile(join(dir, "query.json"), "utf8"))); + assert(report.selected.every((row) => row.similarity > 0)); + const result = await renderFiles( + paths({ XDG_CACHE_HOME: dir }), + dir, + [ + { label: "Timeline", path: "timeline.svg" }, + { label: "Spatial", path: "space.svg" }, + { label: "Frames", path: "frames.png" }, + ], + "Memory · plant", + ); + assert.equal( + result.content.filter((item) => item.type === "image").length, + 3, + ); + assert.equal(result.details.views?.length, 3); + }, +); diff --git a/agent/test/terminal.test.ts b/agent/test/terminal.test.ts new file mode 100644 index 00000000000..78628776088 --- /dev/null +++ b/agent/test/terminal.test.ts @@ -0,0 +1,76 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import { setTimeout as delay } from "node:timers/promises"; +import { + ProcessTerminal, + TuiMainScreen, + setCapabilities, + visibleWidth, +} from "@earendil-works/pi-tui"; +import { initTheme } from "@earendil-works/pi-coding-agent"; +import { PromptEditor } from "../src/input.js"; +import { readCloud, SpatialView } from "../src/spatial.js"; + +test("Pi editor preserves multiline submissions and never puts credentials into chat history", () => { + initTheme("dark", false); + const editor = new PromptEditor(new TuiMainScreen(new ProcessTerminal())); + let submitted = ""; + editor.onSubmit = (value) => { + submitted = value; + }; + editor.handleInput("\x1b[200~line one\nline two\x1b[201~"); + editor.handleInput("\r"); + assert.equal(submitted, "line one\nline two"); + editor.setValue(""); + editor.secret = true; + editor.setValue(""); + editor.handleInput("private-fixture"); + assert(!editor.render(50).join("").includes("private-fixture")); + editor.secret = false; + editor.setValue(""); + for (const key of ["\x1f", "\x19", "\x1b[A"]) editor.handleInput(key); + assert(!editor.getValue().includes("private-fixture")); +}); + +test("spatial inspector rotates exact data, preserves source selections and rejects changed provenance", async (t) => { + const home = await mkdtemp(join(tmpdir(), "dimcode-spatial-")); + t.after(() => rm(home, { recursive: true, force: true })); + const source = join(home, "cloud.json"); + const data = { + points: [ + [1, 4, -0.1], + [2, 6, 0.4], + [-1, 8, 0.6], + ], + frame: "map", + timestamp: 123.45, + selectedIndices: [1], + }; + await writeFile(source, JSON.stringify(data)); + const { cloud, sha256 } = await readCloud(source); + setCapabilities({ images: null, trueColor: true, hyperlinks: false }); + let renders = 0; + const view = new SpatialView(cloud, source, sha256, () => renders++); + t.after(() => view.close()); + const before = view.render(80).join("\n"); + assert.match(before, /1 source-selected points/); + const start = renders; + view.handleInput("r"); + const deadline = Date.now() + 2000; + while (renders === start && Date.now() < deadline) await delay(20); + assert(renders > start, "rotation redraws the observation"); + assert.notEqual(view.render(80).join("\n"), before); + for (const width of [25, 40, 100]) + assert(view.render(width).every((line) => visibleWidth(line) <= width)); + assert.deepEqual(cloud, data); + assert.equal(await readFile(source, "utf8"), JSON.stringify(data)); + await writeFile(source, JSON.stringify({ ...data, timestamp: 124 })); + await assert.rejects(readCloud(source, sha256), /Source changed/); + view.close(); + const closed = renders; + await delay(80); + assert.equal(renders, closed, "no redraws after disposal"); +}); From a3e283496983ab22fade900635518adbc56ba86f Mon Sep 17 00:00:00 2001 From: stash Date: Sat, 12 Sep 2026 03:57:24 -0700 Subject: [PATCH 4/9] test(dimcode): keep renderer fixtures independent of Git LFS --- agent/test/memory.test.ts | 31 ++++++++++++++++++++----------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/agent/test/memory.test.ts b/agent/test/memory.test.ts index a9a5fbfd1fd..5a3b8d0c0c7 100644 --- a/agent/test/memory.test.ts +++ b/agent/test/memory.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import { execFile } from "node:child_process"; import { createHash } from "node:crypto"; -import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { test } from "node:test"; @@ -13,22 +13,31 @@ import { paths } from "../src/config.js"; import { renderFiles } from "../src/render.js"; import { ResultImages } from "../src/tool-images.js"; -test("memory tool keeps all native exports, provenance and selectable panels", async (t) => { +test("memory tool keeps all SVG/PNG exports, provenance and selectable panels", async (t) => { const dir = await mkdtemp(join(tmpdir(), "dimcode-memory-")); t.after(() => rm(dir, { recursive: true, force: true })); const p = paths({ XDG_CACHE_HOME: dir }); const views = [ - { label: "Timeline", path: "plot_plantness_marked.svg" }, - { label: "Spatial", path: "embedding_focused.svg" }, + { label: "Timeline", path: "timeline.svg" }, + { label: "Spatial", path: "space.svg" }, { label: "Frames", path: "grid.png" }, ]; - const cwd = resolve("../docs/capabilities/memory/assets"); - const result = await renderFiles( - p, - cwd, - views, - "Memory · documentation fixture", - ); + // Unit fixtures stay independent of the documentation's Git LFS assets. + const cwd = dir; + for (const [name, shape] of [ + ["timeline.svg", ''], + ["space.svg", ''], + ]) + await writeFile( + join(dir, name), + `${shape}`, + ); + await sharp({ + create: { width: 300, height: 200, channels: 3, background: "#8b7c35" }, + }) + .png() + .toFile(join(dir, "grid.png")); + const result = await renderFiles(p, cwd, views, "Memory · format fixture"); const images = result.content.filter((item) => item.type === "image"); assert.equal(images.length, 3, "every view reaches model context"); assert.deepEqual( From 4ae12fb5502947eb6f4dbac4c14da016cb5c75c1 Mon Sep 17 00:00:00 2001 From: stash Date: Sat, 12 Sep 2026 04:14:01 -0700 Subject: [PATCH 5/9] docs(dimcode): define terminal styling and SVG fallback --- agent/README.md | 4 ++++ agent/skills/dimensional/SKILL.md | 4 ++++ agent/src/session.ts | 1 + 3 files changed, 9 insertions(+) diff --git a/agent/README.md b/agent/README.md index a7d1c95b1c7..8ce5303a3da 100644 --- a/agent/README.md +++ b/agent/README.md @@ -94,6 +94,8 @@ Pi owns context loading, skills, compaction, models and coding-tool behavior. Wo ## Tool rendering +DimOS owns reusable visualizations and encodings for agent understanding: geometry, axes, units, timestamps, legends and selections. Dimcode owns terminal styling and interaction: cards, themes, camera/point presentation, resizing, controls and graphics/Braille adaptation. Terminal styling preserves the meaning of colors and labels in supplied visuals. + DimOS memory owns video/frame/point-cloud analysis. Evaluate the memory operation once with `materialize()`, then use its existing `Space.to_svg()` / `Plot.to_svg()` and selected-frame exports. Pass those exports together to `dimcode_render`: ```json @@ -104,6 +106,8 @@ The tool rasterizes the original SVG/PNG views for terminal display and model co `dimcode_render` also accepts single `image` paths and saved `points` / `series` JSON. Point clouds use XYZ rows; series use timestamp/value pairs. Selection metadata and display decimation remain explicit. See the bundled Dimensional skill for formats. +The agent is instructed to visualize each meaningful sensor/memory operation. When no supported type or existing visualizer applies, it generates a self-contained SVG with inline Python from the evaluated result and displays it through `dimcode_render({kind:"image",path:"result.svg"})`. This supports arbitrary plots, images, simple graphs and labeled proposed overlays. The same rendered image is returned to the model for visual inspection. Rendering failures remain explicit. + Live tools select an existing relay/robot/channel. The terminal receives frames directly through the Web SDK and coalesces drawing to 10 Hz. The gateway retains one final snapshot for model context. Closing/cancelling the tool releases consumers; the last consumer closes the connection. MediaPool is generic over decoded SDK slots and accepts existing decoder registries. The initial live image renderer handles JPEG; other channel types use their owning decoder/renderer or saved exports. Graphics use Pi terminal-image support with text fallback. Derived PNGs have a bounded 128 MiB cache; original DimOS recordings stay with DimOS. No recording, raw continuous video, new transport protocol or new DimOS gateway is introduced. diff --git a/agent/skills/dimensional/SKILL.md b/agent/skills/dimensional/SKILL.md index c7110b5e256..60bfa89d6be 100644 --- a/agent/skills/dimensional/SKILL.md +++ b/agent/skills/dimensional/SKILL.md @@ -15,6 +15,10 @@ Use the selected DimOS executable/Python environment and workspace from the sess For memory, use existing stream operations. Evaluate a query once with materialize(), then derive scores, selected peaks, images and comparisons from that same cache. Preserve timestamps, observation IDs and coordinate frames in exports. The docs reconstruction example includes a skipped block/API TODO; verify the chosen callable operation on actual data. +Show a visualization for each meaningful sensor/memory operation. Prefer a supported type or an existing DimOS visualizer. If neither supports the result, use inline Python to generate a self-contained SVG from the evaluated data, save it, and call `dimcode_render({kind:"image",path:"result.svg"})`. This fallback also supports custom plots, simple graphs and proposed overlays; label proposals distinctly from observations. Use the returned image as visual feedback when interpreting or refining the result. If rendering fails, report that explicitly and retain the original result. + +DimOS owns reusable, agent-independent visualization semantics: geometry, axes, units, timestamps, legends, selections and generic encodings for agent understanding. Dimcode owns terminal-specific styling and interaction: card layout, theme, viewport/camera defaults, point appearance, playback controls, resizing and graphics/Braille adaptation. Terminal styling must preserve semantic colors and labels supplied by DimOS or a custom SVG. Keep terminal-specific presets out of DimOS. + All analysis of point-cloud frames and video belongs in DimOS memory. Prefer its existing `Space.to_svg()` and `Plot.to_svg()` renderers, alongside selected-frame exports from the same materialized query. Display the views together with `dimcode_render({kind:"image", title:"Memory · QUERY", views:[{label:"Timeline",path:"timeline.svg"},{label:"Spatial",path:"space.svg"},{label:"Frames",path:"frames.png"}]})`. Supply only views actually produced by that operation. Preserve the native colors, timestamps, peaks, coordinate frames and selection metadata; don't reconstruct them in the harness. The terminal displays an overview and individually selectable panels, and the model receives every image. MCP tools returning multiple `agent_encode` images are displayed together automatically; a skill returning only a pose does not provide these views. Single saved images and JSON exports remain supported: points use existing point-cloud extraction as XYZ rows with optional `selectedIndices`; series use numeric [timestamp,value] rows from the evaluated operation. Retain original exports and metadata. The renderer labels display projection/decimation and hashes every source. It must never repeat a memory query to obtain a prettier display. diff --git a/agent/src/session.ts b/agent/src/session.ts index 89ffbe25d32..778b441491c 100644 --- a/agent/src/session.ts +++ b/agent/src/session.ts @@ -57,6 +57,7 @@ export async function openSession( ], appendSystemPrompt: [ "You are dimcode, Dimensional's coding and robotics agent. Use DimOS CLI/public Python APIs and the advertised MCP skills. Do not create a parallel runtime or transport service. Select the intended run and endpoint explicitly; never assume the latest run is the intended one. Render actual saved results with dimcode_render. Live previews are labeled context, not historical query results.", + "Visualize each meaningful sensor or memory operation with dimcode_render. Prefer supported types and existing DimOS visualizers; otherwise generate a self-contained SVG with inline Python from the already evaluated result and render that file. Label proposed ideas or overlays distinctly from observations. Use returned images as visual feedback when interpreting results. DimOS owns reusable visualization semantics; dimcode owns terminal-specific styling and interaction.", "Selected configuration: " + JSON.stringify(config), ], }, From 92743ad5d54ef6e2d2b8ab8d62770689581e269f Mon Sep 17 00:00:00 2001 From: stash Date: Sat, 12 Sep 2026 04:40:15 -0700 Subject: [PATCH 6/9] feat(dimcode): render saved sensor sequences in terminal tool cards --- agent/README.md | 41 +++- agent/skills/dimensional/SKILL.md | 6 +- agent/src/artifacts.ts | 56 ++++++ agent/src/clips.ts | 240 ++++++++++++++++++++++ agent/src/input.ts | 3 + agent/src/playback.ts | 181 +++++++++++++++++ agent/src/points.ts | 133 +++++++++++++ agent/src/render.ts | 320 ++++++++++++------------------ agent/src/session.ts | 2 +- agent/src/spatial.ts | 135 +++---------- agent/src/terminal.ts | 97 +++++++-- agent/test/clips.test.ts | 291 +++++++++++++++++++++++++++ agent/test/fixtures/clip_views.py | 83 ++++++++ agent/test/render.test.ts | 33 +-- 14 files changed, 1273 insertions(+), 348 deletions(-) create mode 100644 agent/src/artifacts.ts create mode 100644 agent/src/clips.ts create mode 100644 agent/src/playback.ts create mode 100644 agent/src/points.ts create mode 100644 agent/test/clips.test.ts create mode 100644 agent/test/fixtures/clip_views.py diff --git a/agent/README.md b/agent/README.md index 8ce5303a3da..7526968038c 100644 --- a/agent/README.md +++ b/agent/README.md @@ -88,7 +88,7 @@ dimcode run "inspect the app and explain its blueprint" Endpoints are explicit; ports are examples, not instance identities. Use `/reload` after changing endpoints. Every advertised skill is registered with its original schema, metadata and remote routing. Tool names are bounded and collision-resistant. There is no static copy of the robot's tools or a parallel lifecycle service. -Terminal commands: `/new`, `/sessions`, `/resume ID`, `/models`, `/model PROVIDER MODEL`, `/login PROVIDER [oauth]`, `/logout PROVIDER`, `/abort`, `/steer TEXT`, `/follow TEXT`, `/reload`, `/image PATH`, `/panel N`, `/inspect PATH`, `/view`, `/expand`, `/exit`. Ctrl-C detaches. One terminal owns input; other viewers may observe. Detaching keeps the turn running. Restart restores Pi history and never automatically replays external actions. Very large histories show a bounded recent transcript with an omission notice; the complete agent history remains on disk. Session events carry their source identity so switching sessions cannot mix transcripts. +Terminal commands: `/new`, `/sessions`, `/resume ID`, `/models`, `/model PROVIDER MODEL`, `/login PROVIDER [oauth]`, `/logout PROVIDER`, `/abort`, `/steer TEXT`, `/follow TEXT`, `/reload`, `/image PATH`, `/panel N`, `/inspect PATH`, `/play`, `/pause`, `/seek SECONDS`, `/view`, `/expand`, `/exit`. Ctrl-C detaches. One terminal owns input; other viewers may observe. Detaching keeps the turn running. Restart restores Pi history and never automatically replays external actions. Very large histories show a bounded recent transcript with an omission notice; the complete agent history remains on disk. Session events carry their source identity so switching sessions cannot mix transcripts. Pi owns context loading, skills, compaction, models and coding-tool behavior. Workspace instructions and configured Pi extensions load normally. Attached terminals support serialized dialogs/notifications; executable extension UI factories belong in the terminal renderer and cannot be sent through a socket. @@ -104,13 +104,44 @@ DimOS memory owns video/frame/point-cloud analysis. Evaluate the memory operatio The tool rasterizes the original SVG/PNG views for terminal display and model context. Every view reaches the model; the terminal presents a selectable overview. Each export retains its source path and SHA-256. MCP tool results containing multiple images also appear together automatically. No query or filter runs inside the renderer. Existing skills that return only a pose or JSON still require an explicit export; the harness does not invent missing views. -`dimcode_render` also accepts single `image` paths and saved `points` / `series` JSON. Point clouds use XYZ rows; series use timestamp/value pairs. Selection metadata and display decimation remain explicit. See the bundled Dimensional skill for formats. +Built-in presentation stays constrained to **point clouds and images/SVGs**, with one player for finite sequences of either. `dimcode_render({path:"result.json"})` detects a cloud or frame index; other image paths are rasterized. Numeric plots and graphs use DimOS/Python SVG exports, not a harness plotting engine. Cloud JSON may also supply `colors: [[r,g,b]]` (0–255) and `selectedIndices`; source RGB is preserved and explicit selections are highlighted. + +For a memory/replay interval, materialize the selection in DimOS and export an index alongside the existing frames: + +```json +{ + "type": "points", + "timeOrigin": 1766747348.2995782, + "source": "Go2 memory · lidar · seconds 1–5", + "frames": [ + {"path":"cloud-0.json","timestamp":1766747349.3433642}, + {"path":"cloud-1.json","timestamp":1766747349.4723642} + ] +} +``` + +Use `type:"image"` for PNG/JPEG frames or any custom SVG animation. Paths are relative to the index, timestamps are seconds and must increase; optional per-frame `sha256` checks the original exports. Use one recording origin for synchronized camera/cloud windows. This file indexes already exported results; it is not a new query or transport API. + +Call `dimcode_render({path:"clip.json",title:"Memory · seconds 1–5"})`, or use these commands **inside dimcode** without a model call: + +```text +/inspect /absolute/path/to/clip.json +/play +/pause +/seek 3 +``` + +The tool card plays once automatically; click Play to replay or click the timeline to scrub. Commands control the latest clip. `/seek` uses seconds relative to `timeOrigin`. Restored sessions begin paused. Leaving the session or closing the terminal stops playback. Point-cloud clips share one camera, bounds and height scale; mismatched coordinate frames must be aligned in DimOS first. + +The terminal plays timestamped PNGs. A looping GIF is exported for sharing (GIF timing rounds to centiseconds); the model receives a contact sheet of up to six labeled frames. `dimcode_render({path:"clip.json",frame:17})` returns original zero-based frame 17 for closer inspection, even if preview sampling omitted it. The renderer reads saved files only and never repeats the source query. + +Previews are bounded to 60 seconds, at most 120 frames and approximately 10 Hz, retaining first/last timestamps and original indexes. Source-frame gaps remain visible in playback. Per-file reads are limited to 32 MiB, a selected clip to 128 MiB/two million points, generated PNGs to 64 MiB and GIFs to 32 MiB. If a limit is reached, export a smaller preview with DimOS. Individual clouds label point display sampling. Original recordings and exports remain unchanged. The agent is instructed to visualize each meaningful sensor/memory operation. When no supported type or existing visualizer applies, it generates a self-contained SVG with inline Python from the evaluated result and displays it through `dimcode_render({kind:"image",path:"result.svg"})`. This supports arbitrary plots, images, simple graphs and labeled proposed overlays. The same rendered image is returned to the model for visual inspection. Rendering failures remain explicit. Live tools select an existing relay/robot/channel. The terminal receives frames directly through the Web SDK and coalesces drawing to 10 Hz. The gateway retains one final snapshot for model context. Closing/cancelling the tool releases consumers; the last consumer closes the connection. MediaPool is generic over decoded SDK slots and accepts existing decoder registries. The initial live image renderer handles JPEG; other channel types use their owning decoder/renderer or saved exports. -Graphics use Pi terminal-image support with text fallback. Derived PNGs have a bounded 128 MiB cache; original DimOS recordings stay with DimOS. No recording, raw continuous video, new transport protocol or new DimOS gateway is introduced. +Graphics use Pi terminal-image support with text fallback. Derived PNGs/GIFs have a bounded 128 MiB cache; evicted previews show an explicit unavailable state and can be regenerated from retained exports; original DimOS recordings stay with DimOS. No recording, raw continuous video, new transport protocol or new DimOS gateway is introduced. ## Contributor development and validation @@ -141,6 +172,8 @@ The Go2 E2E test starts the standard `unitree-go2` blueprint in **recorded-data - One shared SDK connection, lazy subscriptions, continued cloud reception after video closes, then zero viewers/subscriptions after the last renderer closes. - Cleanup of the blueprint and relay, including failure paths. +A separate memory test materializes seconds 1–5 once, exports full cloud/camera frames and a native DimOS plot, then checks GIF frame counts, shared time origin and every preview frame’s source hash. Playback unit tests cover seeking, completion, cancellation, stale files, RGB preservation and disposal. + Set `DIMCODE_TEST_REPORT=/absolute/path/report.json` to save measured counts. Without `DIMCODE_TEST_GO2_DB`, the large recording test is explicitly skipped. The Python and Deno variables separately enable the MCP-handler and QUIC relay tests. Unit/CLI tests also cover onboarding, private credentials, cancellation, `tui`, existing-gateway handling, session ownership, detach/recovery and renderer provenance. -A video stream here means consecutive JPEG frames over WebTransport. This does not claim an H.264/WebCodecs decoder, browser UI coverage, or a physical robot test. The harness's initial live terminal renderer displays JPEG; saved point clouds and series render from exact exported results. Other live types can plug into the generic SDK codec registry. +A video stream here means consecutive JPEG frames over WebTransport. This does not claim an H.264/WebCodecs decoder, browser UI coverage, or a physical robot test. The harness's initial live terminal renderer displays JPEG; saved point clouds, timed sequences and SVGs render from exported results. Other live types can plug into the generic SDK codec registry. diff --git a/agent/skills/dimensional/SKILL.md b/agent/skills/dimensional/SKILL.md index 60bfa89d6be..d3b3f0fbd13 100644 --- a/agent/skills/dimensional/SKILL.md +++ b/agent/skills/dimensional/SKILL.md @@ -21,7 +21,11 @@ DimOS owns reusable, agent-independent visualization semantics: geometry, axes, All analysis of point-cloud frames and video belongs in DimOS memory. Prefer its existing `Space.to_svg()` and `Plot.to_svg()` renderers, alongside selected-frame exports from the same materialized query. Display the views together with `dimcode_render({kind:"image", title:"Memory · QUERY", views:[{label:"Timeline",path:"timeline.svg"},{label:"Spatial",path:"space.svg"},{label:"Frames",path:"frames.png"}]})`. Supply only views actually produced by that operation. Preserve the native colors, timestamps, peaks, coordinate frames and selection metadata; don't reconstruct them in the harness. The terminal displays an overview and individually selectable panels, and the model receives every image. MCP tools returning multiple `agent_encode` images are displayed together automatically; a skill returning only a pose does not provide these views. -Single saved images and JSON exports remain supported: points use existing point-cloud extraction as XYZ rows with optional `selectedIndices`; series use numeric [timestamp,value] rows from the evaluated operation. Retain original exports and metadata. The renderer labels display projection/decimation and hashes every source. It must never repeat a memory query to obtain a prettier display. +Built-in renderers only present point clouds and images/SVGs. Export clouds through existing extraction as `{points:[[x,y,z]], colors?:[[r,g,b]], frame?, timestamp?, selectedIndices?}`; RGB is 0–255. Generate all other plots/graphs with DimOS or inline Python as SVG. Retain original exports and metadata. The renderer labels display sampling and hashes every source. + +For memory/replay sequences, materialize the selected interval once in DimOS. Export its cloud JSONs or image/SVG frames, then write a finite index `{type:"points"|"image",timeOrigin:RECORDING_ORIGIN,source:"operation description",frames:[{path:"relative/frame.json",timestamp:CAPTURE_SECONDS,sha256?:DIGEST}]}`. Timestamps must strictly increase. Use the same recording origin for related streams and preserve their coordinate frames. Call `dimcode_render({path:"index.json",title:"Memory · seconds 1–5"})`; no kind is needed. The terminal plays once with a fixed cloud camera and source timestamps, and exports a looping GIF. The model receives a timestamped contact sheet; inspect any original zero-based frame with `dimcode_render({path:"index.json",frame:17})` when the sheet is insufficient. Never claim to have inspected every frame from a contact sheet alone. + +The preview selects approximately 10 Hz, at most 120 frames across at most 60 seconds, and preserves original frame indexes. Larger results need a smaller export selected in DimOS, not a query inside the harness. Images, custom SVG animations and point clouds share this same player. GIF time is quantized to centiseconds; terminal scheduling uses source timestamp differences. Recordings stay with DimOS and only disposable previews enter the harness cache. For live rendering, explicitly choose the existing relay, robot and channel. Web SDK subscriptions open only for the tool/view's lifetime. Label the view live: it is not a historical selection, and relay header timestamps may be send time. Only a selected final snapshot belongs in model context. Non-image encodings need their existing domain decoder/renderer or an explicit saved export. diff --git a/agent/src/artifacts.ts b/agent/src/artifacts.ts new file mode 100644 index 00000000000..61f131c3c2c --- /dev/null +++ b/agent/src/artifacts.ts @@ -0,0 +1,56 @@ +import { createHash } from "node:crypto"; +import { + mkdir, + readFile, + readdir, + stat, + unlink, + writeFile, +} from "node:fs/promises"; +import { join } from "node:path"; +import type { Paths } from "./config.js"; + +export async function readArtifact( + path: string, + digest?: string, +): Promise<{ bytes: Buffer; sha256: string }> { + if ((await stat(path)).size > 32 * 1024 * 1024) + throw new Error("Export exceeds 32 MiB; select a smaller view in DimOS."); + const bytes = await readFile(path); + const sha256 = createHash("sha256").update(bytes).digest("hex"); + if (digest && digest !== sha256) + throw new Error( + "Source changed since the agent inspected it; retained preview remains available.", + ); + return { bytes, sha256 }; +} + +export async function cacheImage( + paths: Paths, + png: Buffer, + extension: "png" | "gif" = "png", +): Promise { + await mkdir(paths.cache, { recursive: true, mode: 0o700 }); + const path = join( + paths.cache, + createHash("sha256").update(png).digest("hex") + "." + extension, + ); + await writeFile(path, png, { mode: 0o600 }); + const entries = ( + await Promise.allSettled( + (await readdir(paths.cache)) + .filter((name) => /^[a-f0-9]{64}\.(png|gif)$/.test(name)) + .map(async (name) => ({ + name, + info: await stat(join(paths.cache, name)), + })), + ) + ).flatMap((result) => (result.status === "fulfilled" ? [result.value] : [])); + let bytes = 0; + for (const entry of entries.sort((a, b) => b.info.mtimeMs - a.info.mtimeMs)) { + bytes += entry.info.size; + if (bytes > 128 * 1024 * 1024 && entry.name !== path.split("/").pop()) + await unlink(join(paths.cache, entry.name)).catch(() => {}); + } + return path; +} diff --git a/agent/src/clips.ts b/agent/src/clips.ts new file mode 100644 index 00000000000..d9781bc48a9 --- /dev/null +++ b/agent/src/clips.ts @@ -0,0 +1,240 @@ +import { dirname, resolve } from "node:path"; +import sharp from "sharp"; +import { z } from "zod"; +import { cacheImage, readArtifact } from "./artifacts.js"; +import type { Paths } from "./config.js"; +import { cloudSchema, fitClouds, paintCloud, type Cloud } from "./points.js"; + +const timestamp = z.number().finite(); +export const clipSourceSchema = z + .object({ + type: z.enum(["points", "image"]), + timeOrigin: timestamp.optional(), + source: z.string().optional(), + frames: z + .array( + z.object({ + path: z.string(), + timestamp, + sha256: z.string().optional(), + }), + ) + .min(1) + .max(10_000), + }) + .refine( + (clip) => + clip.frames.every( + (frame, i) => !i || frame.timestamp > clip.frames[i - 1].timestamp, + ), + "Frame timestamps must be strictly increasing", + ); +export const clipSchema = z.object({ + type: z.enum(["points", "image"]), + timeOrigin: timestamp, + sourceCount: z.number().int().positive(), + frames: z + .array( + z.object({ + path: z.string(), + timestamp, + sha256: z.string(), + source: z.string(), + sourceSha256: z.string(), + index: z.number().int().nonnegative(), + }), + ) + .min(1) + .max(120), + gif: z.string().optional(), +}); +export type Clip = z.infer; + +/** Preview selection only. All selected times and original indexes remain explicit. */ +export function previewIndexes(times: readonly number[]): number[] { + const selected = [0], + step = Math.max(0.1, (times.at(-1)! - times[0]) / 119); + for (let i = 1; i < times.length - 1; i++) + if (times[i] - times[selected.at(-1)!] >= step) selected.push(i); + if (times.length > 1) selected.push(times.length - 1); + return selected; +} +export function frameDelays(times: readonly number[]): number[] { + return times.map((time, i) => + Math.max(10, Math.round(((times[i + 1] ?? time + 0.1) - time) * 1000)), + ); +} +function timestampLabel(time: number): Buffer { + return Buffer.from( + `t = ${time.toFixed(3)} s`, + ); +} + +/** Local presentation of an already exported, finite result. No DimOS query executes here. */ +export async function renderClip( + paths: Paths, + source: string, + data: unknown, + signal?: AbortSignal, + frameIndex?: number, + progress?: (done: number, total: number) => void, +): Promise<{ clip: Clip; png: Buffer; summary: string }> { + const input = clipSourceSchema.parse(data), + times = input.frames.map((frame) => frame.timestamp); + if (times.at(-1)! - times[0] > 60) + throw new Error( + "Clip exceeds 60 seconds; select a smaller interval in DimOS.", + ); + if ( + frameIndex !== undefined && + (!Number.isInteger(frameIndex) || + frameIndex < 0 || + frameIndex >= times.length) + ) + throw new Error( + `Choose a source frame index from 0 to ${times.length - 1}.`, + ); + const indexes = previewIndexes(times); + const required = [ + ...new Set([...indexes, ...(frameIndex === undefined ? [] : [frameIndex])]), + ]; + const loaded = new Map< + number, + { bytes: Buffer; sha256: string; source: string; cloud?: Cloud } + >(); + let totalBytes = 0, + totalPoints = 0; + for (const i of required) { + signal?.throwIfAborted(); + const frame = input.frames[i], + file = resolve(dirname(source), frame.path); + const value = await readArtifact(file, frame.sha256); + totalBytes += value.bytes.length; + if (totalBytes > 128 * 1024 * 1024) + throw new Error( + "Clip source exceeds 128 MiB; select a smaller interval in DimOS.", + ); + const cloud = + input.type === "points" + ? cloudSchema.parse(JSON.parse(value.bytes.toString("utf8"))) + : undefined; + if (cloud) { + totalPoints += cloud.points.length; + if (totalPoints > 2_000_000) + throw new Error( + "Clip exceeds two million source points; export a smaller preview from DimOS.", + ); + if ( + cloud.timestamp !== undefined && + Math.abs(cloud.timestamp - frame.timestamp) > 0.000001 + ) + throw new Error("Cloud timestamp differs from its frame index."); + } + loaded.set(i, { ...value, source: file, cloud }); + } + const clouds = [...loaded.values()].flatMap((value) => + value.cloud ? [value.cloud] : [], + ); + if (new Set(clouds.map((cloud) => cloud.frame)).size > 1) + throw new Error( + "Mixed coordinate frames; align the data in DimOS before rendering.", + ); + const camera = clouds.length ? fitClouds(clouds) : undefined; + const selected = frameIndex === undefined ? indexes : [frameIndex]; + const clip: Clip = { + type: input.type, + timeOrigin: input.timeOrigin ?? times[0], + sourceCount: times.length, + frames: [], + }; + const images: Buffer[] = []; + let outputBytes = 0; + for (const index of selected) { + signal?.throwIfAborted(); + const value = loaded.get(index)!; + const pixels = value.cloud + ? await paintCloud(value.cloud, camera) + : value.bytes; + const png = await sharp(pixels, { density: 144 }) + .resize(900, 448, { fit: "contain", background: "#101b21" }) + .flatten({ background: "#101b21" }) + .extend({ bottom: 28, background: "#0c1216" }) + .composite([ + { + input: timestampLabel(times[index] - clip.timeOrigin), + top: 448, + left: 0, + }, + ]) + .removeAlpha() + .png() + .toBuffer(); + signal?.throwIfAborted(); + outputBytes += png.length; + if (outputBytes > 64 * 1024 * 1024) + throw new Error( + "Clip preview exceeds 64 MiB; export a smaller preview from DimOS.", + ); + const path = await cacheImage(paths, png); + const sha256 = path.split("/").at(-1)!.split(".")[0]; + clip.frames.push({ + path, + sha256, + timestamp: times[index], + source: value.source, + sourceSha256: value.sha256, + index, + }); + images.push(png); + progress?.(images.length, selected.length); + } + if (images.length > 1) { + const gif = await sharp(images, { join: { animated: true } }) + .gif({ + loop: 0, + delay: frameDelays(clip.frames.map((frame) => frame.timestamp)), + effort: 3, + }) + .toBuffer(); + signal?.throwIfAborted(); + if (gif.length > 32 * 1024 * 1024) + throw new Error( + "GIF exceeds 32 MiB; export a smaller preview from DimOS.", + ); + clip.gif = await cacheImage(paths, gif, "gif"); + } + const chosen = [ + ...new Set( + Array.from({ length: Math.min(6, images.length) }, (_, i) => + Math.round( + (i * (images.length - 1)) / + Math.max(1, Math.min(6, images.length) - 1), + ), + ), + ), + ]; + const tiles = await Promise.all( + chosen.map(async (index, i) => ({ + input: await sharp(images[index]).resize(360, 190).png().toBuffer(), + left: (i % 3) * 360, + top: Math.floor(i / 3) * 190, + })), + ); + const png = + images.length === 1 + ? images[0] + : await sharp({ + create: { + width: Math.min(3, tiles.length) * 360, + height: Math.ceil(tiles.length / 3) * 190, + channels: 3, + background: "#101b21", + }, + }) + .composite(tiles) + .png() + .toBuffer(); + const summary = `${selected.length}/${times.length} source frames · ${input.type} · ${(times[0] - clip.timeOrigin).toFixed(3)}–${(times.at(-1)! - clip.timeOrigin).toFixed(3)} s; ${camera ? "fixed camera and height scale; " : ""}model image shows ${chosen.length} timestamped frames; ${input.source ?? "source index"}`; + signal?.throwIfAborted(); + return { clip, png, summary }; +} diff --git a/agent/src/input.ts b/agent/src/input.ts index 83995098dc9..3d71ae04449 100644 --- a/agent/src/input.ts +++ b/agent/src/input.ts @@ -27,6 +27,9 @@ export const commands = [ "inspect", "view", "panel", + "play", + "pause", + "seek", "expand", "exit", ]; diff --git a/agent/src/playback.ts b/agent/src/playback.ts new file mode 100644 index 00000000000..1011004daa7 --- /dev/null +++ b/agent/src/playback.ts @@ -0,0 +1,181 @@ +import { + Image, + Text, + truncateToWidth, + type Component, + type TuiMouseEvent, +} from "@earendil-works/pi-tui"; +import { readArtifact } from "./artifacts.js"; +import { frameDelays, type Clip } from "./clips.js"; +import { accent, clean, muted } from "./terminal-style.js"; + +/** A finite, source-timed image player. It never reads a recording or repeats a query. */ +export class Playback implements Component { + private images: Image[] = []; + private timer?: ReturnType; + private disposed = false; + private error?: string; + private scrubRow = 0; + private index = 0; + private running = false; + readonly ready: Promise; + constructor( + readonly clip: Clip, + private readonly changed: () => void, + private autoplay = false, + ) { + this.ready = this.load() + .then(() => { + if (this.autoplay) this.play(); + }) + .catch((error) => { + if (!this.disposed) { + this.error = clean(String(error)); + this.changed(); + } + }); + } + get frameIndex(): number { + return this.index; + } + get playing(): boolean { + return this.running; + } + private async load(): Promise { + for (const frame of this.clip.frames) { + if (this.disposed) return; + const { bytes } = await readArtifact(frame.path, frame.sha256); + if (this.disposed) return; + this.images.push( + new Image( + bytes.toString("base64"), + "image/png", + { fallbackColor: muted }, + { maxWidthCells: 120, maxHeightCells: 25 }, + ), + ); + } + this.changed(); + } + play(): void { + if ( + this.disposed || + this.images.length !== this.clip.frames.length || + this.error + ) + return; + clearTimeout(this.timer); + if (this.index === this.clip.frames.length - 1) this.index = 0; + this.running = this.clip.frames.length > 1; + this.schedule(); + this.changed(); + } + pause(): void { + this.autoplay = false; + clearTimeout(this.timer); + this.running = false; + if (!this.disposed) this.changed(); + } + seek(seconds: number): void { + if (this.disposed || !Number.isFinite(seconds)) return; + this.pause(); + const absolute = this.clip.timeOrigin + seconds; + this.index = Math.max( + 0, + this.clip.frames.findLastIndex((frame) => frame.timestamp <= absolute), + ); + this.changed(); + } + private schedule(): void { + if (!this.running || this.disposed) return; + const delays = frameDelays( + this.clip.frames.map((frame) => frame.timestamp), + ); + this.timer = setTimeout(() => { + if (this.disposed) return; + if (this.index < this.clip.frames.length - 1) this.index++; + else this.running = false; + this.changed(); + this.schedule(); + }, delays[this.index]); + } + close(): void { + this.disposed = true; + this.running = false; + clearTimeout(this.timer); + } + invalidate(): void { + for (const image of this.images) image.invalidate(); + } + handleMouse( + event: TuiMouseEvent, + ): { handled: boolean; render: boolean } | undefined { + if (event.type !== "press" || event.button !== "left") return; + if (event.y === 0 && event.x < 14) { + if (this.running) this.pause(); + else this.play(); + } else if (event.y === this.scrubRow) { + const first = this.clip.frames[0].timestamp, + last = this.clip.frames.at(-1)!.timestamp; + this.seek( + first - + this.clip.timeOrigin + + Math.max( + 0, + Math.min(1, (event.x - 1) / Math.max(1, event.width - 3)), + ) * + (last - first), + ); + } else return; + return { handled: true, render: true }; + } + render(width: number): string[] { + const frame = this.clip.frames[this.index], + time = frame.timestamp - this.clip.timeOrigin; + const header = + accent(this.running ? " ⏸ Pause" : " ▶ Play") + + muted( + ` ${time.toFixed(3)} s · frame ${frame.index + 1}/${this.clip.sourceCount} · ${this.clip.type}`, + ); + const lines = [ + truncateToWidth(header, width), + ...(this.images[this.index] && !this.error + ? this.images[this.index].render(width) + : new Text( + muted( + this.error + ? `Preview unavailable: ${this.error}` + : "Loading saved frames…", + ), + 0, + 0, + ).render(width)), + ]; + this.scrubRow = lines.length; + const count = Math.max(1, width - 2), + first = this.clip.frames[0].timestamp, + duration = this.clip.frames.at(-1)!.timestamp - first; + const head = Math.round( + (duration ? (frame.timestamp - first) / duration : 0) * (count - 1), + ); + lines.push( + truncateToWidth( + " " + + muted("━".repeat(head)) + + accent("●") + + muted("─".repeat(count - head - 1)), + width, + ), + ); + lines.push( + ...new Text( + muted( + " /play · /pause · /seek SECONDS · click timeline · source-timed playback", + ), + 0, + 0, + ).render(width), + ); + return lines; + } +} diff --git a/agent/src/points.ts b/agent/src/points.ts new file mode 100644 index 00000000000..94e7f2607bf --- /dev/null +++ b/agent/src/points.ts @@ -0,0 +1,133 @@ +import sharp from "sharp"; +import { z } from "zod"; +import { readArtifact } from "./artifacts.js"; + +const xyz = z.tuple([ + z.number().finite(), + z.number().finite(), + z.number().finite(), +]); +const rgb = z.tuple([ + z.number().int().min(0).max(255), + z.number().int().min(0).max(255), + z.number().int().min(0).max(255), +]); +export const cloudSchema = z + .object({ + points: z.array(xyz).min(1).max(1_000_000), + colors: z.array(rgb).optional(), + frame: z.string().optional(), + timestamp: z.number().finite().optional(), + selectedIndices: z.array(z.number().int().nonnegative()).optional(), + }) + .refine( + (cloud) => + cloud.selectedIndices?.every((i) => i < cloud.points.length) ?? true, + "Selection index outside point cloud", + ) + .refine( + (cloud) => !cloud.colors || cloud.colors.length === cloud.points.length, + "RGB count must match point count", + ); +export type Cloud = z.infer; +type XYZ = Cloud["points"][number]; +export interface Camera { + center: XYZ; + extent: number; + floor: number; + height: number; +} +export const POINT_LIMIT = 20_000; + +export async function readCloud( + path: string, + digest?: string, +): Promise<{ cloud: Cloud; sha256: string }> { + const { bytes, sha256 } = await readArtifact(path, digest); + return { + cloud: cloudSchema.parse(JSON.parse(bytes.toString("utf8"))), + sha256, + }; +} + +/** Shared presentation bounds; no registration, reconstruction or filtering. */ +export function fitClouds(clouds: readonly Cloud[]): Camera { + const low: XYZ = [Infinity, Infinity, Infinity], + high: XYZ = [-Infinity, -Infinity, -Infinity]; + for (const cloud of clouds) + for (const point of cloud.points) + for (let i = 0; i < 3; i++) { + low[i] = Math.min(low[i], point[i]); + high[i] = Math.max(high[i], point[i]); + } + return { + center: low.map((n, i) => (n + high[i]) / 2) as XYZ, + floor: low[2], + height: Math.max(high[2] - low[2], 0.001), + extent: Math.max(...high.map((n, i) => n - low[i]), 0.001), + }; +} +export function projectPoint( + point: XYZ, + camera: Camera, + width: number, + height: number, + angle = -0.58, + zoom = 1, +): [number, number] { + const [x, y, z] = point.map((v, i) => v - camera.center[i]); + const c = Math.cos(angle), + s = Math.sin(angle); + const scale = (Math.min(width / 1.65, height / 1.2) / camera.extent) * zoom; + return [ + width / 2 + (x * c - y * s) * scale, + height / 2 + (x * s + y * c) * scale * 0.5 - z * scale, + ]; +} +export async function paintCloud( + cloud: Cloud, + camera = fitClouds([cloud]), + angle = -0.58, + zoom = 1, +): Promise { + const selected = new Set(cloud.selectedIndices), + step = Math.max(1, Math.ceil(cloud.points.length / POINT_LIMIT)); + const circles: string[] = [], + grid: string[] = []; + for (let i = 0; i < cloud.points.length; i += step) { + const [x, y] = projectPoint(cloud.points[i], camera, 900, 400, angle, zoom); + const keep = selected.has(i), + elevation = (cloud.points[i][2] - camera.floor) / camera.height; + const color = keep + ? "#f5c078" + : cloud.colors + ? `rgb(${cloud.colors[i].join(",")})` + : "#6ae5cf"; + circles.push( + ``, + ); + } + for (let i = -3; i <= 3; i++) + for (const axis of [0, 1]) { + const a = [...camera.center] as XYZ, + b = [...camera.center] as XYZ; + a[axis] += (i * camera.extent) / 6; + b[axis] = a[axis]; + a[1 - axis] -= camera.extent / 2; + b[1 - axis] += camera.extent / 2; + a[2] = camera.floor; + b[2] = a[2]; + grid.push( + ``, + ); + } + const legend = `${cloud.points.length.toLocaleString()} points | display 1/${step} | ${cloud.colors ? "source RGB" : `height brightness z=${camera.floor.toFixed(2)}..${(camera.floor + camera.height).toFixed(2)}`} | ${selected.size} selected`; + return sharp( + Buffer.from( + `${grid.join("")}${circles.join("")}xzy${legend}`, + ), + ) + .removeAlpha() + .png() + .toBuffer(); +} diff --git a/agent/src/render.ts b/agent/src/render.ts index 3d6aeb4e2a7..ab3923a11a9 100644 --- a/agent/src/render.ts +++ b/agent/src/render.ts @@ -1,13 +1,4 @@ -import { createHash } from "node:crypto"; -import { - mkdir, - readFile, - readdir, - stat, - unlink, - writeFile, -} from "node:fs/promises"; -import { basename, join, resolve } from "node:path"; +import { basename, extname, resolve } from "node:path"; import { setTimeout as delay } from "node:timers/promises"; import type { ExtensionFactory } from "@earendil-works/pi-coding-agent"; import type { ImageContent, TextContent } from "@earendil-works/pi-ai"; @@ -17,31 +8,14 @@ import { z } from "zod"; import sharp from "sharp"; import type { Paths } from "./config.js"; import { MediaPool } from "./media.js"; +import { cacheImage, readArtifact } from "./artifacts.js"; +import { cloudSchema, paintCloud } from "./points.js"; +import { clipSchema, renderClip } from "./clips.js"; -const point = z.tuple([ - z.number().finite(), - z.number().finite(), - z.number().finite(), -]); -const sample = z.tuple([z.number().finite(), z.number().finite()]); -const cloud = z - .object({ - points: z.array(point).max(1_000_000), - frame: z.string().optional(), - timestamp: z.number().optional(), - }) - .passthrough(); -const series = z - .object({ - series: z - .array( - z.object({ name: z.string(), values: z.array(sample).max(100_000) }), - ) - .max(20), - }) - .passthrough(); export const renderDetails = z.object({ source: z.string(), + kind: z.enum(["image", "points", "sequence"]).optional(), + clip: clipSchema.optional(), sha256: z.string().optional(), summary: z.string(), title: z.string().optional(), @@ -60,121 +34,6 @@ export const renderDetails = z.object({ }); export type RenderDetails = z.infer; -export async function plot( - data: unknown, - kind: "points" | "series", -): Promise<{ png: Buffer; summary: string }> { - let groups: Array<{ name: string; values: Array<[number, number, number?]> }>; - let metadata: Record; - if (kind === "points") { - const { points, ...rest } = cloud.parse(data); - groups = [{ name: "points", values: points }]; - metadata = rest; - } else { - const { series: measured, ...rest } = series.parse(data); - groups = measured; - metadata = rest; - } - const values = groups.flatMap((group) => group.values); - if (!values.length) throw new Error("No points to render"); - const projected = values.map(([x, y, z = 0]) => - kind === "points" ? [x - y * 0.35, z + y * 0.35] : [x, y], - ); - let xmin = Infinity, - xmax = -Infinity, - ymin = Infinity, - ymax = -Infinity; - for (const [x, y] of projected) { - xmin = Math.min(xmin, x); - xmax = Math.max(xmax, x); - ymin = Math.min(ymin, y); - ymax = Math.max(ymax, y); - } - const project = ([x, y]: number[]) => [ - 24 + ((x - xmin) / (xmax - xmin || 1)) * 752, - 424 - ((y - ymin) / (ymax - ymin || 1)) * 400, - ]; - const colors = ["#5eead4", "#fbbf24", "#f472b6", "#60a5fa"]; - const step = Math.max(1, Math.ceil(values.length / 20_000)); - let offset = 0; - const shapes = groups - .map((group, index) => { - const coords = projected - .slice(offset, offset + group.values.length) - .filter((_, i) => i % step === 0) - .map(project); - offset += group.values.length; - const color = colors[index % colors.length]; - return kind === "points" - ? coords - .map( - ([x, y]) => - '', - ) - .join("") - : ''; - }) - .join(""); - const png = await sharp( - Buffer.from( - '' + - shapes + - "", - ), - ) - .png() - .toBuffer(); - return { - png, - summary: - values.length + - " source samples; " + - (kind === "points" - ? "XYZ oblique projection" - : groups.map((group) => group.name).join(", ")) + - "; display every " + - step + - " sample(s); axes " + - JSON.stringify({ xmin, xmax, ymin, ymax }) + - "; source metadata " + - JSON.stringify(metadata), - }; -} -async function cacheImage(paths: Paths, png: Buffer): Promise { - await mkdir(paths.cache, { recursive: true, mode: 0o700 }); - const path = join( - paths.cache, - createHash("sha256").update(png).digest("hex") + ".png", - ); - await writeFile(path, png, { mode: 0o600 }); - const entries = ( - await Promise.allSettled( - (await readdir(paths.cache)) - .filter((name) => /^[a-f0-9]{64}\.png$/.test(name)) - .map(async (name) => ({ - name, - info: await stat(join(paths.cache, name)), - })), - ) - ).flatMap((result) => (result.status === "fulfilled" ? [result.value] : [])); - let bytes = 0; - for (const entry of entries.sort((a, b) => b.info.mtimeMs - a.info.mtimeMs)) { - bytes += entry.info.size; - if (bytes > 128 * 1024 * 1024 && entry.name !== path.split("/").pop()) - await unlink(join(paths.cache, entry.name)).catch(() => {}); - } - return path; -} export function imageComponent( data: string, mimeType: string, @@ -211,12 +70,7 @@ export async function renderFiles( for (const view of views) { signal?.throwIfAborted(); const source = resolve(cwd, view.path); - if ((await stat(source)).size > 32 * 1024 * 1024) - throw new Error( - "Export exceeds 32 MiB; select a smaller view while retaining the original source.", - ); - const bytes = await readFile(source); - const sha256 = createHash("sha256").update(bytes).digest("hex"); + const { bytes, sha256 } = await readArtifact(source); const png = await sharp(bytes, { density: 144 }) .resize({ width: 1600, @@ -249,6 +103,87 @@ export async function renderFiles( }, }; } +/** Inspect saved results without coupling presentation to a DimOS runtime or transport. */ +export async function renderSaved( + paths: Paths, + cwd: string, + path: string, + options: { + kind?: "auto" | "image" | "points" | "sequence"; + title?: string; + frame?: number; + } = {}, + signal?: AbortSignal, + progress?: (done: number, total: number) => void, +): Promise<{ + content: Array; + details: RenderDetails; +}> { + const source = resolve(cwd, path); + let kind = options.kind ?? "auto"; + if ( + (kind === "image" || kind === "auto") && + extname(source).toLowerCase() !== ".json" + ) + return renderFiles( + paths, + cwd, + [{ path, label: basename(path) }], + options.title, + signal, + ); + const { bytes, sha256 } = await readArtifact(source); + signal?.throwIfAborted(); + const data: unknown = JSON.parse(bytes.toString("utf8")); + // A tagged frame index is authoritative, including when a caller supplies a still-view hint. + if ( + z + .object({ + type: z.enum(["points", "image"]), + frames: z.array(z.unknown()), + }) + .safeParse(data).success + ) + kind = "sequence"; + else if (kind === "auto") { + if (cloudSchema.safeParse(data).success) kind = "points"; + else + throw new Error( + "Unsupported JSON view. Export a plot/graph with DimOS or Python as SVG, then render that file.", + ); + } + let png: Buffer, summary: string, clip: RenderDetails["clip"]; + if (kind === "sequence") + ({ png, summary, clip } = await renderClip( + paths, + source, + data, + signal, + options.frame, + progress, + )); + else { + if (options.frame !== undefined) + throw new Error( + "frame selects an original sequence frame; provide a frame index file.", + ); + const cloud = cloudSchema.parse(data); + png = await paintCloud(cloud); + summary = `${cloud.points.length} source points; frame ${cloud.frame ?? "unspecified"}; timestamp ${cloud.timestamp ?? "unspecified"}; terminal projection, source retained`; + } + signal?.throwIfAborted(); + const preview = await cacheImage(paths, png); + return { + content: [ + { + type: "text", + text: `${summary}\nSource: ${source}\nSHA-256: ${sha256}\nPreview: ${preview}${clip?.gif ? "\nGIF: " + clip.gif : ""}${clip ? "\nOriginal frame indexes in preview: " + clip.frames.map((frame) => frame.index).join(", ") + "\nUse frame: INDEX to inspect any original frame without repeating the query." : ""}`, + }, + { type: "image", data: png.toString("base64"), mimeType: "image/png" }, + ], + details: { source, sha256, summary, kind, clip, title: options.title }, + }; +} export const renderExtension = (paths: Paths): ExtensionFactory => (pi) => { @@ -258,14 +193,18 @@ export const renderExtension = name: "dimcode_render", label: "Inspect sensor result", description: - "Display an existing operation's results in its terminal tool card. image: path or views [{path,label}] for up to 6 related SVG/PNG exports, with an optional title. For memory queries prefer DimOS Space/Plot SVGs and selected frames from the same materialized query; all views are returned to the model together. points: JSON {points:[[x,y,z]], frame?, timestamp?, selectedIndices?}. series: JSON {series:[{name,values:[[time,value]]}]}. Never rerun or reimplement memory analysis just to render it. live: explicit existing relay URL, robot and channel; terminal frames stream directly and only one final snapshot enters model context.", + "Display existing results in their terminal tool card. Auto-detect PNG/SVG images, XYZ JSON {points:[[x,y,z]],colors?:[[r,g,b]],frame?,timestamp?,selectedIndices?}, or a finite sequence index {type:'points'|'image',timeOrigin?,source?,frames:[{path,timestamp,sha256?}]}. Frame paths are relative to the index; timestamps are seconds, strictly increasing. Sequence previews use a fixed camera, source timing and a GIF; model receives a timestamped contact sheet. frame: INDEX returns one original source frame for closer inspection. views [{path,label}] groups up to 6 related SVG/PNG exports. Memory selection, filtering and analysis stay in DimOS. For any unsupported plot/graph, use DimOS Space/Plot or inline Python to export SVG and display it here. Never repeat a memory query to render it. live: explicit existing relay URL, robot and channel; only the final snapshot enters model context.", parameters: Type.Object({ - kind: Type.Union([ - Type.Literal("image"), - Type.Literal("points"), - Type.Literal("series"), - Type.Literal("live"), - ]), + kind: Type.Optional( + Type.Union([ + Type.Literal("auto"), + Type.Literal("image"), + Type.Literal("points"), + Type.Literal("sequence"), + Type.Literal("live"), + ]), + ), + frame: Type.Optional(Type.Integer({ minimum: 0 })), path: Type.Optional(Type.String()), title: Type.Optional(Type.String()), views: Type.Optional( @@ -280,22 +219,34 @@ export const renderExtension = seconds: Type.Optional(Type.Number({ minimum: 1, maximum: 30 })), }), execute: async (_id, args, signal, update, ctx) => { - if (args.kind === "image") - return renderFiles( + if (args.views) + return renderFiles(paths, ctx.cwd, args.views, args.title, signal); + if (args.kind !== "live") { + if (!args.path) throw new Error("Saved rendering requires a path"); + return renderSaved( paths, ctx.cwd, - args.views ?? - (args.path - ? [{ path: args.path, label: basename(args.path) }] - : []), - args.title, + args.path, + { kind: args.kind, title: args.title, frame: args.frame }, signal, + (done, total) => + update?.({ + content: [ + { + type: "text", + text: `Rendering saved frames ${done}/${total}`, + }, + ], + details: { + source: resolve(ctx.cwd, args.path!), + summary: `Rendering saved frames ${done}/${total}`, + title: args.title, + }, + }), ); - let png: Buffer, - source: string, - summary: string, - digest: string | undefined; - if (args.kind === "live") { + } + let png: Buffer, source: string, summary: string; + { if (!args.url || !args.channel) throw new Error( "Live rendering requires an existing relay URL and channel.", @@ -356,34 +307,13 @@ export const renderExtension = } finally { lease.close(); } - } else { - if (!args.path) throw new Error("Saved rendering requires a path"); - source = resolve(ctx.cwd, args.path); - const info = await stat(source); - if (info.size > 32 * 1024 * 1024) - throw new Error( - "Export exceeds 32 MiB; select a smaller view while retaining the original source.", - ); - const bytes = await readFile(source); - signal?.throwIfAborted(); - digest = createHash("sha256").update(bytes).digest("hex"); - ({ png, summary } = await plot( - JSON.parse(bytes.toString("utf8")), - args.kind, - )); } const preview = await cacheImage(paths, png); return { content: [ { type: "text", - text: - summary + - "\nSource: " + - source + - (digest ? "\nSHA-256: " + digest : "") + - "\nPreview: " + - preview, + text: summary + "\nSource: " + source + "\nPreview: " + preview, }, { type: "image", @@ -391,7 +321,7 @@ export const renderExtension = mimeType: "image/png", }, ], - details: { source, sha256: digest, summary } satisfies RenderDetails, + details: { source, summary } satisfies RenderDetails, }; }, renderResult: (result) => { diff --git a/agent/src/session.ts b/agent/src/session.ts index 778b441491c..b85a1a26e27 100644 --- a/agent/src/session.ts +++ b/agent/src/session.ts @@ -57,7 +57,7 @@ export async function openSession( ], appendSystemPrompt: [ "You are dimcode, Dimensional's coding and robotics agent. Use DimOS CLI/public Python APIs and the advertised MCP skills. Do not create a parallel runtime or transport service. Select the intended run and endpoint explicitly; never assume the latest run is the intended one. Render actual saved results with dimcode_render. Live previews are labeled context, not historical query results.", - "Visualize each meaningful sensor or memory operation with dimcode_render. Prefer supported types and existing DimOS visualizers; otherwise generate a self-contained SVG with inline Python from the already evaluated result and render that file. Label proposed ideas or overlays distinctly from observations. Use returned images as visual feedback when interpreting results. DimOS owns reusable visualization semantics; dimcode owns terminal-specific styling and interaction.", + "Visualize each meaningful sensor or memory operation with dimcode_render. Prefer supported types and existing DimOS visualizers; otherwise generate a self-contained SVG with inline Python from the already evaluated result and render that file. Label proposed ideas or overlays distinctly from observations. Use returned images as visual feedback when interpreting results. DimOS owns reusable visualization semantics; dimcode owns terminal-specific styling and interaction. Built-in views are only point clouds and images/SVGs. For a saved sequence, use the Dimensional skill’s finite frame index format; the model sees a contact sheet and can request an original frame index for closer inspection. Never imply a contact sheet covers every frame.", "Selected configuration: " + JSON.stringify(config), ], }, diff --git a/agent/src/spatial.ts b/agent/src/spatial.ts index d362529948f..16346036175 100644 --- a/agent/src/spatial.ts +++ b/agent/src/spatial.ts @@ -1,5 +1,3 @@ -import { createHash } from "node:crypto"; -import { readFile, stat } from "node:fs/promises"; import { basename } from "node:path"; import { getCapabilities, @@ -9,52 +7,17 @@ import { truncateToWidth, type Component, } from "@earendil-works/pi-tui"; -import sharp from "sharp"; -import { z } from "zod"; +import { + fitClouds, + paintCloud, + projectPoint, + POINT_LIMIT, + type Cloud, + type Camera, +} from "./points.js"; +export { readCloud } from "./points.js"; import { accent, amber, clean, muted, StatusLine } from "./terminal-style.js"; -export const cloudSchema = z - .object({ - points: z - .array( - z.tuple([ - z.number().finite(), - z.number().finite(), - z.number().finite(), - ]), - ) - .min(1) - .max(1_000_000), - frame: z.string().optional(), - timestamp: z.number().finite().optional(), - selectedIndices: z.array(z.number().int().nonnegative()).optional(), - }) - .refine( - (cloud) => - cloud.selectedIndices?.every((i) => i < cloud.points.length) ?? true, - "Selection index outside point cloud", - ); -type Cloud = z.infer; -type XYZ = Cloud["points"][number]; - -export async function readCloud( - path: string, - digest?: string, -): Promise<{ cloud: Cloud; sha256: string }> { - if ((await stat(path)).size > 32 * 1024 * 1024) - throw new Error("Point-cloud export exceeds 32 MiB"); - const bytes = await readFile(path); - const sha256 = createHash("sha256").update(bytes).digest("hex"); - if (digest && digest !== sha256) - throw new Error( - "Source changed since the agent inspected it; showing the saved result instead", - ); - return { - cloud: cloudSchema.parse(JSON.parse(bytes.toString("utf8"))), - sha256, - }; -} - /** A presentation of one immutable observation. View controls never execute a DimOS query. */ export class SpatialView implements Component { private angle = -0.58; @@ -65,10 +28,7 @@ export class SpatialView implements Component { private revision = 0; private disposed = false; private animation?: ReturnType; - private readonly center: XYZ; - private readonly extent: number; - private readonly floor: number; - private readonly height: number; + private readonly camera: Camera; private readonly selected: Set; private readonly step: number; private readonly heading: StatusLine; @@ -79,19 +39,9 @@ export class SpatialView implements Component { readonly sha256: string, private readonly changed: () => void, ) { - const low: XYZ = [Infinity, Infinity, Infinity], - high: XYZ = [-Infinity, -Infinity, -Infinity]; - for (const point of cloud.points) - for (let i = 0; i < 3; i++) { - low[i] = Math.min(low[i], point[i]); - high[i] = Math.max(high[i], point[i]); - } - this.center = low.map((n, i) => (n + high[i]) / 2) as XYZ; - this.floor = low[2]; - this.height = Math.max(high[2] - low[2], 0.001); - this.extent = Math.max(...high.map((n, i) => n - low[i]), 0.001); + this.camera = fitClouds([cloud]); this.selected = new Set(cloud.selectedIndices); - this.step = Math.max(1, Math.ceil(cloud.points.length / 20_000)); + this.step = Math.max(1, Math.ceil(cloud.points.length / POINT_LIMIT)); this.heading = new StatusLine(() => [ accent("⠿ PointCloud") + muted(" " + clean(basename(source))), muted( @@ -111,53 +61,13 @@ export class SpatialView implements Component { private retained(i: number): boolean { return this.selected.has(i); } - private project(point: XYZ, width: number, height: number): [number, number] { - const [x, y, z] = point.map((v, i) => v - this.center[i]); - const c = Math.cos(this.angle), - s = Math.sin(this.angle); - const scale = - (Math.min(width / 1.65, height / 1.2) / this.extent) * this.zoom; - return [ - width / 2 + (x * c - y * s) * scale, - height / 2 + (x * s + y * c) * scale * 0.5 - z * scale, - ]; - } private async raster(revision: number): Promise { - const masked = !!this.cloud.selectedIndices; - const circles: string[] = []; - for (let i = 0; i < this.cloud.points.length; i += this.step) { - const [x, y] = this.project(this.cloud.points[i], 900, 400); - const keep = this.retained(i); - const elevation = (this.cloud.points[i][2] - this.floor) / this.height; - circles.push( - ``, - ); - } - const grid: string[] = []; - for (let i = -3; i <= 3; i++) - for (const axis of [0, 1]) { - const a = [...this.center] as XYZ, - b = [...this.center] as XYZ; - a[axis] += (i * this.extent) / 6; - b[axis] = a[axis]; - a[1 - axis] -= this.extent / 2; - b[1 - axis] += this.extent / 2; - a[2] = this.floor; - b[2] = a[2]; - const start = this.project(a, 900, 400), - end = this.project(b, 900, 400); - grid.push( - ``, - ); - } - const png = await sharp( - Buffer.from( - `${grid.join("")}${circles.join("")}xzy`, - ), - ) - .removeAlpha() - .png() - .toBuffer(); + const png = await paintCloud( + this.cloud, + this.camera, + this.angle, + this.zoom, + ); if (this.disposed || revision !== this.revision) return; this.image = new Image( png.toString("base64"), @@ -223,7 +133,14 @@ export class SpatialView implements Component { [64, 128], ]; for (let i = 0; i < this.cloud.points.length; i += this.step) { - const [px, py] = this.project(this.cloud.points[i], cols * 2, rows * 4); + const [px, py] = projectPoint( + this.cloud.points[i], + this.camera, + cols * 2, + rows * 4, + this.angle, + this.zoom, + ); const x = Math.floor(px), y = Math.floor(py); if (x < 0 || y < 0 || x >= cols * 2 || y >= rows * 4) continue; diff --git a/agent/src/terminal.ts b/agent/src/terminal.ts index d4cbe522c02..4cdd972228d 100644 --- a/agent/src/terminal.ts +++ b/agent/src/terminal.ts @@ -26,10 +26,12 @@ import { } from "@earendil-works/pi-coding-agent"; import { z } from "zod"; import { Connection, type Event, type Snapshot } from "./protocol.js"; -import { imageComponent, renderDetails } from "./render.js"; +import { imageComponent, renderDetails, renderSaved } from "./render.js"; import { MediaPool, type MediaLease } from "./media.js"; import { PromptEditor, commands } from "./input.js"; import { SpatialView, readCloud } from "./spatial.js"; +import { paths } from "./config.js"; +import { Playback } from "./playback.js"; import { ResultImages } from "./tool-images.js"; import { accent, clean, muted, StatusLine } from "./terminal-style.js"; import type { Slot } from "@dimos/sdk"; @@ -62,9 +64,10 @@ export async function terminal( input = new PromptEditor(tui); let lastSpatial: SpatialView | undefined; let lastImages: ResultImages | undefined; + let lastPlayback: Playback | undefined; + let localRender: AbortController | undefined; let inspectorOpen = false; let layout: VStack; - const localViews = new Set(); const inspect = (view = lastSpatial) => { if (!view) { notice( @@ -112,6 +115,7 @@ export async function terminal( args: unknown; spatial?: SpatialView; images?: ResultImages; + playback?: Playback; loading?: boolean; lease?: MediaLease; close?: () => void; @@ -136,15 +140,17 @@ export async function terminal( tui.requestRender(); }; const closeCards = () => { + localRender?.abort(); + localRender = undefined; for (const card of cards.values()) { card.close?.(); card.spatial?.close(); card.images?.close(); + card.playback?.close(); } - for (const view of localViews) view.close(); - localViews.clear(); lastSpatial = undefined; lastImages = undefined; + lastPlayback = undefined; cards.clear(); }; const draw = ( @@ -183,6 +189,7 @@ export async function terminal( toolName, args, }; + lastPlayback?.pause(); cards.set(id, card); transcript.addChild(card.block); } @@ -221,10 +228,26 @@ export async function terminal( } card.component.updateResult({ ...result.data, isError }, partial); card.component.setExpanded(card.expanded); + if ( + details.success && + details.data.clip && + !partial && + !isError && + !card.playback + ) { + lastPlayback?.pause(); + card.playback = new Playback( + details.data.clip, + () => tui.requestRender(), + !restoring, + ); + lastPlayback = card.playback; + } const images = result.data.content.filter((item) => item.type === "image"); if ( images.length && !card.spatial && + !card.playback && (!card.images || images.length !== card.images.images.length || images.some((image, i) => image.data !== card.images?.images[i].data)) @@ -251,7 +274,8 @@ export async function terminal( card.block.addChild( new Text(muted(" " + clean(summary.slice(0, 220))), 0, 0), ); - if (card.spatial) card.block.addChild(card.spatial); + if (card.playback) card.block.addChild(card.playback); + else if (card.spatial) card.block.addChild(card.spatial); else if (card.images) card.block.addChild(card.images); } const pointArgs = z @@ -259,7 +283,8 @@ export async function terminal( .safeParse(card.args); if ( details.success && - pointArgs.success && + (details.data.kind === "points" || + (!details.data.kind && pointArgs.success)) && !partial && !isError && !card.spatial && @@ -574,7 +599,10 @@ export async function terminal( add(commands.map((command) => "/" + command).join(" · ")); add("/resume ID · /model PROVIDER MODEL · /login PROVIDER [oauth]"); add( - "/panel N: result view (0 overview) · /inspect PATH: saved XYZ · /view: rotate/zoom · /expand: latest tool details", + "/panel N: result view (0 overview) · /inspect PATH: saved result · /view: rotate/zoom · /expand: latest tool details", + ); + add( + "/play · /pause · /seek SECONDS: latest saved clip (click the timeline to scrub)", ); return; } @@ -613,6 +641,7 @@ export async function terminal( if (card) { const [id, tool] = card; tool.expanded = !tool.expanded; + if (tool.expanded) tool.playback?.pause(); draw(id, tool.output, tool.toolName, tool.args); } return; @@ -636,18 +665,56 @@ export async function terminal( lastImages.select(index); return; } + if ( + value === "/play" || + value === "/pause" || + value.startsWith("/seek ") + ) { + if (!lastPlayback) + throw new Error( + "No saved clip yet. Use /inspect INDEX.json or ask the agent to render a sequence.", + ); + if (value === "/play") lastPlayback.play(); + else if (value === "/pause") lastPlayback.pause(); + else { + const seconds = Number(value.slice(6).trim()); + if (!Number.isFinite(seconds)) + throw new Error( + "Use /seek SECONDS relative to the recording origin.", + ); + lastPlayback.seek(seconds); + } + return; + } if (value.startsWith("/inspect ")) { - const source = resolve(current.cwd, value.slice(9).trim()); - const { cloud, sha256 } = await readCloud(source); - const view = new SpatialView(cloud, source, sha256, () => - tui.requestRender(), - ); - lastSpatial = view; - localViews.add(view); - transcript.addChild(view); + const path = value.slice(9).trim(); + localRender?.abort(); + const controller = new AbortController(); + localRender = controller; + try { + const output = await renderSaved( + paths(), + current.cwd, + path, + {}, + controller.signal, + (done, total) => { + status.setText(`Rendering saved frames ${done}/${total}`); + tui.requestRender(); + }, + ); + if (!controller.signal.aborted) + draw(crypto.randomUUID(), output, "dimcode_render", { path }); + } finally { + if (localRender === controller) { + localRender = undefined; + status.setText(ready()); + } + } return; } if (value === "/abort") { + localRender?.abort(); await client.call({ type: "abort" }); return; } diff --git a/agent/test/clips.test.ts b/agent/test/clips.test.ts new file mode 100644 index 00000000000..862273d451e --- /dev/null +++ b/agent/test/clips.test.ts @@ -0,0 +1,291 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { test } from "node:test"; +import { promisify } from "node:util"; +import { setCapabilities, visibleWidth } from "@earendil-works/pi-tui"; +import sharp from "sharp"; +import { paths } from "../src/config.js"; +import { + clipSourceSchema, + frameDelays, + previewIndexes, + renderClip, +} from "../src/clips.js"; +import { readArtifact } from "../src/artifacts.js"; +import { fitClouds, projectPoint, type Cloud } from "../src/points.js"; +import { renderSaved } from "../src/render.js"; +import { Playback } from "../src/playback.js"; + +test("preview timing is bounded and retains source indexes; projection uses shared bounds", () => { + const times = Array.from({ length: 6000 }, (_, i) => 123 + i / 100); + const indexes = previewIndexes(times); + assert(indexes.length <= 120); + assert.equal(indexes[0], 0); + assert.equal(indexes.at(-1), times.length - 1); + const selected = indexes.map((i) => times[i]); + assert(selected.every((time, i) => !i || time > selected[i - 1])); + assert.deepEqual(frameDelays([0, 0.11, 0.26]), [110, 150, 100]); + const clouds: Cloud[] = [ + { + points: [ + [0, 0, 0], + [1, 2, 3], + ], + }, + { + points: [ + [1, 2, 3], + [8, 4, 2], + ], + }, + ]; + const camera = fitClouds(clouds); + assert.deepEqual( + projectPoint(clouds[0].points[1], camera, 900, 400), + projectPoint(clouds[1].points[0], camera, 900, 400), + ); + assert.equal(camera.extent, 8); + assert.equal(camera.floor, 0); + assert.equal(camera.height, 3); +}); + +test("saved image/SVG clips preserve colors, timestamps and hashes; playback seeks, ends and disposes", async (t) => { + const dir = await mkdtemp(join(tmpdir(), "dimcode-clips-")); + t.after(() => rm(dir, { recursive: true, force: true })); + const p = paths({ XDG_CACHE_HOME: dir }); + const source = join(dir, "clip.json"); + const frames = ["#ff0000", "#00ff00", "#0000ff"].map((color, i) => ({ + path: `frame-${i}.svg`, + timestamp: [100, 100.11, 100.26][i], + color, + })); + for (const frame of frames) + await writeFile( + join(dir, frame.path), + ``, + ); + await writeFile( + source, + JSON.stringify({ type: "image", timeOrigin: 99, frames }), + ); + const result = await renderSaved(p, dir, source, { kind: "points" }); + const clip = result.details.clip!; + assert.equal(clip.frames.length, 3); + assert.equal( + result.content.filter((item) => item.type === "image").length, + 1, + "model gets a contact sheet, not every playback frame", + ); + assert.equal( + result.details.kind, + "sequence", + "tagged indexes override a still-view hint", + ); + for (const [i, frame] of clip.frames.entries()) { + assert.equal(frame.timestamp, frames[i].timestamp); + assert.equal(frame.index, i); + assert.equal( + frame.sourceSha256, + createHash("sha256") + .update(await readFile(frame.source)) + .digest("hex"), + ); + const { bytes } = await readArtifact(frame.path, frame.sha256); + const rgb = await sharp(bytes) + .extract({ left: 450, top: 200, width: 1, height: 1 }) + .raw() + .toBuffer(); + assert.deepEqual( + [...rgb], + [0, 1, 2].map((channel) => (channel === i ? 255 : 0)), + ); + } + const gif = await sharp(clip.gif!, { animated: true }).metadata(); + assert.equal(gif.pages, 3); + assert.deepEqual(gif.delay, [110, 150, 100]); + const single = await renderSaved(p, dir, source, { frame: 1 }); + assert.equal(single.details.clip?.frames[0].index, 1); + assert.equal(single.details.clip?.gif, undefined); + await assert.rejects( + renderSaved(p, dir, source, { frame: 3 }), + /source frame index/, + ); + setCapabilities({ images: null, trueColor: true, hyperlinks: false }); + let updates = 0; + let onChange = () => {}; + const view = new Playback(clip, () => { + updates++; + onChange(); + }); + t.after(() => view.close()); + await view.ready; + view.seek(1.12); + assert.equal(view.frameIndex, 1); + assert.equal(view.playing, false); + for (const width of [1, 20, 80]) + assert(view.render(width).every((line) => visibleWidth(line) <= width)); + view.seek(-100); + assert.equal(view.frameIndex, 0); + view.seek(100); + assert.equal(view.frameIndex, 2); + const ended = new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error("Playback did not finish")), + 1500, + ); + onChange = () => { + if (view.frameIndex === 2 && !view.playing) { + clearTimeout(timeout); + resolve(); + } + }; + }); + view.play(); + assert.equal(view.frameIndex, 0, "play at the end restarts"); + await ended; + view.play(); + view.pause(); + assert.equal(view.playing, false); + view.close(); + const closed = updates; + view.play(); + view.seek(1); + view.pause(); + assert.equal(updates, closed, "disposed views cannot notify or restart"); + const pausedWhileLoading = new Playback(clip, () => {}, true); + pausedWhileLoading.pause(); + await pausedWhileLoading.ready; + assert.equal( + pausedWhileLoading.playing, + false, + "loading cannot restart a paused or hidden clip", + ); + pausedWhileLoading.close(); + await writeFile(clip.frames[0].path, "changed"); + const stale = new Playback(clip, () => {}); + await stale.ready; + assert.match(stale.render(80).join("\n"), /Source changed/); + assert.equal(stale.playing, false); + stale.close(); +}); + +test("cloud clips reject changed sources, mismatched frames/times, invalid windows and cancellation", async (t) => { + const dir = await mkdtemp(join(tmpdir(), "dimcode-clouds-")); + t.after(() => rm(dir, { recursive: true, force: true })); + const p = paths({ XDG_CACHE_HOME: dir }), + source = join(dir, "clouds.json"); + const frames = [ + { path: "a.json", timestamp: 1 }, + { path: "b.json", timestamp: 1.2 }, + ]; + const cloud: Cloud = { + points: [ + [0, 0, 0], + [1, 2, 3], + ], + frame: "world", + colors: [ + [255, 0, 0], + [0, 255, 0], + ], + selectedIndices: [1], + }; + for (const frame of frames) + await writeFile( + join(dir, frame.path), + JSON.stringify({ ...cloud, timestamp: frame.timestamp }), + ); + const data = { type: "points", frames }; + const original = await readFile(join(dir, frames[0].path)); + const result = await renderClip(p, source, data); + assert.equal(result.clip.frames.length, 2); + assert.match(result.summary, /fixed camera and height scale/); + assert.deepEqual(await readFile(join(dir, frames[0].path)), original); + await assert.rejects( + renderClip(p, source, { + ...data, + frames: [{ ...frames[0], sha256: "wrong" }], + }), + /Source changed/, + ); + await writeFile( + join(dir, "b.json"), + JSON.stringify({ ...cloud, frame: "camera" }), + ); + await assert.rejects(renderClip(p, source, data), /Mixed coordinate/); + await writeFile( + join(dir, "b.json"), + JSON.stringify({ ...cloud, timestamp: 7 }), + ); + await assert.rejects(renderClip(p, source, data), /timestamp differs/); + assert.throws( + () => clipSourceSchema.parse({ ...data, frames: frames.toReversed() }), + /strictly increasing/, + ); + assert.throws(() => clipSourceSchema.parse({ ...data, frames: [] })); + await assert.rejects( + renderClip(p, source, { + ...data, + frames: [frames[0], { ...frames[1], timestamp: 100 }], + }), + /60 seconds/, + ); + const abort = new AbortController(); + abort.abort(); + await assert.rejects(renderClip(p, source, data, abort.signal), /abort/i); + await writeFile(source, JSON.stringify({ series: [] })); + await assert.rejects(renderSaved(p, dir, source), /DimOS or Python as SVG/); +}); + +test( + "Go2 memory seconds 1–5 export once and render cloud, camera and native plot", + { + timeout: 120000, + skip: !(process.env.DIMCODE_TEST_PYTHON && process.env.DIMCODE_TEST_GO2_DB), + }, + async (t) => { + const dir = await mkdtemp(join(tmpdir(), "dimcode-playback-go2-")); + t.after(() => rm(dir, { recursive: true, force: true })); + await promisify(execFile)( + process.env.DIMCODE_TEST_PYTHON!, + ["test/fixtures/clip_views.py", dir], + { env: { ...process.env, PYTHONPATH: resolve("..") }, timeout: 60000 }, + ); + const p = paths({ XDG_CACHE_HOME: dir }); + for (const type of ["points", "image"]) { + const source = join(dir, type + ".json"); + const index = clipSourceSchema.parse( + JSON.parse(await readFile(source, "utf8")), + ); + assert(index.frames.length > 20); + assert( + index.frames.every( + (frame) => + frame.timestamp >= index.timeOrigin! + 1 && + frame.timestamp < index.timeOrigin! + 5, + ), + ); + const result = await renderSaved(p, dir, source); + const clip = result.details.clip!; + assert(clip.frames.length > 15); + assert.equal(clip.sourceCount, index.frames.length); + for (const frame of clip.frames) { + assert.equal(frame.sourceSha256, index.frames[frame.index].sha256); + assert.equal(frame.timestamp, index.frames[frame.index].timestamp); + } + assert.equal( + (await sharp(clip.gif!, { animated: true }).metadata()).pages, + clip.frames.length, + ); + } + const plot = await renderSaved(p, dir, "point-count.svg"); + assert.equal( + plot.content.filter((item) => item.type === "image").length, + 1, + ); + }, +); diff --git a/agent/test/fixtures/clip_views.py b/agent/test/fixtures/clip_views.py new file mode 100644 index 00000000000..a16bbeea35a --- /dev/null +++ b/agent/test/fixtures/clip_views.py @@ -0,0 +1,83 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Export one real Go2 memory window through DimOS, independently of the harness.""" + +import hashlib +import json +import os +from pathlib import Path +import sys + +from dimos.memory.store.sqlite import SqliteStore +from dimos.memory.vis.plot.elements import Series +from dimos.memory.vis.plot.plot import Plot + + +def main() -> None: + out = Path(sys.argv[1]) + out.mkdir(parents=True, exist_ok=True) + store = SqliteStore(path=os.environ["DIMCODE_TEST_GO2_DB"], must_exist=True) + try: + origin = store.streams.lidar.first().ts + counts = [] + times = [] + report = {"source": os.environ["DIMCODE_TEST_GO2_DB"], "origin": origin, "interval": [1, 5]} + for name, kind in [("lidar", "points"), ("color_image", "image")]: + # Materialize once. Rendering reads these exports, never the database. + selected = store.stream(name).time_range(origin + 1, origin + 5).materialize() + frames = [] + for i, obs in enumerate(selected): + path = out / f"{name}-{i:03d}.{'json' if kind == 'points' else 'png'}" + if kind == "points": + xyz, rgb = obs.data.as_numpy() + data = {"points": xyz.tolist(), "frame": obs.data.frame_id, "timestamp": obs.ts} + if rgb is not None: + data["colors"] = (rgb * 255).round().clip(0, 255).astype(int).tolist() + path.write_text(json.dumps(data, separators=(",", ":"))) + counts.append(len(xyz)) + times.append(obs.ts) + else: + obs.data.save(str(path)) + frames.append( + { + "path": path.name, + "timestamp": obs.ts, + "sha256": hashlib.sha256(path.read_bytes()).hexdigest(), + } + ) + assert frames, f"Missing {name} observations" + index = { + "type": kind, + "timeOrigin": origin, + "source": f"Go2 memory.db · {name} · time_range(origin+1, origin+5).materialize()", + "frames": frames, + } + (out / f"{kind}.json").write_text(json.dumps(index, indent=2)) + report[kind] = { + "count": len(frames), + "first": frames[0]["timestamp"], + "last": frames[-1]["timestamp"], + } + Plot().add( + Series(ts=times, values=counts, label="source point count", color="#43bda7") + ).to_svg(str(out / "point-count.svg")) + (out / "query.json").write_text(json.dumps(report, indent=2)) + print(json.dumps(report)) + finally: + store.stop() + + +if __name__ == "__main__": + main() diff --git a/agent/test/render.test.ts b/agent/test/render.test.ts index 78ce829d60b..16476552268 100644 --- a/agent/test/render.test.ts +++ b/agent/test/render.test.ts @@ -1,40 +1,27 @@ import assert from "node:assert/strict"; import { test } from "node:test"; import sharp from "sharp"; -import { plot } from "../src/render.js"; +import { cloudSchema, paintCloud } from "../src/points.js"; import { ChatInput } from "../src/input.js"; -test("point projection and measured series produce real images without mutating inputs", async () => { - const input = { +test("point presentation validates geometry without mutating source", async () => { + const input = cloudSchema.parse({ points: [ [0, 0, 0], [1, 0, 2], [1, 2, 3], ], frame: "map", - }; + }); const before = JSON.stringify(input); - const result = await plot(input, "points"); + const png = await paintCloud(input); assert.equal(JSON.stringify(input), before); - assert.match(result.summary, /3 source samples/); - assert.equal((await sharp(result.png).metadata()).width, 800); - const compare = await plot( - { - series: [ - { - name: "duration_ms", - values: [ - [1, 12], - [2, 17], - [3, 4], - ], - }, - ], - }, - "series", + assert.equal((await sharp(png).metadata()).width, 900); + assert.throws(() => cloudSchema.parse({ points: [[NaN, 1, 2]] })); + assert.throws(() => + cloudSchema.parse({ points: [[0, 1, 2]], selectedIndices: [1] }), ); - assert.match(compare.summary, /duration_ms/); - await assert.rejects(plot({ points: [[NaN, 1, 2]] }, "points")); + assert.throws(() => cloudSchema.parse({ points: [[0, 1, 2]], colors: [] })); }); test("credentials cannot return through input undo after auth", () => { const input = new ChatInput(); From 4cce29615642bb3b906558a72701e20eb95b1985 Mon Sep 17 00:00:00 2001 From: stash Date: Sat, 12 Sep 2026 05:01:04 -0700 Subject: [PATCH 7/9] fix(dimcode): preserve image geometry while scrolling in Warp --- agent/README.md | 11 +-- agent/skills/dimensional/SKILL.md | 4 +- agent/src/input.ts | 3 - agent/src/playback.ts | 150 +++++++----------------------- agent/src/render.ts | 5 +- agent/src/session.ts | 2 +- agent/src/spatial.ts | 7 +- agent/src/terminal-image.ts | 128 +++++++++++++++++++++++++ agent/src/terminal.ts | 35 ++----- agent/src/tool-images.ts | 5 +- agent/test/clips.test.ts | 26 ++---- agent/test/scroll-images.test.ts | 96 +++++++++++++++++++ 12 files changed, 294 insertions(+), 178 deletions(-) create mode 100644 agent/src/terminal-image.ts create mode 100644 agent/test/scroll-images.test.ts diff --git a/agent/README.md b/agent/README.md index 7526968038c..752e8c0c3a6 100644 --- a/agent/README.md +++ b/agent/README.md @@ -88,7 +88,7 @@ dimcode run "inspect the app and explain its blueprint" Endpoints are explicit; ports are examples, not instance identities. Use `/reload` after changing endpoints. Every advertised skill is registered with its original schema, metadata and remote routing. Tool names are bounded and collision-resistant. There is no static copy of the robot's tools or a parallel lifecycle service. -Terminal commands: `/new`, `/sessions`, `/resume ID`, `/models`, `/model PROVIDER MODEL`, `/login PROVIDER [oauth]`, `/logout PROVIDER`, `/abort`, `/steer TEXT`, `/follow TEXT`, `/reload`, `/image PATH`, `/panel N`, `/inspect PATH`, `/play`, `/pause`, `/seek SECONDS`, `/view`, `/expand`, `/exit`. Ctrl-C detaches. One terminal owns input; other viewers may observe. Detaching keeps the turn running. Restart restores Pi history and never automatically replays external actions. Very large histories show a bounded recent transcript with an omission notice; the complete agent history remains on disk. Session events carry their source identity so switching sessions cannot mix transcripts. +Terminal commands: `/new`, `/sessions`, `/resume ID`, `/models`, `/model PROVIDER MODEL`, `/login PROVIDER [oauth]`, `/logout PROVIDER`, `/abort`, `/steer TEXT`, `/follow TEXT`, `/reload`, `/image PATH`, `/panel N`, `/inspect PATH`, `/view`, `/expand`, `/exit`. Ctrl-C detaches. One terminal owns input; other viewers may observe. Detaching keeps the turn running. Restart restores Pi history and never automatically replays external actions. Very large histories show a bounded recent transcript with an omission notice; the complete agent history remains on disk. Session events carry their source identity so switching sessions cannot mix transcripts. Pi owns context loading, skills, compaction, models and coding-tool behavior. Workspace instructions and configured Pi extensions load normally. Attached terminals support serialized dialogs/notifications; executable extension UI factories belong in the terminal renderer and cannot be sent through a socket. @@ -126,12 +126,9 @@ Call `dimcode_render({path:"clip.json",title:"Memory · seconds 1–5"})`, or us ```text /inspect /absolute/path/to/clip.json -/play -/pause -/seek 3 ``` -The tool card plays once automatically; click Play to replay or click the timeline to scrub. Commands control the latest clip. `/seek` uses seconds relative to `timeOrigin`. Restored sessions begin paused. Leaving the session or closing the terminal stops playback. Point-cloud clips share one camera, bounds and height scale; mismatched coordinate frames must be aligned in DimOS first. +The latest clip loops automatically, including after reconnect, with no player buttons or timeline. Ask the agent “show a single frame at 2 seconds” to inspect a still; it selects the timestamped result and renders that frame. A single-frame result stays static. Leaving the session or closing the terminal stops playback. Point-cloud clips share one camera, bounds and height scale; mismatched coordinate frames must be aligned in DimOS first. The terminal plays timestamped PNGs. A looping GIF is exported for sharing (GIF timing rounds to centiseconds); the model receives a contact sheet of up to six labeled frames. `dimcode_render({path:"clip.json",frame:17})` returns original zero-based frame 17 for closer inspection, even if preview sampling omitted it. The renderer reads saved files only and never repeats the source query. @@ -141,7 +138,7 @@ The agent is instructed to visualize each meaningful sensor/memory operation. Wh Live tools select an existing relay/robot/channel. The terminal receives frames directly through the Web SDK and coalesces drawing to 10 Hz. The gateway retains one final snapshot for model context. Closing/cancelling the tool releases consumers; the last consumer closes the connection. MediaPool is generic over decoded SDK slots and accepts existing decoder registries. The initial live image renderer handles JPEG; other channel types use their owning decoder/renderer or saved exports. -Graphics use Pi terminal-image support with text fallback. Derived PNGs/GIFs have a bounded 128 MiB cache; evicted previews show an explicit unavailable state and can be regenerated from retained exports; original DimOS recordings stay with DimOS. No recording, raw continuous video, new transport protocol or new DimOS gateway is introduced. +Graphics use Pi terminal-image support with text fallback. In Warp, terminal images are rendered as complete cell-row slices so partial scrolling crops the image without compressing its aspect ratio. Derived PNGs/GIFs have a bounded 128 MiB cache; evicted previews show an explicit unavailable state and can be regenerated from retained exports; original DimOS recordings stay with DimOS. No recording, raw continuous video, new transport protocol or new DimOS gateway is introduced. ## Contributor development and validation @@ -172,7 +169,7 @@ The Go2 E2E test starts the standard `unitree-go2` blueprint in **recorded-data - One shared SDK connection, lazy subscriptions, continued cloud reception after video closes, then zero viewers/subscriptions after the last renderer closes. - Cleanup of the blueprint and relay, including failure paths. -A separate memory test materializes seconds 1–5 once, exports full cloud/camera frames and a native DimOS plot, then checks GIF frame counts, shared time origin and every preview frame’s source hash. Playback unit tests cover seeking, completion, cancellation, stale files, RGB preservation and disposal. +A separate memory test materializes seconds 1–5 once, exports full cloud/camera frames and a native DimOS plot, then checks GIF frame counts, shared time origin and every preview frame’s source hash. Playback unit tests cover looping, cancellation, stale files, RGB preservation and disposal. A scrolling regression checks exact visible pixels at multiple scroll positions and widths. Set `DIMCODE_TEST_REPORT=/absolute/path/report.json` to save measured counts. Without `DIMCODE_TEST_GO2_DB`, the large recording test is explicitly skipped. The Python and Deno variables separately enable the MCP-handler and QUIC relay tests. Unit/CLI tests also cover onboarding, private credentials, cancellation, `tui`, existing-gateway handling, session ownership, detach/recovery and renderer provenance. diff --git a/agent/skills/dimensional/SKILL.md b/agent/skills/dimensional/SKILL.md index d3b3f0fbd13..a021a4f70e1 100644 --- a/agent/skills/dimensional/SKILL.md +++ b/agent/skills/dimensional/SKILL.md @@ -17,13 +17,13 @@ For memory, use existing stream operations. Evaluate a query once with materiali Show a visualization for each meaningful sensor/memory operation. Prefer a supported type or an existing DimOS visualizer. If neither supports the result, use inline Python to generate a self-contained SVG from the evaluated data, save it, and call `dimcode_render({kind:"image",path:"result.svg"})`. This fallback also supports custom plots, simple graphs and proposed overlays; label proposals distinctly from observations. Use the returned image as visual feedback when interpreting or refining the result. If rendering fails, report that explicitly and retain the original result. -DimOS owns reusable, agent-independent visualization semantics: geometry, axes, units, timestamps, legends, selections and generic encodings for agent understanding. Dimcode owns terminal-specific styling and interaction: card layout, theme, viewport/camera defaults, point appearance, playback controls, resizing and graphics/Braille adaptation. Terminal styling must preserve semantic colors and labels supplied by DimOS or a custom SVG. Keep terminal-specific presets out of DimOS. +DimOS owns reusable, agent-independent visualization semantics: geometry, axes, units, timestamps, legends, selections and generic encodings for agent understanding. Dimcode owns terminal-specific styling and interaction: card layout, theme, viewport/camera defaults, point appearance, looping playback, resizing and graphics/Braille adaptation. Terminal styling must preserve semantic colors and labels supplied by DimOS or a custom SVG. Keep terminal-specific presets out of DimOS. All analysis of point-cloud frames and video belongs in DimOS memory. Prefer its existing `Space.to_svg()` and `Plot.to_svg()` renderers, alongside selected-frame exports from the same materialized query. Display the views together with `dimcode_render({kind:"image", title:"Memory · QUERY", views:[{label:"Timeline",path:"timeline.svg"},{label:"Spatial",path:"space.svg"},{label:"Frames",path:"frames.png"}]})`. Supply only views actually produced by that operation. Preserve the native colors, timestamps, peaks, coordinate frames and selection metadata; don't reconstruct them in the harness. The terminal displays an overview and individually selectable panels, and the model receives every image. MCP tools returning multiple `agent_encode` images are displayed together automatically; a skill returning only a pose does not provide these views. Built-in renderers only present point clouds and images/SVGs. Export clouds through existing extraction as `{points:[[x,y,z]], colors?:[[r,g,b]], frame?, timestamp?, selectedIndices?}`; RGB is 0–255. Generate all other plots/graphs with DimOS or inline Python as SVG. Retain original exports and metadata. The renderer labels display sampling and hashes every source. -For memory/replay sequences, materialize the selected interval once in DimOS. Export its cloud JSONs or image/SVG frames, then write a finite index `{type:"points"|"image",timeOrigin:RECORDING_ORIGIN,source:"operation description",frames:[{path:"relative/frame.json",timestamp:CAPTURE_SECONDS,sha256?:DIGEST}]}`. Timestamps must strictly increase. Use the same recording origin for related streams and preserve their coordinate frames. Call `dimcode_render({path:"index.json",title:"Memory · seconds 1–5"})`; no kind is needed. The terminal plays once with a fixed cloud camera and source timestamps, and exports a looping GIF. The model receives a timestamped contact sheet; inspect any original zero-based frame with `dimcode_render({path:"index.json",frame:17})` when the sheet is insufficient. Never claim to have inspected every frame from a contact sheet alone. +For memory/replay sequences, materialize the selected interval once in DimOS. Export its cloud JSONs or image/SVG frames, then write a finite index `{type:"points"|"image",timeOrigin:RECORDING_ORIGIN,source:"operation description",frames:[{path:"relative/frame.json",timestamp:CAPTURE_SECONDS,sha256?:DIGEST}]}`. Timestamps must strictly increase. Use the same recording origin for related streams and preserve their coordinate frames. Call `dimcode_render({path:"index.json",title:"Memory · seconds 1–5"})`; no kind is needed. The latest clip loops automatically with a fixed cloud camera and source timestamps, and exports a looping GIF. There are no player buttons or seek commands. When the user asks for a frame at a particular time, use the saved index and its timeOrigin to select the matching original frame, then call dimcode_render with that frame index; a single-frame result is static. The model receives a timestamped contact sheet; inspect any original zero-based frame with `dimcode_render({path:"index.json",frame:17})` when the sheet is insufficient. Never claim to have inspected every frame from a contact sheet alone. The preview selects approximately 10 Hz, at most 120 frames across at most 60 seconds, and preserves original frame indexes. Larger results need a smaller export selected in DimOS, not a query inside the harness. Images, custom SVG animations and point clouds share this same player. GIF time is quantized to centiseconds; terminal scheduling uses source timestamp differences. Recordings stay with DimOS and only disposable previews enter the harness cache. diff --git a/agent/src/input.ts b/agent/src/input.ts index 3d71ae04449..83995098dc9 100644 --- a/agent/src/input.ts +++ b/agent/src/input.ts @@ -27,9 +27,6 @@ export const commands = [ "inspect", "view", "panel", - "play", - "pause", - "seek", "expand", "exit", ]; diff --git a/agent/src/playback.ts b/agent/src/playback.ts index 1011004daa7..13c92984e24 100644 --- a/agent/src/playback.ts +++ b/agent/src/playback.ts @@ -1,32 +1,29 @@ -import { - Image, - Text, - truncateToWidth, - type Component, - type TuiMouseEvent, -} from "@earendil-works/pi-tui"; +import { Text, type Component } from "@earendil-works/pi-tui"; import { readArtifact } from "./artifacts.js"; import { frameDelays, type Clip } from "./clips.js"; -import { accent, clean, muted } from "./terminal-style.js"; +import { clean, muted } from "./terminal-style.js"; +import { TerminalImage } from "./terminal-image.js"; -/** A finite, source-timed image player. It never reads a recording or repeats a query. */ +/** A looping presentation of saved frames. Frame selection belongs to the agent's tools. */ export class Playback implements Component { - private images: Image[] = []; + private images: TerminalImage[] = []; private timer?: ReturnType; private disposed = false; private error?: string; - private scrubRow = 0; private index = 0; private running = false; readonly ready: Promise; constructor( readonly clip: Clip, private readonly changed: () => void, - private autoplay = false, + private autoplay = true, ) { this.ready = this.load() .then(() => { - if (this.autoplay) this.play(); + if (this.autoplay && !this.disposed && this.images.length > 1) { + this.running = true; + this.schedule(); + } }) .catch((error) => { if (!this.disposed) { @@ -47,135 +44,52 @@ export class Playback implements Component { const { bytes } = await readArtifact(frame.path, frame.sha256); if (this.disposed) return; this.images.push( - new Image( + new TerminalImage( bytes.toString("base64"), "image/png", { fallbackColor: muted }, { maxWidthCells: 120, maxHeightCells: 25 }, + this.changed, ), ); } this.changed(); } - play(): void { - if ( - this.disposed || - this.images.length !== this.clip.frames.length || - this.error - ) - return; - clearTimeout(this.timer); - if (this.index === this.clip.frames.length - 1) this.index = 0; - this.running = this.clip.frames.length > 1; - this.schedule(); - this.changed(); - } pause(): void { this.autoplay = false; - clearTimeout(this.timer); this.running = false; - if (!this.disposed) this.changed(); - } - seek(seconds: number): void { - if (this.disposed || !Number.isFinite(seconds)) return; - this.pause(); - const absolute = this.clip.timeOrigin + seconds; - this.index = Math.max( - 0, - this.clip.frames.findLastIndex((frame) => frame.timestamp <= absolute), - ); - this.changed(); + clearTimeout(this.timer); } private schedule(): void { - if (!this.running || this.disposed) return; - const delays = frameDelays( - this.clip.frames.map((frame) => frame.timestamp), - ); + const delay = frameDelays(this.clip.frames.map((frame) => frame.timestamp))[ + this.index + ]; this.timer = setTimeout(() => { - if (this.disposed) return; - if (this.index < this.clip.frames.length - 1) this.index++; - else this.running = false; + if (!this.running || this.disposed) return; + this.index = (this.index + 1) % this.images.length; this.changed(); this.schedule(); - }, delays[this.index]); + }, delay); } close(): void { this.disposed = true; - this.running = false; - clearTimeout(this.timer); + this.pause(); + for (const image of this.images) image.close(); } invalidate(): void { for (const image of this.images) image.invalidate(); } - handleMouse( - event: TuiMouseEvent, - ): { handled: boolean; render: boolean } | undefined { - if (event.type !== "press" || event.button !== "left") return; - if (event.y === 0 && event.x < 14) { - if (this.running) this.pause(); - else this.play(); - } else if (event.y === this.scrubRow) { - const first = this.clip.frames[0].timestamp, - last = this.clip.frames.at(-1)!.timestamp; - this.seek( - first - - this.clip.timeOrigin + - Math.max( - 0, - Math.min(1, (event.x - 1) / Math.max(1, event.width - 3)), - ) * - (last - first), - ); - } else return; - return { handled: true, render: true }; - } render(width: number): string[] { - const frame = this.clip.frames[this.index], - time = frame.timestamp - this.clip.timeOrigin; - const header = - accent(this.running ? " ⏸ Pause" : " ▶ Play") + - muted( - ` ${time.toFixed(3)} s · frame ${frame.index + 1}/${this.clip.sourceCount} · ${this.clip.type}`, - ); - const lines = [ - truncateToWidth(header, width), - ...(this.images[this.index] && !this.error - ? this.images[this.index].render(width) - : new Text( - muted( - this.error - ? `Preview unavailable: ${this.error}` - : "Loading saved frames…", - ), - 0, - 0, - ).render(width)), - ]; - this.scrubRow = lines.length; - const count = Math.max(1, width - 2), - first = this.clip.frames[0].timestamp, - duration = this.clip.frames.at(-1)!.timestamp - first; - const head = Math.round( - (duration ? (frame.timestamp - first) / duration : 0) * (count - 1), - ); - lines.push( - truncateToWidth( - " " + - muted("━".repeat(head)) + - accent("●") + - muted("─".repeat(count - head - 1)), - width, - ), - ); - lines.push( - ...new Text( - muted( - " /play · /pause · /seek SECONDS · click timeline · source-timed playback", - ), - 0, - 0, - ).render(width), - ); - return lines; + return this.images[this.index] && !this.error + ? this.images[this.index].render(width) + : new Text( + muted( + this.error + ? `Preview unavailable: ${this.error}` + : "Loading saved frames…", + ), + 0, + 0, + ).render(width); } } diff --git a/agent/src/render.ts b/agent/src/render.ts index ab3923a11a9..2082d0d91c7 100644 --- a/agent/src/render.ts +++ b/agent/src/render.ts @@ -1,8 +1,9 @@ +import { TerminalImage as Image } from "./terminal-image.js"; import { basename, extname, resolve } from "node:path"; import { setTimeout as delay } from "node:timers/promises"; import type { ExtensionFactory } from "@earendil-works/pi-coding-agent"; import type { ImageContent, TextContent } from "@earendil-works/pi-ai"; -import { Container, Image, Text, type Component } from "@earendil-works/pi-tui"; +import { Container, Text, type Component } from "@earendil-works/pi-tui"; import { Type } from "typebox"; import { z } from "zod"; import sharp from "sharp"; @@ -38,6 +39,7 @@ export function imageComponent( data: string, mimeType: string, summary = "", + changed: () => void = () => {}, ): Component { const block = new Container(); if (summary) block.addChild(new Text(summary, 0, 0)); @@ -47,6 +49,7 @@ export function imageComponent( mimeType, { fallbackColor: (text) => text }, { maxHeightCells: 18, maxWidthCells: 80 }, + changed, ), ); return block; diff --git a/agent/src/session.ts b/agent/src/session.ts index b85a1a26e27..53388d92235 100644 --- a/agent/src/session.ts +++ b/agent/src/session.ts @@ -57,7 +57,7 @@ export async function openSession( ], appendSystemPrompt: [ "You are dimcode, Dimensional's coding and robotics agent. Use DimOS CLI/public Python APIs and the advertised MCP skills. Do not create a parallel runtime or transport service. Select the intended run and endpoint explicitly; never assume the latest run is the intended one. Render actual saved results with dimcode_render. Live previews are labeled context, not historical query results.", - "Visualize each meaningful sensor or memory operation with dimcode_render. Prefer supported types and existing DimOS visualizers; otherwise generate a self-contained SVG with inline Python from the already evaluated result and render that file. Label proposed ideas or overlays distinctly from observations. Use returned images as visual feedback when interpreting results. DimOS owns reusable visualization semantics; dimcode owns terminal-specific styling and interaction. Built-in views are only point clouds and images/SVGs. For a saved sequence, use the Dimensional skill’s finite frame index format; the model sees a contact sheet and can request an original frame index for closer inspection. Never imply a contact sheet covers every frame.", + "Visualize each meaningful sensor or memory operation with dimcode_render. Prefer supported types and existing DimOS visualizers; otherwise generate a self-contained SVG with inline Python from the already evaluated result and render that file. Label proposed ideas or overlays distinctly from observations. Use returned images as visual feedback when interpreting results. DimOS owns reusable visualization semantics; dimcode owns terminal-specific styling and interaction. Built-in views are only point clouds and images/SVGs. For a saved sequence, use the Dimensional skill’s finite frame index format; the model sees a contact sheet and can request an original frame index for closer inspection. Clips loop inline without playback controls. If the user asks for a still at a timestamp, select the original frame from the saved index and render it. Never imply a contact sheet covers every frame.", "Selected configuration: " + JSON.stringify(config), ], }, diff --git a/agent/src/spatial.ts b/agent/src/spatial.ts index 16346036175..ca8b8efbae0 100644 --- a/agent/src/spatial.ts +++ b/agent/src/spatial.ts @@ -1,7 +1,7 @@ +import { TerminalImage as Image } from "./terminal-image.js"; import { basename } from "node:path"; import { getCapabilities, - Image, matchesKey, Text, truncateToWidth, @@ -57,6 +57,7 @@ export class SpatialView implements Component { this.disposed = true; this.revision++; clearInterval(this.animation); + this.image?.close(); } private retained(i: number): boolean { return this.selected.has(i); @@ -69,6 +70,7 @@ export class SpatialView implements Component { this.zoom, ); if (this.disposed || revision !== this.revision) return; + this.image?.close(); this.image = new Image( png.toString("base64"), "image/png", @@ -77,6 +79,7 @@ export class SpatialView implements Component { maxHeightCells: this.expanded ? 30 : 20, maxWidthCells: this.expanded ? 150 : 100, }, + this.changed, ); this.changed(); } @@ -106,6 +109,7 @@ export class SpatialView implements Component { else if (key === "-") this.zoom = Math.max(0.25, this.zoom / 1.2); else if (key === "g") { this.graphics = !this.graphics; + this.image?.close(); this.image = undefined; } else if (key === "d") this.details = !this.details; else if (key === "0") { @@ -117,6 +121,7 @@ export class SpatialView implements Component { } setExpanded(value: boolean): void { this.expanded = value; + this.image?.close(); this.image = undefined; this.refresh(); } diff --git a/agent/src/terminal-image.ts b/agent/src/terminal-image.ts new file mode 100644 index 00000000000..c0b020b9acf --- /dev/null +++ b/agent/src/terminal-image.ts @@ -0,0 +1,128 @@ +import { + Image, + getCapabilities, + getCellDimensions, + getImageDimensions, + truncateToWidth, + type ImageDimensions, + type Component, + type ImageOptions, + type ImageTheme, +} from "@earendil-works/pi-tui"; +import { calculateImageCellSize } from "@earendil-works/pi-tui/dist/terminal-image.js"; +import sharp from "sharp"; + +export function needsImageSlices(env = process.env): boolean { + return ( + env.TERM_PROGRAM?.toLowerCase() === "warpterminal" || + !!(env.WARP_SESSION_ID || env.WARP_TERMINAL_SESSION_UUID) + ); +} + +/** Warp ignores source-rectangle cropping. Whole cell-row images let Pi scroll by clipping rows. */ +export class TerminalImage implements Component { + private readonly fallback: Image; + private readonly dimensions: ImageDimensions | null; + private slices?: Image[]; + private key?: string; + private disposed = false; + private failed = false; + constructor( + private readonly data: string, + mimeType: string, + private readonly theme: ImageTheme, + private readonly options: ImageOptions, + private readonly changed: () => void, + ) { + this.fallback = new Image(data, mimeType, theme, options); + this.dimensions = getImageDimensions(data, mimeType); + } + close(): void { + this.disposed = true; + this.slices = undefined; + } + invalidate(): void { + this.fallback.invalidate(); + for (const slice of this.slices ?? []) slice.invalidate(); + } + private async prepare( + key: string, + columns: number, + rows: number, + ): Promise { + const cell = getCellDimensions(); + const width = Math.round(columns * cell.widthPx), + rowHeight = Math.round(cell.heightPx); + const pixels = await sharp(Buffer.from(this.data, "base64")) + .resize(width, rows * rowHeight, { + fit: "contain", + background: "#101b21", + }) + .flatten({ background: "#101b21" }) + .removeAlpha() + .toColourspace("srgb") + .raw() + .toBuffer(); + const slices = await Promise.all( + Array.from({ length: rows }, async (_, i) => { + const start = i * width * rowHeight * 3; + const png = await sharp( + pixels.subarray(start, start + width * rowHeight * 3), + { + raw: { width, height: rowHeight, channels: 3 }, + }, + ) + .png() + .toBuffer(); + return new Image(png.toString("base64"), "image/png", this.theme, { + maxWidthCells: columns, + maxHeightCells: 1, + }); + }), + ); + if (this.disposed || key !== this.key) return; + this.slices = slices; + this.changed(); + } + render(width: number): string[] { + if (!needsImageSlices() || getCapabilities().images !== "kitty") + return this.fallback.render(width); + const dimensions = this.dimensions; + if (!dimensions) return this.fallback.render(width); + const cell = getCellDimensions(); + const maxWidth = Math.max( + 1, + Math.min(width - 2, this.options.maxWidthCells ?? 60), + ); + const size = calculateImageCellSize( + dimensions, + maxWidth, + this.options.maxHeightCells ?? + Math.max(1, Math.ceil((maxWidth * cell.widthPx) / cell.heightPx)), + cell, + ); + const key = `${size.columns}:${size.rows}:${cell.widthPx}:${cell.heightPx}`; + if (key !== this.key && !this.disposed) { + this.key = key; + this.slices = undefined; + this.failed = false; + void this.prepare(key, size.columns, size.rows).catch(() => { + if (!this.disposed && key === this.key) { + this.failed = true; + this.changed(); + } + }); + } + return ( + this.slices?.map((slice) => slice.render(size.columns + 2)[0]) ?? + Array.from({ length: size.rows }, (_, i) => + i === 0 && this.failed + ? truncateToWidth( + this.theme.fallbackColor("Image preview unavailable"), + width, + ) + : "", + ) + ); + } +} diff --git a/agent/src/terminal.ts b/agent/src/terminal.ts index 4cdd972228d..ac35656de1f 100644 --- a/agent/src/terminal.ts +++ b/agent/src/terminal.ts @@ -32,6 +32,7 @@ import { PromptEditor, commands } from "./input.js"; import { SpatialView, readCloud } from "./spatial.js"; import { paths } from "./config.js"; import { Playback } from "./playback.js"; +import { needsImageSlices } from "./terminal-image.js"; import { ResultImages } from "./tool-images.js"; import { accent, clean, muted, StatusLine } from "./terminal-style.js"; import type { Slot } from "@dimos/sdk"; @@ -174,7 +175,7 @@ export async function terminal( toolName, id, args, - { showImages: true }, + { showImages: !needsImageSlices() }, definitions.find((tool) => tool.name === toolName), tui, cwd, @@ -239,7 +240,7 @@ export async function terminal( card.playback = new Playback( details.data.clip, () => tui.requestRender(), - !restoring, + true, ); lastPlayback = card.playback; } @@ -274,6 +275,8 @@ export async function terminal( card.block.addChild( new Text(muted(" " + clean(summary.slice(0, 220))), 0, 0), ); + } + if ((!builtin && !card.expanded) || needsImageSlices()) { if (card.playback) card.block.addChild(card.playback); else if (card.spatial) card.block.addChild(card.spatial); else if (card.images) card.block.addChild(card.images); @@ -341,6 +344,9 @@ export async function terminal( bytes.toString("base64"), "image/png", "LIVE · " + live.channel + " · seq " + slot.seq, + () => { + if (!disposed) tui.requestRender(); + }, ), ); tui.requestRender(); @@ -601,9 +607,6 @@ export async function terminal( add( "/panel N: result view (0 overview) · /inspect PATH: saved result · /view: rotate/zoom · /expand: latest tool details", ); - add( - "/play · /pause · /seek SECONDS: latest saved clip (click the timeline to scrub)", - ); return; } if (value === "/sessions") { @@ -641,7 +644,6 @@ export async function terminal( if (card) { const [id, tool] = card; tool.expanded = !tool.expanded; - if (tool.expanded) tool.playback?.pause(); draw(id, tool.output, tool.toolName, tool.args); } return; @@ -665,27 +667,6 @@ export async function terminal( lastImages.select(index); return; } - if ( - value === "/play" || - value === "/pause" || - value.startsWith("/seek ") - ) { - if (!lastPlayback) - throw new Error( - "No saved clip yet. Use /inspect INDEX.json or ask the agent to render a sequence.", - ); - if (value === "/play") lastPlayback.play(); - else if (value === "/pause") lastPlayback.pause(); - else { - const seconds = Number(value.slice(6).trim()); - if (!Number.isFinite(seconds)) - throw new Error( - "Use /seek SECONDS relative to the recording origin.", - ); - lastPlayback.seek(seconds); - } - return; - } if (value.startsWith("/inspect ")) { const path = value.slice(9).trim(); localRender?.abort(); diff --git a/agent/src/tool-images.ts b/agent/src/tool-images.ts index 4b9b2e58310..e1c122681d1 100644 --- a/agent/src/tool-images.ts +++ b/agent/src/tool-images.ts @@ -1,5 +1,5 @@ +import { TerminalImage as Image } from "./terminal-image.js"; import { - Image, Text, truncateToWidth, visibleWidth, @@ -36,6 +36,8 @@ export class ResultImages implements Component { } close(): void { this.disposed = true; + this.overview?.close(); + for (const panel of this.panels) panel.close(); } invalidate(): void { this.overview?.invalidate(); @@ -51,6 +53,7 @@ export class ResultImages implements Component { "image/png", { fallbackColor: muted }, { maxWidthCells: 120, maxHeightCells: 26 }, + this.changed, ); } private async prepare(): Promise { diff --git a/agent/test/clips.test.ts b/agent/test/clips.test.ts index 862273d451e..a4ff179c29a 100644 --- a/agent/test/clips.test.ts +++ b/agent/test/clips.test.ts @@ -53,7 +53,7 @@ test("preview timing is bounded and retains source indexes; projection uses shar assert.equal(camera.height, 3); }); -test("saved image/SVG clips preserve colors, timestamps and hashes; playback seeks, ends and disposes", async (t) => { +test("saved image/SVG clips preserve colors, timestamps and hashes; playback loops without controls and disposes", async (t) => { const dir = await mkdtemp(join(tmpdir(), "dimcode-clips-")); t.after(() => rm(dir, { recursive: true, force: true })); const p = paths({ XDG_CACHE_HOME: dir }); @@ -123,37 +123,29 @@ test("saved image/SVG clips preserve colors, timestamps and hashes; playback see }); t.after(() => view.close()); await view.ready; - view.seek(1.12); - assert.equal(view.frameIndex, 1); - assert.equal(view.playing, false); for (const width of [1, 20, 80]) assert(view.render(width).every((line) => visibleWidth(line) <= width)); - view.seek(-100); - assert.equal(view.frameIndex, 0); - view.seek(100); - assert.equal(view.frameIndex, 2); - const ended = new Promise((resolve, reject) => { + assert.doesNotMatch(view.render(80).join("\n"), /Play|Pause|seek|timeline/); + const looped = new Promise((resolve, reject) => { const timeout = setTimeout( - () => reject(new Error("Playback did not finish")), + () => reject(new Error("Playback did not loop")), 1500, ); + let reachedLast = false; onChange = () => { - if (view.frameIndex === 2 && !view.playing) { + reachedLast ||= view.frameIndex === 2; + if (reachedLast && view.frameIndex === 0) { clearTimeout(timeout); resolve(); } }; }); - view.play(); - assert.equal(view.frameIndex, 0, "play at the end restarts"); - await ended; - view.play(); + await looped; + assert.equal(view.playing, true, "clips loop without controls"); view.pause(); assert.equal(view.playing, false); view.close(); const closed = updates; - view.play(); - view.seek(1); view.pause(); assert.equal(updates, closed, "disposed views cannot notify or restart"); const pausedWhileLoading = new Playback(clip, () => {}, true); diff --git a/agent/test/scroll-images.test.ts b/agent/test/scroll-images.test.ts new file mode 100644 index 00000000000..30596b3deda --- /dev/null +++ b/agent/test/scroll-images.test.ts @@ -0,0 +1,96 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { setTimeout as delay } from "node:timers/promises"; +import { + ScrollView, + setCapabilities, + setCellDimensions, +} from "@earendil-works/pi-tui"; +import { renderLayoutFrame } from "@earendil-works/pi-tui/dist/layout.js"; +import sharp from "sharp"; +import { TerminalImage } from "../src/terminal-image.js"; + +// Decode the actual PNG transmissions as a terminal that ignores source-crop controls would. +function transmittedPng(line: string): Buffer { + const commands = [...line.matchAll(/\x1b_G([^;]*);([^\x1b]*)\x1b\\/g)]; + assert(commands.length); + assert.match(commands[0][1], /(?:^|,)r=1(?:,|$)/); + assert.doesNotMatch(commands[0][1], /(?:^|,)[yhw]=/); + return Buffer.from(commands.map((command) => command[2]).join(""), "base64"); +} + +test("Warp scrolling clips exact pixel rows instead of squeezing the original image", async (t) => { + const previous = process.env.TERM_PROGRAM; + process.env.TERM_PROGRAM = "WarpTerminal"; + t.after(() => { + if (previous === undefined) delete process.env.TERM_PROGRAM; + else process.env.TERM_PROGRAM = previous; + }); + setCapabilities({ images: "kitty", trueColor: true, hyperlinks: false }); + setCellDimensions({ widthPx: 10, heightPx: 20 }); + const png = await sharp( + Buffer.from( + '', + ), + ) + .removeAlpha() + .png() + .toBuffer(); + let updates = 0; + const image = new TerminalImage( + png.toString("base64"), + "image/png", + { fallbackColor: (text) => text }, + { maxWidthCells: 90, maxHeightCells: 25 }, + () => updates++, + ); + t.after(() => image.close()); + const ready = async (width: number) => { + const before = updates; + image.render(width); + const deadline = Date.now() + 3000; + while (updates === before && Date.now() < deadline) await delay(10); + assert(updates > before, "image preparation redraws the terminal"); + }; + await ready(92); + const scroll = new ScrollView(image, { follow: "none", scrollbar: "hidden" }); + renderLayoutFrame(scroll, 92, 7, () => {}); + for (const row of [0, 4, 11, 18, 0]) { + scroll.scrollTo(row); + const frame = renderLayoutFrame(scroll, 92, 7, () => {}); + assert.equal(frame.lines.length, 7); + const visible = Buffer.concat( + await Promise.all( + frame.lines.map(async (line) => { + const bytes = transmittedPng(line); + const metadata = await sharp(bytes).metadata(); + assert.equal(metadata.width, 900); + assert.equal(metadata.height, 20); + return sharp(bytes).raw().toBuffer(); + }), + ), + ); + const expected = await sharp(png) + .extract({ left: 0, top: row * 20, width: 900, height: 140 }) + .raw() + .toBuffer(); + assert.deepEqual( + visible, + expected, + "scrolling changes only which source rows are visible", + ); + } + await ready(47); + const narrow = image.render(47); + assert.equal(narrow.length, 13); + assert.equal((await sharp(transmittedPng(narrow[0])).metadata()).width, 450); + image.render(70); + image.close(); + const closed = updates; + await delay(50); + assert.equal( + updates, + closed, + "resizing then closing cannot redraw a disposed view", + ); +}); From 9058d556ded867c1545e29bcc055a9bcb8b6396f Mon Sep 17 00:00:00 2001 From: spomichter Date: Sat, 12 Sep 2026 13:55:50 -0700 Subject: [PATCH 8/9] feat(dimcode): bootstrap agent-led setup and npm prereleases --- .github/workflows/dimcode-release.yml | 69 +++++++++ .github/workflows/dimcode.yml | 2 +- agent/.gitattributes | 1 + agent/LICENSE | 17 +++ agent/README.md | 48 +++--- agent/install.sh | 85 +++++++++++ ...{package-lock.json => npm-shrinkwrap.json} | 8 +- agent/package.json | 8 +- agent/skills/dimensional-install/SKILL.md | 18 +++ agent/skills/dimensional/SKILL.md | 2 +- agent/src/input.ts | 10 +- agent/src/install.ts | 28 ---- agent/src/main.ts | 38 +++-- agent/src/session.ts | 18 ++- agent/src/setup.ts | 100 ++----------- agent/src/terminal.ts | 14 +- agent/test/cli.test.ts | 139 +++++++++++++++++- agent/test/setup.test.ts | 33 ++++- pyproject.toml | 1 + 19 files changed, 462 insertions(+), 177 deletions(-) create mode 100644 .github/workflows/dimcode-release.yml create mode 100644 agent/.gitattributes create mode 100644 agent/LICENSE create mode 100644 agent/install.sh rename agent/{package-lock.json => npm-shrinkwrap.json} (99%) create mode 100644 agent/skills/dimensional-install/SKILL.md delete mode 100644 agent/src/install.ts diff --git a/.github/workflows/dimcode-release.yml b/.github/workflows/dimcode-release.yml new file mode 100644 index 00000000000..a3d3e9a7e65 --- /dev/null +++ b/.github/workflows/dimcode-release.yml @@ -0,0 +1,69 @@ +name: dimcode prerelease + +on: + push: + tags: ['dimcode-v*-next.*'] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: dimcode-prerelease + cancel-in-progress: false + +jobs: + check: + strategy: + matrix: + os: [ubuntu-latest, macos-15] + runs-on: ${{ matrix.os }} + defaults: + run: + working-directory: agent + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v5 + with: + node-version: '24' + cache: npm + cache-dependency-path: agent/npm-shrinkwrap.json + - run: npm ci + - run: npm run check + - run: sh -n install.sh + + publish: + needs: check + runs-on: ubuntu-latest + permissions: + contents: write + id-token: write + defaults: + run: + working-directory: agent + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v5 + with: + node-version: '24' + registry-url: https://registry.npmjs.org + - name: Validate release identity + run: | + node --input-type=module -e ' + import fs from "node:fs"; + const p = JSON.parse(fs.readFileSync("package.json")); + if (p.name !== "@spomichter/dimcode" || !/^\d+\.\d+\.\d+-next\.\d+$/.test(p.version) || process.env.GITHUB_REF !== `refs/tags/dimcode-v${p.version}`) process.exit(1); + ' + - run: npm ci && npm run build + - run: npm pack + - name: Publish npm next + run: npm publish ./spomichter-dimcode-*.tgz --tag next --access public --provenance + env: + NODE_AUTH_TOKEN: ${{ secrets.DIMCODE_NPM_TOKEN }} + - name: Publish installer and package + run: | + gh release create "$GITHUB_REF_NAME" ./spomichter-dimcode-*.tgz install.sh \ + --title "dimcode ${GITHUB_REF_NAME#dimcode-v}" --prerelease \ + --notes 'Dimcode test release: private Node bootstrap, agent-led DimOS installation, persistent Pi sessions and inline memory visualization. Linux x64/arm64 and Apple Silicon installer; Linux and macOS package checks run before publication.' + env: + GH_TOKEN: ${{ github.token }} diff --git a/.github/workflows/dimcode.yml b/.github/workflows/dimcode.yml index 84ae1c45cd4..db0e330c8b0 100644 --- a/.github/workflows/dimcode.yml +++ b/.github/workflows/dimcode.yml @@ -36,7 +36,7 @@ jobs: with: node-version: 24 cache: npm - cache-dependency-path: agent/package-lock.json + cache-dependency-path: agent/npm-shrinkwrap.json - uses: denoland/setup-deno@v2 with: deno-version: v2.6.10 diff --git a/agent/.gitattributes b/agent/.gitattributes new file mode 100644 index 00000000000..300cb12cb93 --- /dev/null +++ b/agent/.gitattributes @@ -0,0 +1 @@ +npm-shrinkwrap.json -diff linguist-generated diff --git a/agent/LICENSE b/agent/LICENSE new file mode 100644 index 00000000000..5e2927e3adb --- /dev/null +++ b/agent/LICENSE @@ -0,0 +1,17 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + Copyright 2025 Dimensional Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/agent/README.md b/agent/README.md index 752e8c0c3a6..caf4fa2911d 100644 --- a/agent/README.md +++ b/agent/README.md @@ -4,13 +4,25 @@ Persistent terminal agent for DimOS, built on Pi. The gateway runs independently ## Install -The local executable is ready to use: +Test release: **`@spomichter/dimcode@next`**. Install on Linux x64/arm64 or Apple Silicon: ```sh +curl -fsSL https://github.com/dimensionalOS/dimos/releases/download/dimcode-v0.1.0-next.0/install.sh | sh +``` + +The installer downloads and checks a private Node 24 runtime, installs the npm package and creates `~/.local/bin/dimcode`. It offers to open setup immediately. No system Node upgrade, sudo, source checkout or build commands are needed. If `~/.local/bin` is not on your PATH, use the absolute launcher path printed by the installer. + +With Node **24 or 26** already installed, npm also works: + +```sh +npm install -g @spomichter/dimcode@next dimcode setup -dimcode ``` +`setup` signs you in, selects a model and asks whether to start the gateway at login, then opens **agent-led DimOS setup** in the terminal. Drop a checkout/environment path or describe what you want to build. The agent reads DimOS's own installation instructions and uses its ordinary coding tools to inspect, install, verify and connect it. There is no hard-coded DimOS installer in dimcode. + +Run `dimcode` to launch again. Existing credentials, recordings and sessions are preserved. The shell installer itself starts no daemon or blueprint; setup offers the optional Linux user service. Windows, Homebrew and WinGet distribution are not part of this prerelease. + `dimcode` opens the fullscreen Pi-based terminal: scrollable chat, a multiline editor with command/file completion, compact tool output, and a persistent model/status footer. Shift+Enter adds a line; Ctrl-C detaches from the running gateway. Memory results appear **inside the agent's tool card**, with an overview combining DimOS's timeline, spatial map and selected frames. Click a panel label or enter `/panel 1`, `/panel 2`, etc. to see a larger individual view; `/panel 0` returns to the overview. `/expand` shows the latest tool's full output and source hashes. Use a terminal with image support (such as Kitty) for the SVG/PNG views; other terminals show image metadata. @@ -26,24 +38,6 @@ Cloud exports use `{ "points": [[x, y, z]], "frame": "world", "timestamp": 123.4 Point-cloud results from `dimcode_render` open the same inspector through `/view`. The terminal verifies the source hash before loading interactive geometry; if the file changed, the original tool preview remains available. `/expand` toggles details for the latest tool. Images use supported terminal graphics; Braille supports point-cloud inspection without image support. -`dimcode setup` walks through: - -1. Provider and authentication: paste an API key into a masked field, use existing credentials, or sign in with a ChatGPT subscription. -2. Model selection. -3. Workspace directory. -4. DimOS: choose an existing installation, create a new environment, or connect later. -5. An existing DimOS MCP endpoint, if available. -6. Daemon setup: select **Start at login** to install and enable the systemd user service, or start the gateway when you open dimcode. - -To install the packaged local build on another machine, use Node **24 or 26** and the supplied tarball: - -```sh -npm install -g ./dimensional-dimcode-0.1.0.tgz -dimcode -``` - -This prerelease is distributed locally; it is not published to npm yet. No source build or test commands are needed to install the tarball. - A fresh `dimcode` launch opens setup automatically. Repeating `dimcode setup` lets you change the choices; configured providers offer **Use configured credentials** so you do not need to paste a key again. Credentials never enter chat. ```sh @@ -52,13 +46,13 @@ dimcode tui # open the terminal (same as dimcode) dimcode --help # all launch commands ``` -The setup can create a new DimOS environment using `uv` (Python 3.12), or select a `dimos` executable from a pip environment or editable checkout. Install [uv](https://docs.astral.sh/uv/getting-started/installation/) first if you choose a new environment. Use DimOS's installation instructions for robot extras and system packages. The harness can run before DimOS is installed. +The bundled [`dimensional-install` skill](skills/dimensional-install/SKILL.md) handles DimOS onboarding. Existing environments and editable checkouts are inspected and reused; a new install follows the selected version's README and OS-specific instructions, including its Python requirements and requested extras. Commands and failures appear in normal tool cards. The resulting conversation can be detached and resumed like any session. Agent-led setup needs a working model; `setup --provider NAME --key-env VAR` configures credentials without launching a model or installing DimOS. ## What is installed / where is the source? This package uses **upstream Pi 0.85.1**, pinned as npm dependencies. It is not a Pi fork or a native binary. `dimcode` is a Node CLI with a Dimensional gateway, terminal frontend and extensions. Pi supplies the agent runtime, provider login, coding tools, message components and tool cards. A fork is unnecessary for these customizations. -All source is in this repository's [`agent/src/`](src): [`main.ts`](src/main.ts) launches the CLI; [`setup.ts`](src/setup.ts) handles onboarding; [`terminal.ts`](src/terminal.ts) renders chat; [`gateway.ts`](src/gateway.ts) owns persistent sessions; [`media.ts`](src/media.ts) lazily connects the existing Web SDK. [`skills/dimensional`](skills/dimensional) supplies DimOS instructions. +All source is in this repository's [`agent/src/`](src): [`main.ts`](src/main.ts) launches the CLI; [`setup.ts`](src/setup.ts) handles private bootstrap; [`skills/dimensional-install`](skills/dimensional-install) guides agent-led installation; [`terminal.ts`](src/terminal.ts) renders chat; [`gateway.ts`](src/gateway.ts) owns persistent sessions; [`media.ts`](src/media.ts) lazily connects the existing Web SDK. [`skills/dimensional`](skills/dimensional) supplies DimOS instructions. ## Authentication and service @@ -79,6 +73,7 @@ Config/auth live in `$XDG_CONFIG_HOME/dimcode` (or `~/.config/dimcode`), session ```sh dimcode connect go2 http://127.0.0.1:9990/mcp dimcode relay http://127.0.0.1:7780 my-robot +dimcode workspace /path/to/app # save the default workspace dimcode --cwd /path/to/app dimcode sessions dimcode --session SESSION_ID @@ -148,10 +143,15 @@ These commands are for working on the source, not the user installation: cd agent npm ci npm run check -npm link +node dist/main.js setup +node dist/main.js ``` -`npm pack` produces the distributable tarball; `npm publish --access public` requires access to the `@dimensional` npm scope. Building alone does not publish it. +These commands run the source build without installing the `dimcode` command. `npm pack` produces the distributable tarball. `npm-shrinkwrap.json` pins the published dependency tree. + +Prereleases are published by [dimcode-release.yml](../.github/workflows/dimcode-release.yml) from `dimcode-vVERSION` tags matching the package's `-next.N` version. The workflow checks Linux/macOS, publishes to npm's `next` tag using `DIMCODE_NPM_TOKEN`, then creates a GitHub prerelease with the tarball and installer. Ordinary branch pushes do not publish. The personal npm scope is for this test release. + +Installer options: `DIMCODE_VERSION` selects a version/tag (default `next`), `DIMCODE_INSTALL_DIR` selects private runtime/releases, `DIMCODE_BIN_DIR` selects the launcher directory, and `DIMCODE_SETUP=0` skips the setup offer. Downloads are verified before extraction. Run the installer again to update the launcher; older releases remain available to running sessions. Existing gateways continue using their current release until restarted with `dimcode service install` after active turns finish. To run the complete integration suite with an existing DimOS environment and Go2 recording: diff --git a/agent/install.sh b/agent/install.sh new file mode 100644 index 00000000000..c40541c4ce4 --- /dev/null +++ b/agent/install.sh @@ -0,0 +1,85 @@ +#!/bin/sh +# Installs the harness only. DimOS installation belongs to the agent's skill. +set -eu + +main() { + package='@spomichter/dimcode' + version=${DIMCODE_VERSION:-next} + data=${DIMCODE_INSTALL_DIR:-${XDG_DATA_HOME:-$HOME/.local/share}/dimcode} + bin=${DIMCODE_BIN_DIR:-$HOME/.local/bin} + case "$data:$bin" in /*:/*) ;; *) echo 'Install directories must be absolute.' >&2; exit 1 ;; esac + case "$(uname -s)-$(uname -m)" in + Linux-x86_64) platform=linux-x64 ;; + Linux-aarch64|Linux-arm64) platform=linux-arm64 ;; + Darwin-arm64) platform=darwin-arm64 ;; + *) echo 'This prerelease supports Linux x64/arm64 and Apple Silicon.' >&2; exit 1 ;; + esac + case "$version" in ''|*[!a-zA-Z0-9._-]*) echo 'Invalid DIMCODE_VERSION.' >&2; exit 1 ;; esac + command -v curl >/dev/null || { echo 'curl is required.' >&2; exit 1; } + mkdir -p "$data" "$bin" + staging=$(mktemp -d "$data/.install.XXXXXX") + trap 'rm -rf "$staging"' EXIT + trap 'exit 130' INT + trap 'exit 143' TERM + printf '\ndimcode · Install your Dimensional agent\n\n' + + node="$data/node/bin/node" + if ! "$node" -e 'process.exit(+process.versions.node.split(".")[0] === 24 ? 0 : 1)' 2>/dev/null; then + dist=https://nodejs.org/dist/latest-v24.x + printf 'Installing a private Node 24 runtime…\n' + curl -fsSL "$dist/SHASUMS256.txt" -o "$staging/checksums" + archive=$(awk -v suffix="-$platform.tar.gz" '$2 ~ /^node-v[0-9.]+-/ && substr($2,length($2)-length(suffix)+1)==suffix {print $2; exit}' "$staging/checksums") + [ -n "$archive" ] || { echo 'No compatible Node archive found.' >&2; exit 1; } + curl -fsSL "$dist/$archive" -o "$staging/$archive" + awk -v file="$archive" '$2==file {print}' "$staging/checksums" > "$staging/selected.sha256" + if command -v sha256sum >/dev/null 2>&1; then + (cd "$staging" && sha256sum -c selected.sha256) + elif command -v shasum >/dev/null 2>&1; then + (cd "$staging" && shasum -a 256 -c selected.sha256) + else + echo 'sha256sum or shasum is required to verify Node.' >&2; exit 1 + fi + tar -xzf "$staging/$archive" -C "$staging" + if [ -e "$data/node" ] || [ -L "$data/node" ]; then + echo "Existing runtime at $data/node is unusable; choose another DIMCODE_INSTALL_DIR." >&2 + exit 1 + fi + mv "$staging/${archive%.tar.gz}" "$data/node" + fi + PATH="$data/node/bin:$PATH" + export PATH + npm="$data/node/bin/npm" + resolved=$("$npm" view "$package@$version" version) + case "$resolved" in ''|*[!a-zA-Z0-9._-]*) echo 'Invalid package version from registry.' >&2; exit 1 ;; esac + release="$data/releases/$resolved" + entry="$release/node_modules/$package/dist/main.js" + if [ ! -f "$entry" ]; then + printf 'Installing %s@%s…\n' "$package" "$resolved" + "$npm" install --prefix "$staging/package" --ignore-scripts --min-release-age=0 --omit=dev --no-audit --no-fund "$package@$resolved" + "$node" "$staging/package/node_modules/$package/dist/main.js" --help >/dev/null + mkdir -p "$data/releases" + [ ! -e "$release" ] || { echo "Incomplete release at $release; choose another DIMCODE_INSTALL_DIR." >&2; exit 1; } + mv "$staging/package" "$release" + fi + "$node" - "$bin/dimcode" "$node" "$entry" <<'NODE' +const fs = require('node:fs'); +const [dest, node, entry] = process.argv.slice(2); +const quote = (value) => "'" + value.replaceAll("'", "'\\''") + "'"; +const temporary = `${dest}.install-${process.pid}`; +fs.writeFileSync(temporary, `#!/bin/sh\nexec ${quote(node)} ${quote(entry)} "$@"\n`, { mode: 0o755, flag: 'wx' }); +fs.renameSync(temporary, dest); +NODE + printf '\nInstalled %s@%s\n' "$package" "$resolved" + case ":${PATH}:" in + *":$bin:"*) printf 'Run: dimcode setup\nThen: dimcode\n' ;; + *) printf 'Run: %s/dimcode setup\nAdd %s to your shell PATH to use the short command.\n' "$bin" "$bin" ;; + esac + printf 'No daemon or robot was started. Setup asks about gateway startup.\n' + if [ "${DIMCODE_SETUP:-1}" = 1 ] && ( : /dev/null; then + printf '\nOpen interactive setup now? [Y/n] ' >/dev/tty + IFS= read -r answer =24 <25 || >=26 <27" diff --git a/agent/skills/dimensional-install/SKILL.md b/agent/skills/dimensional-install/SKILL.md new file mode 100644 index 00000000000..12a13a57a32 --- /dev/null +++ b/agent/skills/dimensional-install/SKILL.md @@ -0,0 +1,18 @@ +--- +name: dimensional-install +description: Set up DimOS from an existing checkout/environment or a new installation, following its own version-specific instructions with the normal coding tools. +--- + +Start with one question: drop a path to an existing DimOS checkout/environment, or describe the robot/app you want to build. Wait for that answer. Do not assume the current directory needs a new `.venv`. + +Use the existing Read and Bash tools. There is no harness-owned DimOS install recipe. + +1. Inspect the supplied path, OS, available Python/package tools and existing DimOS installations. Distinguish a checkout, Python environment, app workspace and remote MCP endpoint. Reuse a working installation; when several match, ask which one to use. +2. Read that checkout/version's `README.md`, `pyproject.toml` and linked installation instructions. For a new package install, retrieve the official DimensionalOS/dimos README and linked OS-specific instructions from GitHub, selecting a release/ref with the user when needed. Report the source/ref you used. Do not invent commands if the instructions cannot be retrieved. Python requirements, extras and system dependencies come from those instructions, not this skill. +3. Explain the selected destination and required changes briefly, then execute the documented commands for the user's requested setup. Show command progress in normal tool cards, inspect failures and resolve them. Never replace an existing environment, install every robot extra by default, or start physical hardware as an install check. System changes needing elevated privileges require the user's involvement. +4. Verify the selected executable, `importlib.metadata.version("dimos")`, imported module path, CLI help and blueprint discovery. Run package checks from outside the checkout so the current directory cannot shadow the installed package. Distinguish installed distribution metadata from a checkout version string. Inspect `dimos status` when supported; distinguish installed software from a running blueprint. Only start an explicitly requested replay/simulation or hardware target. Do not claim installation succeeded from package-manager output alone. +5. Save verified selections through the existing CLI: `"$DIMCODE_NODE" "$DIMCODE_CLI" dimos /absolute/bin/dimos`, `python /absolute/bin/python`, and `workspace /absolute/app`. The launcher variables work for source builds without a global `dimcode` command. Use absolute paths in the current session; the saved workspace applies to subsequent launches. Configure `connect NAME URL` / `relay URL [ROBOT]` only for endpoints the user selected; use `/reload` after the turn to discover changed MCP skills. + +Provider credentials and the gateway-at-login choice belong to the bootstrap UI. Never request API keys in chat, read auth files, or dump environment variables. Use `/login` for model authentication. Do not stop/restart the gateway hosting this conversation from a Bash tool. If the user wants to change daemon startup later, give the exact `service install/status/uninstall` launcher command to run after detaching. + +Finish with the selected paths, verified version, available blueprint result, what is actually running, and any remaining dependency or connection issue. Setup is a normal persistent Pi conversation: the user can ask questions, correct the plan, detach or resume. diff --git a/agent/skills/dimensional/SKILL.md b/agent/skills/dimensional/SKILL.md index a021a4f70e1..50ed3f952e8 100644 --- a/agent/skills/dimensional/SKILL.md +++ b/agent/skills/dimensional/SKILL.md @@ -5,7 +5,7 @@ description: Install DimOS, build and operate apps/blueprints, invoke deployed s Use the selected DimOS executable/Python environment and workspace from the session instructions. Check installed help when versions differ. Never replace an existing environment silently. -- Installation: use `dimcode install-dimos NEW_VENV` for the base package, or the existing DimOS installation recipe for required robot extras. For a checkout use its documented uv setup. Harness setup and model auth are independent of DimOS installation. +- Installation: read the dimensional-install skill, inspect the selected environment and follow that DimOS version's own installation instructions using normal coding tools. Harness setup and model auth are independent of DimOS installation. - Apps: `dimos list`, `dimos show-config`, `dimos bake`, source editing, and external package entry points in the `dimos.blueprints` group. Run external apps by the qualified `distribution-name.blueprint-name` shown by `dimos list`; bare names select built-ins. Do not edit the built-in registry for an external app. - Runtime: `dimos run BLUEPRINT --daemon`; inspect `dimos status --run ID`, `dimos log --run ID`, `dimos stop --run ID`, `dimos restart --run ID`. Older versions may lack exact targeting: use the existing public run_registry APIs, never silently act on the latest run instead. - Python-launched coordinators: `Dimos.connect()` and its public module/RPC inspection APIs; `stop()` on that connection disconnects only. Use separate configured transport buses/endpoints for independent coordinators. diff --git a/agent/src/input.ts b/agent/src/input.ts index 83995098dc9..96c30e71650 100644 --- a/agent/src/input.ts +++ b/agent/src/input.ts @@ -76,14 +76,14 @@ export class PromptEditor extends Editor { } export class ChatInput implements Component { - private editor = new Input({ - prompt: "dimcode › ", - placeholder: "Ask about your robot or app…", - }); + private editor: Input; private masked = false; focused = false; onSubmit?: (value: string) => void; onEscape?: () => void; + constructor(private placeholder = "") { + this.editor = new Input({ prompt: "› ", placeholder }); + } get secret(): boolean { return this.masked; } @@ -91,7 +91,7 @@ export class ChatInput implements Component { if (value === this.masked) return; this.masked = value; // Discard the credential editor and its undo/kill-ring history on mode change. - this.editor = new Input({ prompt: "dimcode › " }); + this.editor = new Input({ prompt: "› ", placeholder: this.placeholder }); } getValue(): string { return this.editor.getValue(); diff --git a/agent/src/install.ts b/agent/src/install.ts deleted file mode 100644 index 57ca79bb9e2..00000000000 --- a/agent/src/install.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { access } from "node:fs/promises"; -import { join, resolve } from "node:path"; -import { saveConfig, type Config, type Paths } from "./config.js"; -import { execute } from "./service.js"; - -export async function installDimos( - destination: string, - config: Config, - paths: Paths, -): Promise { - const venv = resolve(destination); - try { - await access(venv); - } catch (error) { - if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) - throw error; - await execute("uv", ["venv", "--python", "3.12", venv]); - const python = join(venv, "bin/python"); - await execute("uv", ["pip", "install", "--python", python, "dimos"]); - config.python = python; - config.dimos = join(venv, "bin/dimos"); - await saveConfig(paths, config); - return; - } - throw new Error( - "Destination exists; select its dimos/python executables or choose a new environment.", - ); -} diff --git a/agent/src/main.ts b/agent/src/main.ts index ee1b23da7d4..da02a0cc337 100644 --- a/agent/src/main.ts +++ b/agent/src/main.ts @@ -8,7 +8,6 @@ import { Gateway } from "./gateway.js"; import { Connection, reachable } from "./protocol.js"; import { setup, needsSetup } from "./setup.js"; import { service } from "./service.js"; -import { installDimos } from "./install.js"; import { terminal } from "./terminal.js"; const { values, positionals } = parseArgs({ @@ -26,6 +25,9 @@ const { values, positionals } = parseArgs({ }); const p = paths(); let config = await loadConfig(p); +let onboarding = false; +const setupPrompt = + "Help me set up DimOS using the dimensional-install skill. Start by asking me to drop an existing checkout/environment path or describe what I want to build. Wait for my answer before installing anything."; if (values.cwd) config.workspace = resolve(values.cwd); async function ensureGateway(): Promise { if (await reachable(p.socket)) return; @@ -69,18 +71,19 @@ async function main(): Promise { const [command, ...args] = positionals; if (values.help || command === "help") { console.log( - "dimcode [tui] [--session ID] [--cwd DIR] [--view]\ndimcode setup [--provider NAME] [--oauth | --key-env VAR]\ndimcode gateway | --foreground | service install/status/uninstall\ndimcode connect NAME MCP_URL | dimos PATH | python PATH | relay URL [ROBOT]\ndimcode install-dimos [VENV] | sessions | stop | run PROMPT | config", + "dimcode [tui] [--session ID] [--cwd DIR] [--view]\ndimcode setup [--provider NAME] [--oauth | --key-env VAR]\ndimcode gateway | --foreground | service install/status/uninstall\ndimcode connect NAME MCP_URL | dimos PATH | python PATH | workspace PATH | relay URL [ROBOT]\ndimcode sessions | stop | run PROMPT | config", ); return; } if (command === "setup") { - await setup(p, { + onboarding = await setup(p, { provider: values.provider, oauth: values.oauth, keyEnv: values["key-env"], workspace: values.cwd, }); - return; + if (!onboarding) return; + config = await loadConfig(p); } if (command === "config") { console.log(JSON.stringify(config, null, 2)); @@ -96,8 +99,13 @@ async function main(): Promise { console.log("Saved endpoint; reload sessions to discover tools."); return; } - if (command === "dimos" || command === "python") { - if (!args[0]) throw new Error("Executable path required"); + if (command === "dimos" || command === "python" || command === "workspace") { + if (!args[0]) throw new Error("Path required"); + if ( + command === "workspace" && + !(await stat(resolve(args[0]))).isDirectory() + ) + throw new Error("Workspace must be a directory"); config[command] = resolve(args[0]); await saveConfig(p, config); return; @@ -107,24 +115,20 @@ async function main(): Promise { await saveConfig(p, config); return; } - if (command === "install-dimos") { - await installDimos(args[0] ?? config.workspace + "/.venv", config, p); - return; - } if (command === "service") { await service(args[0], config, p, process.argv[1]); return; } if ( command && - !["gateway", "stop", "sessions", "run", "tui"].includes(command) + !["gateway", "stop", "sessions", "run", "tui", "setup"].includes(command) ) throw new Error("Unknown command: " + command + ". Run dimcode --help."); if ( (!command || command === "tui" || values.foreground) && (await needsSetup(p)) ) { - await setup(p, { workspace: values.cwd }); + onboarding = await setup(p, { workspace: values.cwd }); config = await loadConfig(p); } if (command === "gateway" || values.foreground) { @@ -139,7 +143,10 @@ async function main(): Promise { await gateway.start(); if (values.foreground) { try { - await terminal(p.socket, { cwd: config.workspace }); + await terminal(p.socket, { + cwd: config.workspace, + initialPrompt: onboarding ? setupPrompt : undefined, + }); } finally { await gateway.close(); } @@ -202,9 +209,10 @@ async function main(): Promise { return; } await terminal(p.socket, { - sessionId: values.session, + sessionId: onboarding ? undefined : values.session, cwd: config.workspace, - view: values.view, + view: onboarding ? false : values.view, + initialPrompt: onboarding ? setupPrompt : undefined, }); } await main().catch((error) => { diff --git a/agent/src/session.ts b/agent/src/session.ts index 53388d92235..27fd88c55d9 100644 --- a/agent/src/session.ts +++ b/agent/src/session.ts @@ -25,6 +25,7 @@ export async function openSession( await mkdir(paths.config, { recursive: true, mode: 0o700 }); await mkdir(paths.sessions, { recursive: true, mode: 0o700 }); const mcp = new McpTools(async () => (await loadConfig(paths)).mcp, report); + let selected = config; const services = await createAgentSessionServices({ cwd: manager.getCwd(), agentDir: paths.config, @@ -36,15 +37,26 @@ export async function openSession( mcp.extension(), renderExtension(paths), (pi) => { + pi.on("before_agent_start", async (event) => { + selected = await loadConfig(paths); + return { + systemPrompt: + event.systemPrompt + + "\nSelected configuration: " + + JSON.stringify(selected), + }; + }); pi.registerTool( createBashToolDefinition(manager.getCwd(), { spawnHook: (context) => ({ ...context, env: { ...context.env, + DIMCODE_NODE: process.execPath, + DIMCODE_CLI: process.argv[1], PATH: [ - config.dimos && dirname(config.dimos), - config.python && dirname(config.python), + selected.dimos && dirname(selected.dimos), + selected.python && dirname(selected.python), context.env.PATH, ] .filter(Boolean) @@ -58,7 +70,7 @@ export async function openSession( appendSystemPrompt: [ "You are dimcode, Dimensional's coding and robotics agent. Use DimOS CLI/public Python APIs and the advertised MCP skills. Do not create a parallel runtime or transport service. Select the intended run and endpoint explicitly; never assume the latest run is the intended one. Render actual saved results with dimcode_render. Live previews are labeled context, not historical query results.", "Visualize each meaningful sensor or memory operation with dimcode_render. Prefer supported types and existing DimOS visualizers; otherwise generate a self-contained SVG with inline Python from the already evaluated result and render that file. Label proposed ideas or overlays distinctly from observations. Use returned images as visual feedback when interpreting results. DimOS owns reusable visualization semantics; dimcode owns terminal-specific styling and interaction. Built-in views are only point clouds and images/SVGs. For a saved sequence, use the Dimensional skill’s finite frame index format; the model sees a contact sheet and can request an original frame index for closer inspection. Clips loop inline without playback controls. If the user asks for a still at a timestamp, select the original frame from the saved index and render it. Never imply a contact sheet covers every frame.", - "Selected configuration: " + JSON.stringify(config), + 'For installing or connecting DimOS, use the dimensional-install skill and the selected version’s own installation instructions. The dimcode CLI is available in Bash as "$DIMCODE_NODE" "$DIMCODE_CLI", even when dimcode is not on PATH. Never read or print provider credentials; use /login for authentication.', ], }, }); diff --git a/agent/src/setup.ts b/agent/src/setup.ts index 9b38b1dbcdf..03d14bcbcc5 100644 --- a/agent/src/setup.ts +++ b/agent/src/setup.ts @@ -1,5 +1,5 @@ -import { access, constants, stat } from "node:fs/promises"; -import { dirname, join, resolve } from "node:path"; +import { access } from "node:fs/promises"; +import { join, resolve } from "node:path"; import { stdin, stdout } from "node:process"; import { ModelRuntime, @@ -17,7 +17,6 @@ import { import type { AuthInteraction, AuthPrompt } from "@earendil-works/pi-ai"; import { loadConfig, saveConfig, type Paths } from "./config.js"; import { ChatInput } from "./input.js"; -import { installDimos } from "./install.js"; import { service } from "./service.js"; /** Pi's login callbacks, with credentials confined to a disposable masked input. */ @@ -56,7 +55,9 @@ function setupUI(): AuthInteraction & { close(): void } { body.addChild(new Text(question.message, 1, 1)); return new Promise((resolve, reject) => { const cancel = () => finish(undefined); - const input = new ChatInput(); + const input = new ChatInput( + question.type === "select" ? "" : question.placeholder, + ); const finish = (value: string | undefined) => { abort.signal.removeEventListener("abort", cancel); question.signal?.removeEventListener("abort", cancel); @@ -132,7 +133,7 @@ export async function setup( paths: Paths, options: SetupOptions = {}, interaction?: AuthInteraction, -): Promise { +): Promise { const config = await loadConfig(paths); if (options.workspace) config.workspace = resolve(options.workspace); const runtime = await ModelRuntime.create({ @@ -149,8 +150,7 @@ export async function setup( message: string, items: readonly { id: string; label: string; description?: string }[], ) => ask({ type: "select", message, options: items }); - let install: string | undefined, - atLogin = false; + let atLogin = false; try { const providers = runtime .getProviders() @@ -239,89 +239,15 @@ export async function setup( ) : ordered[0].id; if (wizard) { - config.workspace = resolve( - ( - await ask({ - type: "text", - message: - "3. Workspace directory (Enter for " + config.workspace + ")", - }) - ).trim() || config.workspace, - ); - if (!(await stat(config.workspace)).isDirectory()) - throw new Error("Workspace must be a directory"); - await access(config.workspace, constants.R_OK | constants.X_OK); - const mode = await choose("4. Connect DimOS", [ - { - id: "later", - label: "Set up later", - description: - "Start coding now; connect a local or remote runtime later", - }, - { - id: "existing", - label: "Use an existing DimOS installation", - description: "pip environment or editable checkout", - }, - { - id: "install", - label: "Install DimOS", - description: "Create a new Python environment using uv", - }, - ]); - if (mode === "existing") { - const executable = resolve( - ( - await ask({ - type: "text", - message: - "Path to the DimOS executable (e.g. /path/to/.venv/bin/dimos)", - }) - ).trim(), - ); - if (!(await stat(executable)).isFile()) - throw new Error("Select a dimos executable file"); - await access(executable, constants.X_OK); - config.dimos = executable; - const python = join(dirname(executable), "python"); - try { - await access(python, constants.X_OK); - config.python = python; - } catch { - config.python = undefined; - } - } else if (mode === "install") { - install = resolve( - ( - await ask({ - type: "text", - message: - "New environment directory (Enter for " + - join(config.workspace, ".venv") + - ")", - }) - ).trim() || join(config.workspace, ".venv"), - ); - } - const url = ( - await ask({ - type: "text", - message: "5. Existing DimOS MCP URL (Enter to connect later)", - }) - ).trim(); - if (url) - config.mcp = [ - ...config.mcp.filter((e) => e.name !== "default"), - { name: "default", url: new URL(url).href }, - ]; if (process.platform === "linux") atLogin = - (await choose("6. Start the gateway at login?", [ + (await choose("3. Start the gateway at login?", [ { id: "no", label: "Start when I open dimcode" }, { id: "yes", label: "Start at login", - description: "Install a systemd user service", + description: + "Keep dimcode available in the background; no robot starts", }, ])) === "yes"; } @@ -331,7 +257,9 @@ export async function setup( } finally { if (ui && "close" in ui && typeof ui.close === "function") ui.close(); } - if (install) await installDimos(install, config, paths); if (atLogin) await service("install", config, paths, process.argv[1]); - stdout.write("dimcode configured. Run dimcode (or dimcode tui) to chat.\n"); + stdout.write( + wizard ? "Opening agent-led DimOS setup…\n" : "Provider configured.\n", + ); + return wizard; } diff --git a/agent/src/terminal.ts b/agent/src/terminal.ts index ac35656de1f..da2793bfdf6 100644 --- a/agent/src/terminal.ts +++ b/agent/src/terminal.ts @@ -54,7 +54,12 @@ const resultSchema = z.object({ }); export async function terminal( socket: string, - options: { sessionId?: string; cwd?: string; view?: boolean } = {}, + options: { + sessionId?: string; + cwd?: string; + view?: boolean; + initialPrompt?: string; + } = {}, ): Promise { initTheme("dark", false); const client = new Connection(socket), @@ -783,13 +788,16 @@ export async function terminal( } return undefined; }); - tui.start(); - await new Promise((resolve) => { + const disconnected = new Promise((resolve) => { client.onClose = () => { stop(); resolve(); }; }); + tui.start(); + if (options.initialPrompt && !options.sessionId && !options.view) + input.onSubmit(options.initialPrompt); + await disconnected; } finally { stop(); } diff --git a/agent/test/cli.test.ts b/agent/test/cli.test.ts index 90dd68f423e..d07fafc7013 100644 --- a/agent/test/cli.test.ts +++ b/agent/test/cli.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import { execFile, spawn } from "node:child_process"; import { once } from "node:events"; -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdtemp, mkdir, rm, stat } from "node:fs/promises"; import { createServer, type Socket } from "node:net"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; @@ -51,6 +51,22 @@ test( { env: { ...env, DIMCODE_FIXTURE_KEY: key } }, ); assert(!configured.stdout.includes(key)); + assert( + !(await reachable(paths(env).socket)), + "provider-only setup does not launch an agent", + ); + const workspace = join(home, "app with spaces"); + await mkdir(workspace); + await run("workspace", workspace); + assert.equal(JSON.parse((await run("config")).stdout).workspace, workspace); + await assert.rejects(run("workspace", cli), /must be a directory/); + await assert.rejects( + run("install-dimos", join(home, "unexpected-env")), + /Unknown command/, + ); + await assert.rejects(stat(join(home, "unexpected-env")), { + code: "ENOENT", + }); const child = spawn(process.execPath, [cli, "tui"], { env, stdio: ["pipe", "pipe", "pipe"], @@ -86,6 +102,127 @@ test( }, ); +test( + "interactive setup hands off once to a normal agent session without installing DimOS", + { timeout: 20000, skip: process.platform !== "linux" }, + async (t) => { + const home = await mkdtemp(join(tmpdir(), "dimcode-onboard-")); + t.after(() => rm(home, { recursive: true, force: true })); + const env = { + ...process.env, + DIMCODE_HOME: join(home, "config"), + XDG_STATE_HOME: join(home, "state"), + XDG_CACHE_HOME: join(home, "cache"), + XDG_CONFIG_HOME: join(home, "xdg-config"), + XDG_RUNTIME_DIR: home, + DIMCODE_FIXTURE_KEY: "onboarding-private-fixture", + TERM: "xterm-256color", + }; + const cli = resolve(".test/src/main.js"); + await exec( + process.execPath, + [ + cli, + "setup", + "--provider", + "openai", + "--key-env", + "DIMCODE_FIXTURE_KEY", + "--cwd", + home, + ], + { env }, + ); + const sockets = new Set(); + let prompts = 0; + const server = createServer((socket) => { + sockets.add(socket); + socket.on("error", () => {}); + socket.on("close", () => sockets.delete(socket)); + readLines(socket, (value) => { + const request = requestSchema.parse(value); + if (request.command.type === "new_session") { + send(socket, { + type: "response", + id: request.id, + data: { + sessionId: "onboarding", + cwd: home, + seq: 0, + busy: false, + writable: true, + messages: [], + text: "", + notices: [], + tools: [], + }, + }); + } else if (request.command.type === "prompt") { + prompts++; + assert.match(request.command.message, /dimensional-install skill/); + assert.match(request.command.message, /Wait for my answer/); + send(socket, { type: "response", id: request.id, data: null }); + send(socket, { + type: "event", + sessionId: "onboarding", + seq: 1, + event: { + type: "notice", + message: "fixture: agent received setup request", + }, + }); + } + }); + }); + await new Promise((done) => server.listen(paths(env).socket, done)); + t.after(() => { + for (const socket of sockets) socket.destroy(); + return new Promise((done) => server.close(() => done())); + }); + const quote = (arg: string) => "'" + arg.replaceAll("'", "'\\''") + "'"; + const child = spawn( + "script", + [ + "-q", + "-e", + "-c", + [process.execPath, cli, "setup"].map(quote).join(" "), + "/dev/null", + ], + { env, stdio: ["pipe", "pipe", "pipe"] }, + ); + t.after(() => child.kill()); + let output = "", + stage = 0; + const screens = [ + "1. Choose your model provider", + "Sign in", + "2. Choose a model", + "3. Start the gateway at login?", + "fixture: agent received setup request", + ]; + child.stdout.on("data", (chunk) => { + output += chunk; + if (stage < screens.length && output.includes(screens[stage])) { + stage++; + child.stdin.write(stage === screens.length ? "/exit\r" : "\r"); + } + }); + let errors = ""; + child.stderr.on("data", (chunk) => { + errors += chunk; + }); + const timer = setTimeout(() => child.kill(), 15000); + const [code] = await once(child, "exit"); + clearTimeout(timer); + assert.equal(code, 0, errors + output); + assert.equal(stage, screens.length, output); + assert.equal(prompts, 1); + assert(!output.includes(env.DIMCODE_FIXTURE_KEY)); + await assert.rejects(stat(join(home, ".venv")), { code: "ENOENT" }); + }, +); + test( "terminal drops old-session events queued during a session switch", { timeout: 15000 }, diff --git a/agent/test/setup.test.ts b/agent/test/setup.test.ts index b1093b7aab0..52a18041af0 100644 --- a/agent/test/setup.test.ts +++ b/agent/test/setup.test.ts @@ -3,9 +3,11 @@ import { mkdtemp, readFile, rm, stat } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { test } from "node:test"; -import { SettingsManager } from "@earendil-works/pi-coding-agent"; +import { stripVTControlCharacters } from "node:util"; +import { initTheme, SettingsManager } from "@earendil-works/pi-coding-agent"; import { loadConfig, paths } from "../src/config.js"; import { needsSetup, setup } from "../src/setup.js"; +import { ChatInput } from "../src/input.js"; for (const cancel of [false, true]) test( @@ -21,10 +23,15 @@ for (const cancel of [false, true]) let asked = 0; const configure = setup( p, - {}, + { workspace: home }, { prompt: async (question) => { asked++; + assert( + !/environment directory|MCP URL|Workspace directory/.test( + question.message, + ), + ); if (cancel && asked === 2) throw new Error("Cancelled"); if (question.type === "select") { if (question.options.some((item) => item.id === "openai")) @@ -46,7 +53,12 @@ for (const cancel of [false, true]) assert(await needsSetup(p)); return; } - await configure; + assert.equal( + await configure, + true, + "interactive bootstrap hands off to the agent", + ); + await assert.rejects(stat(join(home, ".venv")), { code: "ENOENT" }); assert(!(await needsSetup(p))); assert.equal((await loadConfig(p)).workspace, home); assert.equal( @@ -65,3 +77,18 @@ for (const cancel of [false, true]) ); }, ); + +test("setup fields use their own placeholder and discard credential history", () => { + initTheme("dark", false); + const input = new ChatInput("Drop a directory path…"); + const visible = () => stripVTControlCharacters(input.render(80).join("")); + assert.match(visible(), /Drop a directory path/); + assert(!input.render(80).join("").includes("Ask about")); + input.secret = true; + input.handleInput("fixture-secret"); + assert(!input.render(80).join("").includes("fixture-secret")); + input.secret = false; + input.handleInput("\x1f"); + assert.equal(input.getValue(), ""); + assert.match(visible(), /Drop a directory path/); +}); diff --git a/pyproject.toml b/pyproject.toml index bc3dfd27b9b..5360f0773bb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -758,6 +758,7 @@ ignore = [ "uv.lock", "dimos/experimental/isolated_python/example/python/uv.lock", "*/package-lock.json", + "agent/npm-shrinkwrap.json", "Cargo.lock", "*/Cargo.lock", "dimos/web/dimos_interface/themes.json", From 20a9b4b552fec15ee91e1c3f085e47dab8627c13 Mon Sep 17 00:00:00 2001 From: spomichter Date: Sat, 12 Sep 2026 14:02:34 -0700 Subject: [PATCH 9/9] fix(dimcode): install verified prerelease tarballs directly --- agent/README.md | 6 +++--- agent/install.sh | 15 +++++++++++++-- agent/npm-shrinkwrap.json | 4 ++-- agent/package.json | 2 +- 4 files changed, 19 insertions(+), 8 deletions(-) diff --git a/agent/README.md b/agent/README.md index caf4fa2911d..dccb5ce708b 100644 --- a/agent/README.md +++ b/agent/README.md @@ -7,7 +7,7 @@ Persistent terminal agent for DimOS, built on Pi. The gateway runs independently Test release: **`@spomichter/dimcode@next`**. Install on Linux x64/arm64 or Apple Silicon: ```sh -curl -fsSL https://github.com/dimensionalOS/dimos/releases/download/dimcode-v0.1.0-next.0/install.sh | sh +curl -fsSL https://github.com/dimensionalOS/dimos/releases/download/dimcode-v0.1.0-next.1/install.sh | sh ``` The installer downloads and checks a private Node 24 runtime, installs the npm package and creates `~/.local/bin/dimcode`. It offers to open setup immediately. No system Node upgrade, sudo, source checkout or build commands are needed. If `~/.local/bin` is not on your PATH, use the absolute launcher path printed by the installer. @@ -15,7 +15,7 @@ The installer downloads and checks a private Node 24 runtime, installs the npm p With Node **24 or 26** already installed, npm also works: ```sh -npm install -g @spomichter/dimcode@next +npm install -g https://registry.npmjs.org/@spomichter/dimcode/-/dimcode-0.1.0-next.1.tgz dimcode setup ``` @@ -151,7 +151,7 @@ These commands run the source build without installing the `dimcode` command. `n Prereleases are published by [dimcode-release.yml](../.github/workflows/dimcode-release.yml) from `dimcode-vVERSION` tags matching the package's `-next.N` version. The workflow checks Linux/macOS, publishes to npm's `next` tag using `DIMCODE_NPM_TOKEN`, then creates a GitHub prerelease with the tarball and installer. Ordinary branch pushes do not publish. The personal npm scope is for this test release. -Installer options: `DIMCODE_VERSION` selects a version/tag (default `next`), `DIMCODE_INSTALL_DIR` selects private runtime/releases, `DIMCODE_BIN_DIR` selects the launcher directory, and `DIMCODE_SETUP=0` skips the setup offer. Downloads are verified before extraction. Run the installer again to update the launcher; older releases remain available to running sessions. Existing gateways continue using their current release until restarted with `dimcode service install` after active turns finish. +Installer options: `DIMCODE_VERSION` selects a version/tag (default `next`), `DIMCODE_INSTALL_DIR` selects private runtime/releases, `DIMCODE_BIN_DIR` selects the launcher directory, and `DIMCODE_SETUP=0` skips the setup offer. Node downloads and npm tarballs are verified before installation. The installer resolves npm’s version metadata directly and checks the package SHA-512, so a newly published release does not depend on npm’s package-index propagation. Run the installer again to update the launcher; older releases remain available to running sessions. Existing gateways continue using their current release until restarted with `dimcode service install` after active turns finish. To run the complete integration suite with an existing DimOS environment and Go2 recording: diff --git a/agent/install.sh b/agent/install.sh index c40541c4ce4..487d4b5bf2b 100644 --- a/agent/install.sh +++ b/agent/install.sh @@ -49,13 +49,24 @@ main() { PATH="$data/node/bin:$PATH" export PATH npm="$data/node/bin/npm" - resolved=$("$npm" view "$package@$version" version) + curl -fsSL "https://registry.npmjs.org/$package/$version" -o "$staging/metadata.json" + resolved=$("$node" -p 'JSON.parse(require("node:fs").readFileSync(process.argv[1])).version' "$staging/metadata.json") case "$resolved" in ''|*[!a-zA-Z0-9._-]*) echo 'Invalid package version from registry.' >&2; exit 1 ;; esac release="$data/releases/$resolved" entry="$release/node_modules/$package/dist/main.js" if [ ! -f "$entry" ]; then printf 'Installing %s@%s…\n' "$package" "$resolved" - "$npm" install --prefix "$staging/package" --ignore-scripts --min-release-age=0 --omit=dev --no-audit --no-fund "$package@$resolved" + tarball=$("$node" -p 'JSON.parse(require("node:fs").readFileSync(process.argv[1])).dist.tarball' "$staging/metadata.json") + case "$tarball" in https://registry.npmjs.org/*) ;; *) echo 'Unexpected package registry.' >&2; exit 1 ;; esac + curl -fsSL "$tarball" -o "$staging/dimcode.tgz" + "$node" - "$staging/metadata.json" "$staging/dimcode.tgz" "$package" <<'NODE' +const fs = require('node:fs'); +const crypto = require('node:crypto'); +const metadata = JSON.parse(fs.readFileSync(process.argv[2])); +const actual = 'sha512-' + crypto.createHash('sha512').update(fs.readFileSync(process.argv[3])).digest('base64'); +if (metadata.name !== process.argv[4] || actual !== metadata.dist.integrity) throw new Error('Package integrity check failed'); +NODE + "$npm" install --prefix "$staging/package" --ignore-scripts --min-release-age=0 --omit=dev --no-audit --no-fund "$staging/dimcode.tgz" "$node" "$staging/package/node_modules/$package/dist/main.js" --help >/dev/null mkdir -p "$data/releases" [ ! -e "$release" ] || { echo "Incomplete release at $release; choose another DIMCODE_INSTALL_DIR." >&2; exit 1; } diff --git a/agent/npm-shrinkwrap.json b/agent/npm-shrinkwrap.json index 4897f495b72..3d1a1c54173 100644 --- a/agent/npm-shrinkwrap.json +++ b/agent/npm-shrinkwrap.json @@ -1,12 +1,12 @@ { "name": "@spomichter/dimcode", - "version": "0.1.0-next.0", + "version": "0.1.0-next.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@spomichter/dimcode", - "version": "0.1.0-next.0", + "version": "0.1.0-next.1", "license": "Apache-2.0", "dependencies": { "@earendil-works/pi-ai": "0.85.1", diff --git a/agent/package.json b/agent/package.json index 04e9e05acfa..4b89b201bcb 100644 --- a/agent/package.json +++ b/agent/package.json @@ -1,6 +1,6 @@ { "name": "@spomichter/dimcode", - "version": "0.1.0-next.0", + "version": "0.1.0-next.1", "description": "Persistent DimOS coding agent and terminal, built on Pi", "type": "module", "license": "Apache-2.0",