diff --git a/bun.lock b/bun.lock index 742578ad2b25..907c04c31d2f 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.17.13", + "version": "1.17.14", "dependencies": { "@dnd-kit/abstract": "0.5.0", "@dnd-kit/dom": "0.5.0", @@ -93,7 +93,7 @@ }, "packages/cli": { "name": "@opencode-ai/cli", - "version": "1.17.13", + "version": "1.17.14", "bin": { "lildax": "./bin/lildax.cjs", }, @@ -141,7 +141,7 @@ }, "packages/codemode": { "name": "@opencode-ai/codemode", - "version": "0.0.1", + "version": "1.17.14", "dependencies": { "acorn": "8.15.0", "effect": "catalog:", @@ -155,7 +155,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.17.13", + "version": "1.17.14", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -191,7 +191,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.17.13", + "version": "1.17.14", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -218,7 +218,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.17.13", + "version": "1.17.14", "dependencies": { "@ai-sdk/anthropic": "3.0.82", "@ai-sdk/openai": "3.0.48", @@ -240,7 +240,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.17.13", + "version": "1.17.14", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -264,7 +264,7 @@ }, "packages/console/support": { "name": "@opencode-ai/console-support", - "version": "1.17.13", + "version": "1.17.14", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@opencode-ai/console-core": "workspace:*", @@ -284,7 +284,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "1.17.13", + "version": "1.17.14", "bin": { "opencode": "./bin/opencode", }, @@ -378,7 +378,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.17.13", + "version": "1.17.14", "dependencies": { "@zip.js/zip.js": "2.7.62", "effect": "catalog:", @@ -432,7 +432,7 @@ }, "packages/effect-drizzle-sqlite": { "name": "@opencode-ai/effect-drizzle-sqlite", - "version": "1.17.13", + "version": "1.17.14", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -446,7 +446,7 @@ }, "packages/effect-sqlite-node": { "name": "@opencode-ai/effect-sqlite-node", - "version": "1.17.13", + "version": "1.17.14", "dependencies": { "effect": "catalog:", }, @@ -458,7 +458,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.17.13", + "version": "1.17.14", "dependencies": { "@hono/standard-validator": "catalog:", "@opencode-ai/core": "workspace:*", @@ -490,7 +490,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.17.13", + "version": "1.17.14", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -506,7 +506,7 @@ }, "packages/http-recorder": { "name": "@opencode-ai/http-recorder", - "version": "1.17.13", + "version": "1.17.14", "dependencies": { "@effect/platform-node": "4.0.0-beta.83", "@effect/platform-node-shared": "4.0.0-beta.83", @@ -537,7 +537,7 @@ }, "packages/llm": { "name": "@opencode-ai/llm", - "version": "1.17.13", + "version": "1.17.14", "dependencies": { "@opencode-ai/schema": "workspace:*", "@smithy/eventstream-codec": "4.2.14", @@ -556,7 +556,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.17.13", + "version": "1.17.14", "bin": { "opencode": "./bin/opencode", }, @@ -687,7 +687,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.17.13", + "version": "1.17.14", "dependencies": { "@ai-sdk/provider": "3.0.8", "@opencode-ai/sdk": "workspace:*", @@ -704,9 +704,9 @@ "typescript": "catalog:", }, "peerDependencies": { - "@opentui/core": ">=0.3.4", - "@opentui/keymap": ">=0.3.4", - "@opentui/solid": ">=0.3.4", + "@opentui/core": ">=0.4.3", + "@opentui/keymap": ">=0.4.3", + "@opentui/solid": ">=0.4.3", }, "optionalPeers": [ "@opentui/core", @@ -763,7 +763,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.17.13", + "version": "1.17.14", "dependencies": { "cross-spawn": "catalog:", }, @@ -778,7 +778,7 @@ }, "packages/server": { "name": "@opencode-ai/server", - "version": "1.17.13", + "version": "1.17.14", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/protocol": "workspace:*", @@ -793,7 +793,7 @@ }, "packages/session-ui": { "name": "@opencode-ai/session-ui", - "version": "1.17.13", + "version": "1.17.14", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -837,7 +837,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.17.13", + "version": "1.17.14", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -850,7 +850,7 @@ }, "packages/stats/app": { "name": "@opencode-ai/stats-app", - "version": "1.17.13", + "version": "1.17.14", "dependencies": { "@ibm/plex": "6.4.1", "@opencode-ai/stats-core": "workspace:*", @@ -883,7 +883,7 @@ }, "packages/stats/core": { "name": "@opencode-ai/stats-core", - "version": "1.17.13", + "version": "1.17.14", "dependencies": { "@aws-sdk/client-athena": "3.933.0", "@planetscale/database": "1.19.0", @@ -902,7 +902,7 @@ }, "packages/stats/server": { "name": "@opencode-ai/stats-server", - "version": "1.17.13", + "version": "1.17.14", "dependencies": { "@aws-sdk/client-firehose": "3.933.0", "@effect/platform-node": "catalog:", @@ -943,7 +943,7 @@ }, "packages/tui": { "name": "@opencode-ai/tui", - "version": "1.17.13", + "version": "1.17.14", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/plugin": "workspace:*", @@ -970,7 +970,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.17.13", + "version": "1.17.14", "dependencies": { "@kobalte/core": "catalog:", "@pierre/diffs": "catalog:", @@ -1021,7 +1021,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.17.13", + "version": "1.17.14", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", @@ -1096,9 +1096,9 @@ "@npmcli/arborist": "9.4.0", "@octokit/rest": "22.0.0", "@openauthjs/openauth": "0.0.0-20250322224806", - "@opentui/core": "0.3.4", - "@opentui/keymap": "0.3.4", - "@opentui/solid": "0.3.4", + "@opentui/core": "0.4.3", + "@opentui/keymap": "0.4.3", + "@opentui/solid": "0.4.3", "@pierre/diffs": "1.2.10", "@playwright/test": "1.59.1", "@sentry/solid": "10.36.0", @@ -2031,27 +2031,27 @@ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="], - "@opentui/core": ["@opentui/core@0.3.4", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.3.4", "@opentui/core-darwin-x64": "0.3.4", "@opentui/core-linux-arm64": "0.3.4", "@opentui/core-linux-arm64-musl": "0.3.4", "@opentui/core-linux-x64": "0.3.4", "@opentui/core-linux-x64-musl": "0.3.4", "@opentui/core-win32-arm64": "0.3.4", "@opentui/core-win32-x64": "0.3.4" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-y0DlrChP9lcJ4jC5z/1wMS34+ygfSTW7gD5OJHwJaAScfmlFvuJOZbwmCGrJURZ+5wFBxuOi9LatZsmeAUIKAA=="], + "@opentui/core": ["@opentui/core@0.4.3", "", { "dependencies": { "bun-ffi-structs": "0.2.4", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.4.3", "@opentui/core-darwin-x64": "0.4.3", "@opentui/core-linux-arm64": "0.4.3", "@opentui/core-linux-arm64-musl": "0.4.3", "@opentui/core-linux-x64": "0.4.3", "@opentui/core-linux-x64-musl": "0.4.3", "@opentui/core-win32-arm64": "0.4.3", "@opentui/core-win32-x64": "0.4.3" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-rrJfAk13tALDqldYjhc78eWQ+aKq1iknJgffIOg3OwyZoqQo+p6gtuqyhmWvXIfQzlNUbpgpCPcxbXlhMnlaHQ=="], - "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.3.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4A7JYXUsZqhu9PPCe07E30ourSJYkitkwMujUyNKjM5e/dHNDVnz+5r5cO3M5snofLafc1DN7+9jEPn4UQzchQ=="], + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.4.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-p5+7AAxpxGuDGagyQfewKtmTFnN7THvTVY4FyKqUtJomNaHdQXPHztapNNzMx0DGWbwOUbVKzpL+yc3CZY3chQ=="], - "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.3.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-Jvm9E8n2sPhKEyKSXn9GlmJcj8WoJXJTooXb3djwjVaiimjihIj0XxHzCWhdqbDtQp+VxDFyCKoQagOOz20qhA=="], + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.4.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-+fh0vEUE0lwVC7RW5ijYLRlTLp5NfvCRj8SzxDVd7IL2j2ssB6YXcfIbXq2EW7UGnrejwPRXf1tgUrIXW9KmOw=="], - "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-0uPuHCeZxm/O7+L+iNQl8zRAfehiwYstKkT9J0uTZO64/byBCLvy5lvn1DiE/72s/nTJ5nwpLN+pQs2/WYVKLQ=="], + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.4.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-gl6qA5QJy6u8Cbt7gOtHbhhfMZ4qQDb0kEwFXHcMGmbnKzz4OHoq74D6tNjyvSQB9saoC7C6C0tvn2DcJOuNog=="], - "@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-sJYUzYcSOb5PCXRlhwsse/fdsMiVomNvIwq/2TDhAANef+YPO3Br+OH9kQRbuj0bjVDmUS36SGYWSTFu2lUO+A=="], + "@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.4.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-8p8g8/AEq/xFGpQ7XcIFKcAqjc0QwsZcv+Ll9RbCDpUA56FGH6jfLDir0KYTNTgYXJTIrBIENI9K46VuxMUMQA=="], - "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-btYIQeNdPbN4JCrCjVB/RwMGrnRY7qWB2piNEfALSByuULKNjPKQ33PYIj38Yd01zCvCV7FotIeXEGSHx3tgCA=="], + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.4.3", "", { "os": "linux", "cpu": "x64" }, "sha512-dXpJitiZdYE3hq2Pvx6e9I0uPQSOcnaLLp1pDgWAHv+3kvKSHEX//9Yr/pV/Ua6qqT7p+2D/K4vXNap/NKVo2w=="], - "@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-fhmUey4oJJ2+N62xlIgAPxAl36Fa7wYffqDOT4QLpm0jfyD5xzo+wL/hr2zUqaEI439R8Iq6jHNxf/Nsx1WuuQ=="], + "@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.4.3", "", { "os": "linux", "cpu": "x64" }, "sha512-/QiFpCrpU2O7vy8QYmLIQYbvAtKDgmqcVjR7dGtqSzkiQk3ktNJoo5RozG7ueXnjung1Wp0nKldKxo2Csg/OrA=="], - "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.3.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-sh432vPU+eLp8eA4I0KWKKn7D0VHbk01YTg6mA9/ihCNYHntc6LZ8/sLvsPv8CvKscMotfIkh3M5YhdS36BuXw=="], + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.4.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-Mx2zuOjrhm/z2SDS6RExIyjP/SnN/8QhhagxURUw0jQi/NssGSeAllu1cBAFFnhobJL5QLTE4FU4CRhUK9svgg=="], - "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.3.4", "", { "os": "win32", "cpu": "x64" }, "sha512-dw8FcjUZaLAjw25P3/7BarobCh/QOHn3srYaWYQdysoqyvSlPkQumpI8kV/KgpJtdITU1GW02MQC4EeLIFFalA=="], + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.4.3", "", { "os": "win32", "cpu": "x64" }, "sha512-NuoqvWKGXaYnmlqvu7Gg2lLI6yVMnS9OfWBvxp+7Q+McSgHFSTQmYBXaPpvQ8HikpQXE1nCeMPtuSG4PdZHe2w=="], - "@opentui/keymap": ["@opentui/keymap@0.3.4", "", { "dependencies": { "@opentui/core": "0.3.4" }, "peerDependencies": { "@opentui/react": "0.3.4", "@opentui/solid": "0.3.4", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-8fo6BZWQgCjANfbKkzPo0ghAzS1E7TlHjDDS+SUhrX01qEUO1clFTRssKluHbXd2UJY1Ehle01TV5bFmY78f8w=="], + "@opentui/keymap": ["@opentui/keymap@0.4.3", "", { "dependencies": { "@opentui/core": "0.4.3" }, "peerDependencies": { "@opentui/react": "0.4.3", "@opentui/solid": "0.4.3", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-sinX0pyQBRrEvo89PSSUbSUDIYpL3xWo81VEfec58VFoVRB5FG48/deAtvRTQfJ8w1kgbzN8hzdOXdSm61zBmw=="], - "@opentui/solid": ["@opentui/solid@0.3.4", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.3.4", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-gin1VnsVBahX0nrU3mpgh5U1qvyJBIZu4NE5mc0YnObWOEf9HVNxKY4/BpUvQPh91kT6zeOzTBvAvYK4R7g9MQ=="], + "@opentui/solid": ["@opentui/solid@0.4.3", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.4.3", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-RcV0+S8HMdXOASyr7HmJUBuTUIaFPzAxMDa44VftS5C2JUgrmAuWo0Njv1q3TWRB1owjHnyKhEfWGKq7A82wxw=="], "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="], @@ -3243,7 +3243,7 @@ "builder-util-runtime": ["builder-util-runtime@9.7.0", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw=="], - "bun-ffi-structs": ["bun-ffi-structs@0.2.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-N/ZWtyN0piZlrXQT7TO0V+q952orYqkfhXRXM1Hcbb+R3QSiBH4vLnib187Mrs1H7pWIYECAmPeapGYDOMCl+w=="], + "bun-ffi-structs": ["bun-ffi-structs@0.2.4", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-AJzsqoVFs1KBbJbWHIYrVZLDC3NhTqqh25awRXqzoLzmBAKr5oqk6+CwuYHAekKx+VBCYVohBoKuRq40dV+TYg=="], "bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="], @@ -5591,8 +5591,6 @@ "yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="], - "yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="], - "youch": ["youch@4.1.0-beta.10", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="], "youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="], diff --git a/nix/hashes.json b/nix/hashes.json index a86dbdec4f20..da7fa46acc1f 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-NRSHbXA5jYZ2VUQ+b7d/dwg5BWap54ej3Ys9MPwgLwc=", - "aarch64-linux": "sha256-ua9ZjsF0KDuLOPjEcxhD4u5cMfjPuqoAyUftrNPr2sI=", - "aarch64-darwin": "sha256-QH3as89//yrRpWK7f96rNjiwCO5JTDYhVrUHbqQMomw=", - "x86_64-darwin": "sha256-30cIeLqasI+FUBYBttb3+MezWoQmb+UpkFWhPy4B3Xk=" + "x86_64-linux": "sha256-KTyd2ISQ4n1E3tm1LMEHtz+rKRZga+SONn5J6H0pheQ=", + "aarch64-linux": "sha256-ZIeaaqRr4JorEmmwFYuitxfYEAtrP8C6IlD+pmFRko0=", + "aarch64-darwin": "sha256-Po8FISoBzUMwJSn6nw243/hLT/gAQCrm5HbwXe2uA0g=", + "x86_64-darwin": "sha256-5TZzrCnvg1z00YP6O9U0SXjL04r+VmF7LVrqQ9BbSn4=" } } diff --git a/package.json b/package.json index fd86ad3decd2..e5526b22ffb1 100644 --- a/package.json +++ b/package.json @@ -39,9 +39,9 @@ "@octokit/rest": "22.0.0", "@hono/standard-validator": "0.2.0", "@hono/zod-validator": "0.4.2", - "@opentui/core": "0.3.4", - "@opentui/keymap": "0.3.4", - "@opentui/solid": "0.3.4", + "@opentui/core": "0.4.3", + "@opentui/keymap": "0.4.3", + "@opentui/solid": "0.4.3", "@tanstack/solid-virtual": "3.13.28", "@shikijs/stream": "4.2.0", "ulid": "3.0.1", diff --git a/packages/app/package.json b/packages/app/package.json index 766f00aeb964..739dfc5ba0e6 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.17.13", + "version": "1.17.14", "description": "", "type": "module", "exports": { diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index c7cdc73f9b3d..9c2e7d6a1f0e 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -126,10 +126,10 @@ function SelectedServerProviders(props: ParentProps) { ) } -function LegacyServerLayout(props: ParentProps) { +function LegacyServerLayout(props: ParentProps<{ serverScoped?: JSX.Element }>) { return ( - {props.children} + {props.children} ) } @@ -263,12 +263,14 @@ function DesktopCommands() { type ServerScopedShellProps = ParentProps<{ directory?: () => string | undefined sessionID?: () => string | undefined + serverScoped?: JSX.Element }> function ServerScopedProviders(props: ServerScopedShellProps) { return ( + {props.serverScoped} {props.children} @@ -277,16 +279,16 @@ function ServerScopedProviders(props: ServerScopedShellProps) { function LegacyServerScopedShell(props: ServerScopedShellProps) { return ( - + {props.children} ) } -function NewAppLayout(props: ParentProps) { +function NewAppLayout(props: ParentProps<{ serverScoped?: JSX.Element }>) { return ( - + {props.children} @@ -347,7 +349,7 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) { ) } -function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) { +function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean; startup?: Promise }>) { const server = useServer() const checkServerHealth = useCheckServerHealth() @@ -376,34 +378,45 @@ function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) { const checking = createMemo( () => checkMode() === "blocking" && ["unresolved", "pending"].includes(startupHealthCheck.state), ) + const [startup] = createResource(async () => { + if (!props.startup) return true + await props.startup.catch((error) => { + console.error("[startup] startup gate failed", error) + }) + return true + }) + const startupChecking = createMemo( + () => startupHealthCheck.latest === true && ["unresolved", "pending"].includes(startup.state), + ) + const loading = createMemo(() => checking() || startupChecking()) return ( - + <> + + { + if (checkMode() === "background") void healthCheckActions.refetch() + }} + onServerSelected={(key) => { + setCheckMode("blocking") + server.setActive(key) + void healthCheckActions.refetch() + }} + /> + } + > + {props.children} + + + +
- } - > - { - if (checkMode() === "background") void healthCheckActions.refetch() - }} - onServerSelected={(key) => { - setCheckMode("blocking") - server.setActive(key) - void healthCheckActions.refetch() - }} - /> - } - > - {props.children} -
+ ) } @@ -470,6 +483,8 @@ export function AppInterface(props: { servers?: Array router?: Component disableHealthCheck?: boolean + startup?: Promise + serverScoped?: JSX.Element }) { // The visual new layout lives in the router root so it remains mounted across // route changes. Draft and session routes override only their server-bound data @@ -491,7 +506,7 @@ export function AppInterface(props: { > - + - {routerProps.children} + {routerProps.children} )} > - + @@ -517,12 +532,16 @@ export function AppInterface(props: { ) } -function Routes() { +function Routes(props: { serverScoped?: JSX.Element }) { const settings = useSettings() return ( <> - + ( + {routeProps.children} + )} + > {} } /> diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 8706fd93c904..2a818d565da1 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -334,6 +334,21 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { return } + if (route.type === "home") { + const selection = layout.home.selection() + const conn = global.servers.list().find((item) => ServerConnection.key(item) === selection.server) + const project = conn + ? global + .ensureServerCtx(conn) + .projects.list() + .find((item) => item.worktree === selection.directory) + : undefined + if (conn && project) { + tabs.newDraft({ server: ServerConnection.key(conn), directory: project.worktree }, "") + return + } + } + const current = layout.projects.list()[0] if (current) { tabs.newDraft({ server: server.key, directory: current.worktree }, "") diff --git a/packages/app/src/context/server.tsx b/packages/app/src/context/server.tsx index 95c6940d70c5..450129f45884 100644 --- a/packages/app/src/context/server.tsx +++ b/packages/app/src/context/server.tsx @@ -312,7 +312,10 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( }) } - const isReady = createMemo(() => ready() && !!state.active) + const isReady = Object.assign( + createMemo(() => ready() && !!state.active), + { promise: ready.promise }, + ) const scope = (key = state.active) => ServerScope.fromServerKey(key, props.canonicalLocalServer) const projects = createServerProjects({ scope, store, setStore }) diff --git a/packages/app/src/index.ts b/packages/app/src/index.ts index 2bc9262fde63..ced001ceccf9 100644 --- a/packages/app/src/index.ts +++ b/packages/app/src/index.ts @@ -1,4 +1,10 @@ export { AppBaseProviders, AppInterface } from "./app" +export { useLayout } from "./context/layout" +export { useServerSDK } from "./context/server-sdk" +export { useServerSync } from "./context/server-sync" +export { useServer } from "./context/server" +export { useTabs } from "./context/tabs" +export { useProviders } from "./hooks/use-providers" export { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, filePickerFilters } from "./constants/file-picker" export { useCommand } from "./context/command" export { loadLocaleDict, normalizeLocale, type Locale } from "./context/language" diff --git a/packages/app/src/pages/new-session.tsx b/packages/app/src/pages/new-session.tsx index fdb9929eca58..461be60450b4 100644 --- a/packages/app/src/pages/new-session.tsx +++ b/packages/app/src/pages/new-session.tsx @@ -26,7 +26,7 @@ import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout" import { PromptWorkspaceSelector } from "@/components/prompt-workspace-selector" import { useTitlebarRightMount } from "@/components/titlebar" -const showWorkspaceBar = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod" +const workspaceBarEnabled = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod" /** * The `/new-session` draft page. Unlike `session.tsx`, this only renders the prompt @@ -63,7 +63,9 @@ export default function NewSessionPage() { const [store, setStore] = createStore<{ worktree?: string }>({}) const rightMount = useTitlebarRightMount() + const showWorkspaceBar = createMemo(() => workspaceBarEnabled && sync().project?.vcs === "git") const newSessionWorktree = createMemo(() => { + if (!showWorkspaceBar()) return "main" if (store.worktree) return store.worktree const project = sync().project if (project && sdk().directory !== project.worktree) return sdk().directory @@ -123,7 +125,7 @@ export default function NewSessionPage() { } > -
+
- + .(input)`), instead of exposing dozens -of MCP tools individually. The point is **control flow**: sequencing, filtering, and composing -tool calls in one program instead of round-tripping through the agent loop, plus not flooding -the context window when users connect many MCP servers. +### Purpose -Architecture split (locked): +CodeMode gives a model one `execute` tool backed by a confined JavaScript interpreter. Inside the program, the model +can call an explicit tree of schema-described tools, sequence dependent work, run independent calls concurrently, +and filter or aggregate results before returning them to the agent loop. -- **`packages/codemode` (`@opencode-ai/codemode`)** - the generic, host-agnostic runtime: - a hand-rolled, Effect-native, tree-walking interpreter over acorn ASTs (TypeScript stripped - via `typescript`'s `transpileModule`), the tool runtime/data boundary, discovery/search, and - `Tool.make`. It knows nothing about OpenCode, MCP, permissions, or rendering. -- **`packages/opencode`** - the OpenCode integration: an MCP adapter that converts MCP tool - definitions into `Tool.make(...)` definitions, permission gating, host-side attachment - collection, the agent-facing `execute` tool, and TUI progress rendering. +The goals are: -This package was seeded from the experiments workspace implementation -(`experiments/agents/packages/codemode`, package `@agents/codemode`) and then modified here. -The older vendored interpreter in `packages/opencode/src/session/rune/` was superseded by this -package and was **deleted** in Wave 3 (done, see below). +- Reduce model context consumed by large tool catalogs. +- Avoid an agent round-trip between every dependent tool call. +- Keep large intermediate results inside the program instead of sending them through model context. +- Give generated code only the authority explicitly supplied by the host. ---- +CodeMode is an orchestration language, not a general JavaScript runtime or an application authorization system. -## 2. Locked decisions +### Runtime -From issue #34787 and design discussion. Do not relitigate these casually. +The generic runtime lives in `packages/codemode` and is host-neutral: -### Core direction +1. The host builds a tree of `Tool.make(...)` definitions and calls `CodeMode.make(...)` or `CodeMode.execute(...)`. +2. CodeMode generates model instructions, a budgeted inline catalog, and the internal `$codemode.search` tool. +3. TypeScript syntax is transpiled away, Acorn parses the resulting JavaScript, and an owned tree-walking interpreter + executes it without `eval`. +4. Tool inputs and outputs cross schema and plain-data boundaries before they become visible on either side. +5. Execution returns `CodeMode.Result`. Expected program and tool failures are diagnostic data; host interruption + remains Effect interruption. -- Generic CodeMode lives in its own package: `@opencode-ai/codemode` (repo scope convention; - the issue's `@opencode/codemode` name was normalized to the `@opencode-ai/*` convention). -- **Keep the hand-rolled interpreter.** No QuickJS/V8/sandbox-engine dependency. We own and - test the whole surface; the model only needs orchestration syntax, not a full runtime. -- Naming: `CodeMode`, `Tool`, `ToolError`, `UnknownTool` (diagnostic kind), `$codemode` - reserved discovery namespace. (Historical names - "rune", "capability" - are dead.) -- Existing OpenCode core tools (bash/edit/patch/...) stay registered normally for v1. - CodeMode covers MCP tools, user-registered tools, and deferred tools only. -- Test runner is `bun test`; typecheck is `tsgo --noEmit` (repo conventions). Not vitest. -- **Never reference external prior-art implementations** (other companies' code-execution - products/blog posts) in code, comments, commit messages, or docs in this repo. +Effect Schemas validate and transform tool inputs and outputs. JSON Schemas render model-facing signatures but do not +validate values; adapter-provided values still cross the plain-data boundary. A tool without an output schema is +advertised as `Promise`. -### MCP / tools +### Discovery and model workflow -- The MCP adapter lives in OpenCode, not here. It converts MCP definitions into ordinary - `Tool.make(...)` definitions and hands CodeMode a plain tool tree. -- Permissions stay in the OpenCode adapter (each tool's `run` wraps the permission ask). - CodeMode stays dumb - no permission model in this package. -- Namespace collisions: last write wins (plain JS object override). No `tools.mcp.*` prefix, - no `_2` suffixing, no cleverness. OpenCode groups flat `server_tool` MCP names into - `tools..` namespaces before handing them over. +The model sees a token-budgeted catalog. Every namespace remains visible, and complete signatures are selected +round-robin across namespaces so one large namespace cannot starve the others. `$codemode.search` is always callable +and is advertised when the inline catalog is partial. -### Discovery / search +The intended workflow is: -- **Search only - no separate `describe`.** `tools.$codemode.search({ query?, namespace?, -limit?, offset? })` over the final tool tree, owned by this package. -- Search result item shape: `{ path, description, signature }` in an - `{ items, remaining, next }` - wrapper. The `signature` string embeds the full input/output TypeScript types and uses the - same pretty, JSDoc-annotated multiline form in inline catalogs and search results, so - per-field schema `description`s and constraints (`@default`, `@format`, `@deprecated`, - `@minItems`, `@maxItems`) ride along as field comments. The original spec's separate `input`/`output` - raw-schema fields are deliberately NOT added: shapes are already fully expressed in the - TypeScript signature and schema annotations now arrive as JSDoc - intent satisfied, letter - deviated. Result `path`s render a JavaScript expression rooted at `tools` (for example - `tools.github.list_issues` or `tools.context7["resolve-library-id"]`) so each is directly - usable as the call site; the internal `ToolDescription.path` stays unprefixed. -- `offset` is zero-based and defaults to 0. `remaining` counts matches after the current page; - `next` is `{ offset }` when another page exists and `null` on the final page. -- Search is an internal `Tool.make` definition backed by Effect input/output schemas. Its - validation, output checking, call observation, and TypeScript signature use the same path as - host-provided schema tools. Host-only catalog preparation keeps internal tools out of their - own search index; only conditional advertisement remains special. -- Default limit: **10** (done). Exact-path lookup goes through search too: a query equal to a - canonical tool path, `tools.`-prefixed path, or rendered JavaScript expression returns that - tool alone (done). -- Signatures render **native payloads**: `Promise`, NOT `Promise>`. - There is no result envelope; attachments never appear in return types (they are collected - host-side, see below). -- Tools without an output schema render `unknown` as their return type. +1. Pick an exact signature from the inline catalog, or return `$codemode.search(...)` results and use a selected path + in the next execution. +2. Call the exact returned path without guessing or normalizing segments. +3. Narrow `Promise` results before reading fields. +4. Start independent calls together and await them with `Promise.all`. +5. Filter and aggregate inside the program, then return only the data needed by the model. -### Schemas / Tool.make +Search returns directly usable JavaScript paths, descriptions, and complete TypeScript signatures. It supports exact +path lookup, namespace browsing, deterministic ranking, and pagination. -- `Tool.make` carries rich metadata so search can render real signatures. -- Support **Effect Schema** (first-class, validating) and **JSON Schema** (initially - render-only - used for TypeScript rendering; the adapter may validate on its own). Leave - room for Standard Schema later. -- Tool implementations are **Effect-based** for v1 (`run` returns `Effect`). Promise - normalization for plugin authors can come later. +### Tool execution -### Attachments / output +Calling a tool starts its Effect eagerly on a supervised fiber. The returned sandbox promise is run-once and can be +awaited directly or through the supported `Promise` combinators. At most eight tool calls execute concurrently. +Unfinished calls are drained before successful program completion, and an unhandled call failure becomes a diagnostic. + +The public execution-policy knobs are `timeoutMs`, `maxToolCalls`, and `maxOutputBytes`. The package supplies no +defaults because budgets are host policy. The interpreter also enforces fixed internal boundaries for tool-call +concurrency and data nesting depth. -- **No `output.text/file/image` API in v1.** (Deleted in Wave 2.) -- Tool calls return native structured payloads into the sandbox. Files/images emitted by - child tools **never enter the sandbox** - the OpenCode adapter strips and accumulates them - host-side as calls happen, then returns them on the outer `execute` tool result as ordinary - tool-result attachments (OpenCode already has `Tool.ExecuteResult.attachments` -> vision - plumbing in `message-v2.ts`). -- No base64 in CodeMode values, ever. The model routes nothing; it can't accidentally dump - image bytes into context or drop attachments. +### Data, files, and failures -### Runtime behavior - -- Limits are EXACTLY the three public knobs: `{ timeoutMs, maxToolCalls, maxOutputBytes }` - - matching the original locked spec exactly. NO limit has a default (user direction, Fix 6 - for the first two; extended to `maxOutputBytes` in the truncation-layering fix below): - absent = no timeout / unlimited calls / no output truncation - budgets are host policy. - A host without its own output bounding should set `maxOutputBytes` explicitly, or - oversized results silently flood model context. OpenCode's adapter policy (user - direction): NO limits at all - no timeout, unlimited tool calls (each child call is - permission-gated; user cancel interrupts the execution fiber and its children), and no - CodeMode truncation (output bounding is OpenCode's native tool-output truncation). - The internal limit system that Wave 2 kept behind - an `@internal` `InternalExecutionLimits` type (maxOperations, maxDataBytes, maxValueDepth, - maxCollectionLength, maxSourceBytes, maxAuditBytes, maxConcurrency) was deleted outright in - Fix 5 (see Post-wave fixes). Two internals survive as fixed constants, not knobs: - `TOOL_CALL_CONCURRENCY = 8` (the fork semaphore) and `MAX_VALUE_DEPTH = 32` (the `copyIn` - boundary depth check, kept only because it beats a native stack-overflow RangeError as an - error message; still reports `InvalidDataValue`). -- Truncation layering RESOLVED (user direction): CodeMode truncation is off in OpenCode. - `execute` is a normal `Tool.define` tool, so OpenCode's native tool-output truncation - (50KB / 2000 lines in `tool.ts` + `truncate.ts`, full output dumped to a file) applies to - it with no special-casing - verified by tracing `wrap()` in `tool.ts:130-144` (the - `metadata.truncated` exemption never fires for `execute`). One truncation layer, the - host's. `maxOutputBytes` remains available for hosts without their own bounding. -- Pure-JS built-ins only. **No ambient authority**: no fs, child processes, network/fetch, - process/env, or timers in v1. The agent has the bash tool for that. -- Forgiving JS semantics are locked (see section 3, Wave 1a/1b-i) - missing props read `undefined`, - `typeof` never throws, NaN/Infinity flow in-sandbox, etc. -- `console.*` is captured into `logs` on the result; the host appends them to model-facing - output. Not a tool call; costs no tool budget. -- Simple tool-call **start/end hooks** for nested progress: `onToolCallStart({ index, name, -input })` and `onToolCallEnd({ index, name, input, durationMs, outcome, message? })`. - Interrupted calls fire no end event. No `CurrentToolCall` context service (removed in - Wave 2). - ---- - -## 3. Current status (what is already done on `codemode-v2`) - -Everything below is committed and pushed on `codemode-v2` (six commits, in pairs of -generic-package + OpenCode-integration: waves 0-5, Fixes 4-9, then the DSL-expansion pass / -real-JS error names / truncation layering). Verification: from `packages/codemode`, -`bun test` (211 pass / 0 fail across `codemode/parity/stdlib/promise/enumeration/signature`) -and `bun run typecheck`; from `packages/opencode`, `bun run typecheck` and -`bun test test/tool/` (all green - the adapter suites are `test/tool/code-mode.test.ts`, -43 tests, and `test/tool/code-mode-integration.test.ts`, 16 tests, moved from -`test/session/` by the registry promotion; registry coverage in -`test/tool/registry.test.ts`). - -### Wave 0 - scaffold (done) - -- `packages/codemode` created from the experiments implementation: `src/{index,codemode,tool, -tool-error,tool-runtime}.ts`, README, AGENTS.md, tests. -- `package.json`: name `@opencode-ai/codemode`, deps `acorn@8.15.0`, `typescript: catalog:`, - `effect: catalog:` (both repos pin effect `4.0.0-beta.83`; opencode's effect patch only - touches `unstable/httpapi`, which this package doesn't use). -- Tests converted vitest -> `bun:test`. Only src change from verbatim: the `CurrentToolCall` - Context.Service key string renamed to `@opencode-ai/codemode/CurrentToolCall`. - -### Wave 1a - forgiving JS semantics (done) - -Ported from the old opencode rune work; `test/parity.test.ts` (24 tests) is the acceptance -spec. The seeded interpreter was deliberately strict; these behaviors replaced that: - -- **H1**: NaN/Infinity flow as in-sandbox values (`copyIn` admits them; `NaN`/`Infinity` are - bindable globals; `charCodeAt` returns real NaN). Normalized to `null` only at the data - boundary (`copyOut` - single chokepoint for final results AND tool-call arguments), matching - `JSON.stringify`. Guards like `Number.isNaN(x)` / `parseInt(x) || 0` work. -- **H2/H3**: unknown property reads on strings/numbers/arrays -> `undefined` (incl. under - `?.`), instead of throwing. This was the real-transcript failure: models write - `result?.login ?? result` against JSON-string tool results. -- **H4**: `typeof undeclaredIdentifier` -> `"undefined"` (short-circuits before resolution). -- **H5**: `Boolean`/`String`/`Number` accepted as array callbacks (`filter(Boolean)`). -- **H6**: `{...null}` / `{...undefined}` object spread is a no-op. Array spread of - null/undefined still throws (real JS throws too). - -### Wave 1b-i - stdlib value types: Date, RegExp, Map, Set (done) - -`src/values.ts` holds `SandboxDate/SandboxRegExp/SandboxMap/SandboxSet` (own module so both -`codemode.ts` and `tool-runtime.ts` import without a cycle). Design: - -- Opaque-by-default: all four join `isRuntimeReference`, with explicit carve-outs (member - access allowlists, Date in binary/unary ops, Map/Set in spread/for...of, console formatting, - `containsOpaqueReference` for operator guards; the `runtimeValueBytes` byte-accounting - carve-out died with that machinery in Fix 5). -- **JSON semantics at every boundary and checkpoint**: Date -> ISO string (invalid -> null), - RegExp/Map/Set -> `{}`. `copyIn` also converts host `Date`/`RegExp`/`Map`/`Set` instances the - same way (a host tool may legitimately return them). (Narrowed by the DSL-expansion pass: - intra-sandbox checkpoints now preserve the instances; JSON forms apply at the host - boundary only.) -- Date: `Date.now/parse/UTC`, `new Date(epoch|string|components)`, getters + UTC variants, - `end - start`, `a < b`, `+date`; `toString` is ISO for cross-host determinism. -- RegExp: literals + `new RegExp`, `test`/`exec` (stateful `lastIndex` for `g`), string - `match/matchAll/replace/replaceAll/split/search`. Match results are plain arrays carrying - `index`/named `groups` as own properties (enabled by a general array own-property read fix); - `input` omitted deliberately. Function replacers unsupported (clear error). Patterns run on - the host engine - catastrophic backtracking is bounded only by `timeoutMs` (accepted, in - README). -- Map/Set: full method sets; `keys/values/entries` return **arrays** (not iterators); - `for...of` + spread work; `Object.fromEntries(map)`, `Array.from(map|set)`; SameValueZero - keys (NaN findable). (The incremental byte totals and `maxCollectionLength`/`maxDataBytes` - enforcement this wave added were deleted in Fix 5.) -- Rode along, same spirit: `typeof` never throws for any value (`typeof fn` -> `"function"`), - `!` works on any value, `for...of` over strings, `{...sandboxValue}` no-op, template - interpolation renders `/regex/` and ISO dates directly. - -### Wave 2 - API layer (done) - -The package's public contract, reshaped for the Wave 3 adapter. 101 tests / 0 fail after this -wave; both packages typecheck clean. - -- **`Tool.make` schema flexibility** (`src/tool.ts`): `input`/`output` each accept an Effect - Schema (validating, decoded both directions as before) OR a raw JSON Schema document - (render-only - no validation, values pass through; rendering handles `$defs`/`definitions` - - `$ref`). `output` is **optional** -> signature renders `Promise` and the host - result is exposed as-is. Discrimination via `Schema.isSchema`. New helpers exported from - `tool.ts`: `inputTypeScript`/`outputTypeScript`/`decodeInput`/`decodeOutput`/ - `jsonSchemaToTypeScript`; `tool-runtime.ts` consumes them (no direct `Schema.*` use there - anymore). Types `Tool.JsonSchema`/`Tool.SchemaType` exported from the index. Note: an empty - `Schema.Struct({})` renders as `{ } | Array` (effect's JSON Schema emission) - - cosmetic, fixed in Wave 4. -- **`output.*` API deleted**: `OutputItem`(+Schema), result `output` fields, the `output` - global/namespace dispatch, `invokeOutput`/`outputItem`/helpers, interpreter output fields, - instructions line, README section, seeded tests. AGENTS.md keeps a rephrased - future-design note (channel name stays `output` if it ever returns). -- **Hooks**: `CurrentToolCall` removed entirely (class, provideService, `Services` Exclude - special-casing, index export). `onToolCall` -> `onToolCallStart({ index, name, input })` + - `onToolCallEnd({ index, name, input, durationMs, outcome: "success"|"failure", message? })`. - End fires symmetrically via `Effect.tap`/`tapError` around the settling portion (host run + - output decode + boundary copy; search too - its post-record body is wrapped in `Effect.try` - so failures are typed and observable). `message` is the model-safe failure message - (`ToolError`/`ToolRuntimeError` message, else "Tool execution failed"). Interrupted calls - fire no end event (timeout kills the whole execution anyway). -- **Limits collapse**: public `CodeMode.ExecutionLimits` = `{ timeoutMs?, maxToolCalls?, -maxOutputBytes? }` (defaults 10_000 / 100 / 32_000). This wave kept the other knobs as - internal defaults reachable through an `@internal` `InternalExecutionLimits` type; Fix 5 - later deleted that type and the internal limit system entirely. -- **`maxOutputBytes` truncation** (CodeMode-owned, never fails): applied via `boundOutput` in - a final `Effect.map` over every result path (success/timeout/normalized failure). Oversized - serialized values become truncated text + ` [result truncated: N bytes exceeds the M-byte -output limit; return a smaller value]`; logs keep leading lines within the remaining budget - - `[logs truncated: showing K of N lines]`; result gains `truncated: true` (also added to - `CodeMode.Result`). UTF-8-safe truncation (no split code points). (The in-sandbox - `maxDataBytes` check that used to throw first on oversized raw values died in Fix 5 - - truncation is now the only result-size mechanism.) -- **Search polish**: default limit 12 -> **10** (`defaultSearchLimit`); exact-path lookup - a - trimmed query equal to one tool path (optionally `tools.`-prefixed) returns that tool alone - (`remaining: 0`, `next: null`), bypassing ranking. Tokenization/ranking/shape unchanged. - -### Wave 3 - OpenCode MCP adapter (done) - -`packages/opencode/src/session/code-mode.ts` rewritten as a thin adapter over this package; -the vendored rune interpreter is gone. Same `define(mcpTools, mcpDefs, servers)` signature, so -`tools.ts` gating (flag on + MCP tools exist -> single `execute` tool, early-return suppresses -per-MCP registration; MCP resource tools unaffected) is unchanged. - -- **Tool tree**: `groupByServer` (longest-sanitized-prefix, ported) groups flat `server_tool` - keys into `CatalogEntry`s carrying the raw MCP `inputSchema`/`outputSchema` as render-only - JSON Schema; `toolTree` turns each into `Tool.make({ description, input, output?, run })` - under `tools..`. The agent-facing description is - `CodeMode.make({ tools }).instructions()` over a preview tree (placeholder runs, never - invoked) - so signature rendering, the inline-vs-search switch, and `$codemode.search` - availability all come from this package and stay consistent with execution. -- **`run` path**: per-child permission ask first (`ctx.ask({ permission: entry.key, patterns: -["*"], always: ["*"] })`, exactly the old gating; approving `execute` approves no child). - Denials and host failures are mapped to `toolError(message)` so they surface as safe, - catchable in-program failures (MCP `isError` text propagates as `e.message`; without this - they'd be sanitized to "Tool execution failed"). Dispatch reuses the ai-sdk wrapper from - `catalog.convertTool` (`entry.tool.execute!`), which owns callTool timeouts/progress-reset. -- **Result shaping** (`toSandboxResult`): prefer `structuredContent`; else joined text - content; media (image/audio/resource blob/resource_link) NEVER enters the sandbox - blocks - are stripped into a per-execution `Attachment[]` accumulator, and a media-only result - becomes a marker payload (`"[1 image attached to the result]"`, noun/count adjusted). An - MCP-shaped result with nothing extractable becomes `null`; non-MCP values pass through. - No handles, no `Result` envelope, no base64 in the sandbox, no data-size tuning (the - `maxDataBytes` budget that existed at the time was deleted in Fix 5). -- **Execute result**: `{ output: formatValue(value) + trailing "Logs:" section (success AND -error - logs are plain pre-formatted lines now), attachments: accumulated }` through the - existing `Tool.ExecuteResult.attachments` -> `message-v2.ts` vision plumbing; attachments - ride on both success and error results. Diagnostic `suggestions` not already contained in - the message are appended to error output. Native outer truncation stays on (adapter never - sets `metadata.truncated`); CodeMode's own `maxOutputBytes` (32 KB default at the time) - cut first - since the truncation-layering fix, native truncation is the only layer. - Limits: `{ timeoutMs: 30_000 }` at the time (matched the default MCP request timeout); - killed in Fix 6 - the adapter now passes no limits at all. -- **Progress**: `onToolCallStart`/`onToolCallEnd` -> `ctx.metadata({ toolCalls })` with - `{ tool, status: running|completed|error, input? }` per call index - the exact shape the - TUI `Execute` component (`packages/tui/src/routes/session/index.tsx`) already renders. - `$codemode.search` calls stream through the same channel. -- **Deletions/deps**: `src/session/rune/` (all five files) and - `test/session/rune-parity.test.ts` (superseded by this package's `test/parity.test.ts`) - deleted; `acorn` removed from opencode deps, `typescript` moved back to devDependencies, - `"@opencode-ai/codemode": "workspace:*"` added; `bun install` run (lockfile updated). -- **Tests**: both opencode suites rewritten against the adapter design - - `code-mode.test.ts` (34: grouping, description/signature rendering incl. the large-catalog - search fallback, execution, permission flow + denial, metadata streaming, attachment - accumulation + media-only marker, logs on success/error, truncation marker, - `toSandboxResult`/`formatValue`/`withLogs` units) and `code-mode-integration.test.ts` - (16: real in-memory MCP server; native structured results, attachment accumulation, isError - propagation, logs, permissions, live metadata). Old envelope/attachment-handle/`$rune` - describe/`renderType`/`rankTools` tests died with the old design (58+17+24 -> 34+16). - -### Wave 4 - instructions/prompting + polish (done) - -Instructions are now the budgeted-catalog + prompting-guidance form; verified e2e against a -real MCP config. Package still 101 tests / 0 fail; opencode adapter suites still 34 + 16; both -packages typecheck clean. - -- **Budgeted catalog** (`prepare` in `tool-runtime.ts`): the all-or-nothing - inline/search modes are gone - `DiscoveryMode` deleted, `CodeMode.DiscoveryOptions` is just - `{ maxInlineCatalogBytes? }` (default 16,000 UTF-8 bytes; later converted to - `catalogBudget`, default 4,000 estimated tokens - see Post-wave fixes). Port of - the old opencode - `describe()` `PREVIEW_BUDGET` algorithm, adapted to `ToolDescription`: every namespace is - ALWAYS listed with its tool count; full signature lines - (` - // `) are inlined - cheapest-first (line byte length, path tiebreak) within each namespace, namespaces processed - alphabetically; once one line does not fit, inlining stops for every remaining namespace - (counts only), exactly like the ported algorithm (this stop-everything behavior was later - replaced by round-robin fairness in Fix 8). The header states comprehensiveness - precisely: "Available tools (COMPLETE list - ...)" vs "Available tools (PARTIAL - N of M - shown; find the rest with tools.$codemode.search)"; namespace labels are `(N tools)` / - `(N tools, K shown)` / `(N tools, none shown)`. An empty tree renders "No tools are - currently available." -- **Search always registered** (documented decision): `DiscoveryPlan.searchIndex` is required - and built unconditionally (new exported `ToolRuntime.searchIndex(tools)`; `SearchEntry` type - exported); `CodeMode.execute` (one-shot) passes it too, preserving the - `execute`==`make().execute` law. A speculative `tools.$codemode.search` call on a small - catalog now succeeds instead of `UnknownTool`, and unknown-tool suggestions always point at - search. Search is _advertised_ in the instructions only when the inlined list is PARTIAL, - keeping small-catalog instructions tight. -- **Prompting content** in `instructions()`, mapping 1:1 to the section 5 transcript failures: - parse-string-results-as-JSON, return-small, console-for-intermediates, and - read-the-description-before-calling guidance. (The flat prose layout this wave produced - was later replaced wholesale by the markdown-section restructure - see Post-wave fixes - - which also deleted this wave's worked example.) -- **Cosmetic renderer fixes** (`renderSchema` in `tool.ts`): an object schema with no - properties renders `{}` (was `{ }`), and the empty `Schema.Struct({})` emission - (`anyOf: [{ type: "object" }, { type: "array" }]`, no properties/items) collapses to `{}` - (was `{ } | Array`). -- **Tests**: 4 package discovery tests rewritten for the budgeted behavior (COMPLETE small - catalog + search-still-registered; PARTIAL at budget 0; cheapest-first selection + - per-namespace labels + budget-exhaustion stopping later namespaces; mode-validation - assertion dropped); 3 opencode description assertions updated (COMPLETE/PARTIAL headers, - namespace labels, `(input: {})` rendering, cheapest-first op_0 shown / op_149 not). -- **E2E (verified, headless)**: from the repo root with `OPENCODE_EXPERIMENTAL_CODE_MODE=1`, - the scratch `.opencode/opencode.jsonc` (context7, github, playwright, sentry, memory, - sequential-thinking; left uncommitted/as-is), and `bun packages/opencode/src/index.ts run ---dangerously-skip-permissions -m opencode/claude-sonnet-4-5 "..."`. Confirmed: a single - `execute` tool registered alongside core tools (per-MCP registration suppressed; MCP - resource tools unaffected); the live description read back as "Available tools (PARTIAL - - 56 of 88 shown; find the rest with tools.$codemode.search):" with correct per-namespace - labels (context7/github/memory fully shown; playwright/sentry/sequential-thinking "none - shown" - the alphabetical-exhaustion starvation Fix 8 later replaced with round-robin - fairness); programs executed with in-program `$codemode.search` - calls and returned the correct answer. NOT verified e2e (headless only; covered by - unit/integration tests instead): TUI child-call rendering, attachments becoming visible - images, output truncation. - -### Wave 5 - Promise generalization (done) - -First-class promise values in the interpreter; the direct-tool-call-only `Promise.all` -restriction (and its bespoke AST checks) is gone. Package suite is 136 tests / 0 fail (35 new -in `test/promise.test.ts`); adapter suites and both typechecks unchanged/green; the opencode -adapter needed **no changes**. - -- **Decision: eager fork** (`const p = tools.a.b(x)` starts the call immediately on a - supervised child fiber; `await p` observes its settlement). Chosen over lazy because: - (1) it's spec-faithful - JS promise work starts at call time, so - `const a = t1(); const b = t2(); return [await a, await b]` gets real parallelism instead of - silently sequential awaits; (2) run-once is free - a fiber settles exactly once and - `Fiber.await` is idempotent, so `await p` twice or `Promise.all([p, p])` can never re-invoke - the tool (lazy needs a deferred/latch to match); (3) effect's structured concurrency does the - hard part - `Effect.forkChild` children are auto-supervised (interrupted when the parent - fiber exits) and `Effect.timeoutOrElse` is `raceFirst`, which runs the program on its own - raced fiber, so forked calls cannot escape the timeout (tested: in-flight forks are - interrupted, awaited or abandoned, direct or inside `Promise.all`). -- **Mechanics**: `SandboxPromise` in `values.ts` (fiber-backed for tool calls; fiberless - `immediate` effect for `Promise.resolve`/`reject`). Forks run - `semaphore.withPermit(invoke)` with `startImmediately: true` - a per-execution - `Semaphore.makeUnsafe(TOOL_CALL_CONCURRENCY)` (fixed 8, see Fix 5) caps live calls (the - "Effect.all or equivalent" cap lives where the work is, so combinator joins can be - sequential without losing parallelism), and the tool-call-count charge (`recordCall`) plus - `onToolCallStart` fire at the call site before any await. `await` of a non-promise is a passthrough no-op; a returned - top-level promise resolves like an async-function return (`return tools.a.b(x)` works - without await). -- **Promise combinators are normal functions over values**: `Promise.all`/`allSettled`/`race` - accept any array (or spreadable collection) mixing promises and plain data - inline, built - beforehand, spread, nested in variables. `allSettled` yields - `{ status: "fulfilled", value } | { status: "rejected", reason }` with reasons produced by - the same `caughtErrorValue` helper the `catch` binding uses (factored out of - `evaluateTryStatement`). `race` resolves/rejects with the first settlement and interrupts - losing in-flight calls; awaiting an interrupted loser afterwards is a catchable program - failure ("interrupted because another value settled a Promise.race first"), while any other - interrupt-only settlement keeps propagating as interruption (preserving the - host-interruption law). `Promise.resolve` flattens promises; `Promise.reject` rejects with - the reason via `ProgramThrow`. -- **Opaqueness/boundaries**: promises are runtime references - `typeof` -> `"object"` (real JS), - operators reject them, `copyIn` raises an await-hinting `InvalidDataValue` ("contains an - un-awaited Promise; await tool calls (...) before using their results") for results, tool - arguments, and `JSON.stringify` instead of `{}`. Property access on a promise is a - deliberate error (not the forgiving `undefined`): `.then/.catch/.finally` -> - `UnsupportedSyntax` pointing at `await` + try/catch; anything else -> "await it first". - `new Promise(...)` -> UnsupportedSyntax ("tool calls already return promises"); - `Promise.` lists the five available statics. `console.log(p)` prints - `[Promise (await it to get its value)]`. -- **Program-end drain**: on successful completion the interpreter awaits still-running - un-awaited fibers (like a runtime waiting on in-flight I/O at exit), so fire-and-forget - calls complete deterministically; a failure nobody could have handled surfaces as an - "Unhandled rejection from an un-awaited tool call: ..." diagnostic (kind preserved, - suggestion says to await) - keeping pre-wave failure visibility for un-awaited - statement-position calls. Settlement observation (await/all/allSettled/race) marks a - promise handled; failed executions skip the drain and children are interrupted by - supervision. -- **Deletions/updates**: `evaluatePromiseAll`, `evaluateParallelMap`, `isToolCallExpression`, - `isToolPath`, `forkForParallelCallback`, and `PromiseAllReference` deleted - (`PromiseMethodReference` over `all/allSettled/race/resolve/reject` replaces it); - `supportedSyntaxMessage`, the two instructions lines in `tool-runtime.ts`, and README - "Supported Programs" rewritten for the new surface. -- **Known divergences (deliberate)**: `p === q` on promises throws the operators-need-data - diagnostic instead of comparing identity; `{...promise}` errors instead of JS's silent `{}`; - a per-iteration `await` inside `items.map(async (i) => await tools.x(i))` runs sequentially - (interpreter callbacks compose synchronously) - the parallel idiom is mapping to un-awaited - calls and awaiting `Promise.all`, which the instructions show. - -### Post-wave fixes - -- **Key enumeration: `Object.keys(tools)` + `for...in` (done).** Motivating transcript: a - model tried to enumerate tool namespaces with `Object.keys(tools)` (failed with the generic - "Object.keys input must contain plain objects only." - `tools` is a `ToolReference`, not - plain data) and then `for (const key in tools)` ("Syntax 'ForInStatement' is not - supported"), and had to fall back to guessing namespace names from the instructions - - defeating discovery. Fixes, all in this package: - - `ToolRuntime.make` now returns a `keys(path)` capability (`namespaceKeys` in - `tool-runtime.ts`) threaded into the `Interpreter` alongside `invoke` - the interpreter - still never holds the callable tool tree. `Object.keys(tools)` yields the top-level namespace - names, including the internally registered `$codemode`; `Object.keys(tools.$codemode)` yields - `["search"]`, and `Object.keys(tools.ns)` yields names at that node; a callable tool leaf - enumerates as `[]` (like `Object.keys` of a JS function); an unknown path throws an - `UnknownTool` diagnostic suggesting `Object.keys(tools)` and `$codemode.search` (matching - call-time unknown-tool behavior rather than silently returning `[]`). - - `Object.values`/`Object.entries` (and every other `Object.*` helper) on a tool reference - now fail with "...not plain data. Use Object.keys(tools) for names, or - tools.$codemode.search({ query }) for signatures." instead of the generic message. - - `Object.keys(array)` returns index strings (`["0", "1", ...]`) like real JS (was a - Backlog item). - - `for...in` (ForInStatement) iterates own enumerable string keys of plain objects, index - strings of arrays, and namespace/tool names of tool references - sharing the interpreter's - `enumerableKeys` helper with the `Object.keys` tool path. const/let declarations and bare - identifiers bind the key; break/continue work. Anything else (strings, Map/Set, numbers, - null, ...) is a clear error suggesting `for...of` or `Object.keys` - deliberately smaller - than real JS (which yields indices for strings and zero iterations for Maps/Sets/null). - - `supportedSyntaxMessage`, the instructions loops line, and README "Supported Programs" - mention the new surface; tests in `test/enumeration.test.ts` (14, incl. the exact - transcript program) plus one adapter-level assertion that `Object.keys(tools)` returns - MCP server and CodeMode namespace names. - -- **Search ranking, namespace scoping, prefixed result paths (done).** - Motivation: the Wave 4 e2e run showed a model retrying calls because search-result paths - lacked the `tools.` prefix (a Backlog item), and the word-set ranker missed - parameter-name and partial-word queries. Fixes: - - **Ranking ported from the pre-rebuild implementation** (the `searchTextFor`/`tokenize`/ - `rankTools` algorithm in `packages/opencode/src/session/code-mode.ts` at git HEAD), - replacing the word-set ranker in `tool-runtime.ts`. Searchable text per tool = path + - description + input-schema property names + their `description` strings - extracted by - the new `inputProperties` helper in `tool.ts` (Effect Schemas via - `Schema.toJsonSchemaDocument`, the same emission signature rendering uses; JSON Schemas - read `properties` directly, resolving a trivial top-level `$ref`; try/catch falls back to - path + description). Queries tokenize on camelCase boundaries + non-alphanumeric - separators (empties and `*` dropped). Additive per-term scoring: exact path or - path-segment match 20, path substring 8, description substring 4, searchable-text - substring 2; summed across terms, filtered to score > 0, sorted score desc then path asc - (Fix 8 later made each field check accept the term OR a naive singular variant). - An empty query now browses ALPHABETICALLY by path (was declaration order). Kept: - `{ path, description, signature }` result items, default limit 10, exact-path instant - lookup, input validation errors. - - **Namespace scoping**: `tools.$codemode.search({ query?, namespace?, limit?, offset? })` - - `namespace` (validated as a string when provided) filters `SearchEntry`s to one top-level - namespace before ranking; `{ query: "", namespace: "github" }` lists that namespace - alphabetically. `searchSignature` updated. - - **Callable result paths**: search-result `path`s are rendered as JavaScript expressions - rooted at `tools` (`tools.github.list_issues`, or bracket notation for non-identifier - segments), directly usable as the call site. Internal `ToolDescription.path` stays - unprefixed; only the search RESULT items are rendered this way. Exact-path queries accept - canonical paths and rendered expressions. - - **Instructions** (`prepare`): an explicit calling-convention line and a browse - hint on the search advertisement (both since absorbed into the `## Rules` section by - the instructions restructure below). - - **Tests**: package search/discovery tests updated (prefixed paths, alphabetical browse) - plus new coverage for namespace scoping, parameter-name matching, partial-word substring - matching, alphabetical empty-query order, and prefixed exact-path lookup; one adapter - assertion updated to the prefixed path (suites stay 35 + 16, green). - -- **Instructions restructure: markdown sections, placeholder-only call forms (done).** - The flat prose instructions (which mixed a real catalog tool with fabricated result - fields in the worked example) are replaced by structured markdown in `prepare`, - ordered so the workflow sits at the top (the least likely part of a long description to - be truncated or skimmed away) and the catalog at the bottom (the per-section content - described here was later condensed by Fix 8 and the language-accuracy pass): - - **Intro**: identifies the language as restricted JavaScript for calling tools rather than - a general-purpose runtime. - - **`## Workflow`**: with a partial catalog, return search results from one execution, then - copy a selected path into the next execution. With a complete catalog, pick and call an - inlined signature, then return only the needed fields. - - **`## Rules`**: narrow unknown results at runtime; filter/aggregate large collections in code instead of per-item round-trips; - console.log/warn/error/dir/table for intermediates; `Promise.all` parallelism (no - .then/.catch - await + try/catch); `Object.keys(tools)`/`for...in` enumeration; - browse-one-namespace via search (PARTIAL only); and host-side media handling (files/ - images never enter the program; a media-only call yields a small text marker - wording - verified against the adapter's `toSandboxResult`/`mediaMarker`). - - **`## Language`**: a concise positive capability summary plus the major unavailable - runtime capabilities and the data-boundary serialization note. - - **`## Available tools`**: the budgeted catalog unchanged, with the COMPLETE/PARTIAL - header merged into the section heading (no trailing colon); the search-signature - advertisement follows when PARTIAL (its description-reading and browse clauses moved - to Workflow/Rules). - - Every call form in Workflow/Rules uses explicit `.`/`` - placeholders - the example builder that derived a worked example from the first inlined - catalog tool (`exampleArguments` + the example-selection machinery) is DELETED, so no - real catalog tool is cherry-picked into examples and no fabricated names or fields - appear anywhere in the instructions. Zero tools keep "No tools are currently - available." under minimal sections (intro + Syntax + Available tools). - - **Tests**: the package worked-example test replaced by section-structure/placeholder - assertions (section order; unknown-result + return-small rules present; no - `total_count`/`list_issues`/real-tool example lines; browse hint only when PARTIAL; - zero-tool minimal sections) - 156 pass / 0 fail; adapter suites gain the same - assertions on the built description (still 35 + 16, green). - -**Fix 4 - token-budgeted catalog (was bytes)** (user direction: signatures need a token -budget; namespaces must always be present): - -- `src/token.ts` added: copy of `@opencode-ai/core/util/token` (`round(chars / 4)`), so - the package stays dependency-free; keep in sync if the core heuristic changes. -- `CodeMode.DiscoveryOptions.maxInlineCatalogBytes` -> `catalogBudget` (default 4,000 - estimated tokens ~ the old 16,000 bytes at 4 chars/token - behavior parity, not a size - reduction). `prepare` charges `estimate(catalogLine(tool))` per line; cheapest-first - - stop-on-first-miss unchanged at the time (stop-on-first-miss replaced by round-robin in - Fix 8). Namespace stub lines were and remain unbudgeted - every - namespace always appears with its tool count, even at budget 0 (asserted in package and - adapter tests). -- Ripple: chars/4 rounding erases small line-length differences, so equal-cost lines fall - to the lexicographic path tiebreak; the adapter's PARTIAL test now asserts the - lexicographic tail (`op_99`) is excluded instead of `op_149`. Fixed-prose measurements - (2026-07): preamble ~44 + Workflow ~146 + Rules ~362 + Syntax ~453 ~ 1,100 tokens fixed; - worst-case net description ~ fixed + 4,000 ~ 5,100 estimated tokens. - -**Fix 5 - internal limits removed** (user direction: only the three PUBLIC limits survive as -configurable knobs; the internal limit system dies): - -- `CodeMode.ExecutionLimits` (`timeoutMs` 10_000 / `maxToolCalls` 100 / `maxOutputBytes` 32_000 at - the time; Fix 6 later removed the first two defaults. Same validation: safe integers, - timeoutMs >= 1, others >= 0, RangeError otherwise) is now - the ENTIRE limit surface - exactly the shape section 2's original locked spec named. - `ResolvedExecutionLimits` shrank to those three fields; the `@internal` - `InternalExecutionLimits` type is deleted. -- **Deleted outright**: `maxOperations` and the whole operation-budget machinery - (`recordWork`/`recordOperation`/`budget.operations`, plus the `workUnits`/ - `cheapArrayMethods` cost helpers); `maxSourceBytes` (the pre-parse source-size check); - `maxDataBytes` (every byte-accounting path: `runtimeValueBytes`, `boundedProgramValue`, - the container-size caches (`containerSizes`/`objectCounts`), Map/Set incremental `bytes` - fields in `values.ts`, string-growth `limitString` checks, tool-argument/result byte - checks in `tool-runtime.ts`, and the final-result size check); `maxAuditBytes` (log and - audit-trail byte accounting - `toolCalls` records and the start/end hooks are unchanged); - `maxCollectionLength` (every array-length/object-field-count check - this knob was - actively harmful: an MCP tool returning 20k rows failed). The `OperationLimitExceeded` - and `AuditLimitExceeded` diagnostic kinds are gone from the `DiagnosticKind` union and - `CodeMode.Result` (fine - the package is unreleased). -- **Fixed constants, not knobs**: `TOOL_CALL_CONCURRENCY = 8` (codemode.ts; the fork - semaphore) and `MAX_VALUE_DEPTH = 32` (tool-runtime.ts; the `copyIn` depth check - kept - only because it produces a clearer error than a native stack-overflow RangeError; still - `InvalidDataValue`). The `DataLimits` plumbing through `tool-runtime.ts` is gone - - `copyIn(value, label)` needs no limits argument, and `ToolRuntime.make` takes just - `(tools, maxToolCalls, hooks?, searchIndex?)`. -- **Verified fact**: timeout interruption does NOT depend on the operation budget - the - Effect fiber runtime auto-yields between interpreter steps, so `timeoutMs` interrupts - even a pure `while (true) {}` loop (empirically verified: a 200ms timeout fired at - ~225ms with maxOperations set to MAX_SAFE_INTEGER before the deletion). A regression - test in `codemode.test.ts` asserts exactly this (`while(true){}` + `timeoutMs: 200` -> - `TimeoutExceeded`, elapsed well under a few seconds). -- **Kept (correctness, not budgets)**: circular detection (`copyIn` walks + - `rejectCircularInsertion` on mutations), plain-objects-only, blocked properties - (`__proto__`/`constructor`/`prototype`), data-only checks, and all three public-limit - behaviors unchanged. -- Behavior deltas beyond the intended kills: in-sandbox structures deeper than 32 levels - now fail at the data boundary (`copyIn`) instead of at construction; array index - assignment allows any non-negative integer index (holes permitted, message now "must be - a non-negative integer"); interpreter-produced deep/hostile structures that overflow the - native stack during a walk still normalize to the existing "Execution exceeded the - maximum nesting depth." data diagnostic - failures remain data everywhere. -- Tests: deleted the knob-only tests (stdlib Map/Set collection-length growth x2, - enumeration operation-budget, codemode maxDataBytes/maxSourceBytes/maxOperations/ - maxConcurrency-RangeError assertions, and the adapter's runaway-loop-via-operation-limit - test - superseded by the package timeout regression test); rewrote the helpers that used - `InternalExecutionLimits` as a convenience to plain `CodeMode.ExecutionLimits` - (promise/enumeration/stdlib run helpers). Package suite: 154 pass / 0 fail; adapter - suites: 34 + 16. - -**Fix 6 - no default timeout / tool-call cap** (user direction): `timeoutMs` and -`maxToolCalls` lost their defaults (were 10_000 / 100) - absent now means no timeout / -unlimited calls. Budgets are host policy, not library policy; `maxOutputBytes` kept its -32,000 default at the time (removed later - see the truncation-layering entry: absent now -means no truncation). `ResolvedExecutionLimits` carries `number | undefined` for both, the -timeout wrapper is only applied when configured, and `ToolRuntime.make` treats undefined -`maxToolCalls` as uncapped. Validation is unchanged when values ARE provided (safe integers, -timeoutMs >= 1, others >= 0). The OpenCode adapter is unaffected in behavior it sets -(explicit 30s timeout) but now runs with unlimited tool calls. Immediately after, per user -direction, the adapter's 30s timeout was killed too: `CODE_LIMITS` is deleted and OpenCode -passes NO limits - no timeout, no tool-call cap. Rationale: user cancel interrupts the -execution fiber and structured concurrency takes the program and in-flight child calls down -with it; every child call is permission-gated; output truncation (32KB default) is the only -active bound. New regression test: 150 tool calls succeed with no limits configured (would -have tripped the old default 100). Package suite: 155 pass / 0 fail. - -**Fix 7 - JSDoc-annotated search signatures**: `tools.$codemode.search` result signatures are -now the pretty, indented multiline form with per-field JSDoc - ported from the pre-rebuild -rune renderer in this repo's git history (`renderType(def, { pretty })`/`docTags`/`jsdoc`/ -`renderObject`), adapted to the current renderer's conventions (`Array`, `unknown` -fallback, existing `$defs`/`$ref` handling and empty-object `{}` collapse; the old -`Result`/`returnType` machinery was deliberately not ported - payloads stay native). -Semantics: each described input/output field carries its schema `description` as a -`/** ... */` comment at the right indent (nested objects recurse deeper); constraints TS can't -express surface as JSDoc tags - `@deprecated`, `@default ` (unserializable defaults -skipped), `@format`, `@minItems`/`@maxItems`; `*/` inside text is neutralized to `* /`; -multiline descriptions become `*`-prefixed blocks with blank edges trimmed; undescribed, -untagged fields get no comment. Implementation: `renderSchema` in `tool.ts` grew a -`RenderContext` (`{ definitions, pretty }`), a `MAX_RENDER_DEPTH = 8` recursion ceiling plus -a `$ref` `seen` guard (the renderer previously had neither - a cyclic `$defs` would have -looped; it now degrades to the ref name/`unknown`), and try/catch totality on the public -helpers (`toTypeScript`/`jsonSchemaToTypeScript`/`inputTypeScript`/`outputTypeScript` never -throw - pathological schemas render `unknown`); each helper takes an optional trailing -`pretty = false` parameter, so existing callers are unchanged and compact output stays -byte-identical (inline `catalogLine`s and the token budget depend on it). `SearchEntry` -gained an eagerly-computed `signature` field (built once per tool at index-build time in -`toSearchEntry` - rendering is cheap and the search hot path stays allocation-free); both -ranked results and exact-path lookups serve it. Works for both tool kinds: Effect Schema -annotations (`Schema.String.annotate({ description })`) flow through the emitted JSON -Schema, and raw JSON Schema (MCP) property metadata is read directly - both covered in -`test/signature.test.ts` (12 tests) plus one strengthened adapter assertion (MCP property -description appears as JSDoc in a live search result; the tool description/catalog contains -no `/**`). README search section updated with an example. Package suite: 167 pass / 0 fail; -adapter suites: 34 + 16. - -**Fix 8 - condensed instructions + round-robin catalog fairness + plural-aware search** -(user direction: the fixed instruction prose was too verbose; two discovery fixes ride -along). All in `tool-runtime.ts`; no interpreter changes. - -- **Syntax section inverted**: the three dense allowlist lines (~453 estimated tokens) - are replaced by four short lines (~188) built on "models already know JavaScript; name - only what is unusual or missing": (1) standard modern JS works - functions/closures, - destructuring, template literals, loops, try/catch, spread, optional chaining, the - usual Array/String/Object/Math/JSON methods, plus Date/RegExp/Map/Set and - Promise.all/allSettled/race/resolve/reject; (2) TypeScript type annotations are - stripped before execution, decorators are not supported; (3) NOT supported (each fails - with a message naming the alternative): classes, generators, for await...of, - .then/.catch/.finally (use await with try/catch), `x instanceof Error` (caught errors - are plain `{ name, message }` objects), splice; (4) the data-boundary note (Dates -> - ISO strings; Map/Set/RegExp -> `{}`). Every claim was verified against the interpreter - before writing: probed empirically - classes/generators/for-await/.then/.catch/ - .finally/`instanceof Error`/splice/decorators/BigInt/labeled statements/tagged - templates/object getters all fail with clear diagnostics; TS annotations/`as`/ - interfaces/type aliases are stripped and TS **enums actually work** (transpileModule - compiles them to an IIFE the interpreter runs), hence enums deliberately unmentioned. - `supportedSyntaxMessage` (the in-diagnostic text in `codemode.ts`) is untouched. -- **Workflow/Rules deduped**: the call-by-exact-path and return-small content now lives ONLY - in the numbered Workflow steps; Rules keeps only bullets adding new - content - filter/aggregate collections in code, console.\* intermediates (logs ride - back), Promise.all parallelism, Object.keys/for...in enumeration, browse-namespace - (PARTIAL only), and the media rule compressed to one line. The no-.then/.catch - guidance moved to the Syntax not-supported line. Content upgrades: the PARTIAL search - step gained query-style guidance (`- short phrases like "list issues" work best`; a - clearly-a-query-string example, not a tool name), and the exact-path guidance is now - "call it with the result's `path` as-is (never guess segments)" / COMPLETE: "use it - as-is rather than guessing segments". -- **Fixed-prose measurements** (instructions split on `"\n## "`, catalog budget 0, - bytes/3.7 - same method as Fix 4; chars/4 in parentheses): - preamble 44 -> 44 (41 -> 41), Workflow 146 -> 187 (135 -> 171), Rules 362 -> 191 - (332 -> 176), Syntax 453 -> 188 (419 -> 174); fixed prose total 1,005 -> 610 (927 -> 562), - ~ 40% reduction with no behavioral content dropped. Workflow grew slightly because it - absorbed the deduped parse/return-small justifications. -- **Round-robin namespace inlining** (`prepare`): the ported stop-on-first-miss - behavior (alphabetically-late namespaces starved to "none shown" while an early - namespace inlines everything) is replaced by round-robin fairness - in each round - (namespaces alphabetical), every namespace still holding un-inlined tools attempts to - place its next-cheapest line against the shared token budget; a namespace whose next - line does not fit is done while the others keep going; stop when all are done. Every - namespace gets some representation before any namespace gets everything. Kept: - `estimate` (chars/4) budget accounting, unbudgeted namespace stub lines, per-namespace - `(N tools)`/`(N tools, K shown)`/`(N tools, none shown)` labels, COMPLETE vs PARTIAL - header, alphabetical namespace order in the output, cheapest-first within each - namespace's shown set. -- **Plural/singular search fix**: `tokenize`d terms matched one-directionally (term must - be substring of indexed text), so query "issues" missed a tool whose text only says - "issue". Now each term expands to `termForms` - the term plus naive singular variants - (trailing "es" stripped when length > 3, trailing "s" when length > 2) - and each of - the four field checks passes when ANY form matches. Weights, exact-path lookup, and - namespace scoping untouched. A true plural path match still outranks a singular-only - description match (path substring 8 + searchable 2 > description 4 + searchable 2). -- **Tests**: package instruction/structure assertions updated to the new text; the - language-section test rejects full-runtime wording, names major unavailable capabilities, - and keeps the data-boundary note; the budget-exhaustion - test rewritten to assert the new fairness (alpha.expensive not fitting must NOT - prevent beta.cheap from showing: PARTIAL 2 of 3, `- beta (1 tool)` fully shown); new - plural/singular test (query "issues" finds a singular-only tool; ranking still - prefers the true "issues" path match). Adapter: description assertions updated; the - large-catalog PARTIAL test now asserts `zeta_only_tool` IS shown (`- zeta (1 tool)` + - its inlined line) - it was "none shown" under starvation. README updated (budgeted - catalog paragraph -> round-robin; search paragraph -> singular variants; - instructions-structure paragraph -> new section contents). Package suite: 169 pass / - 0 fail; adapter suites: 34 + 16. - -**Fix 9 - prompting trims per user review of Fix 8** (user reviewed the condensed -instructions and directed further cuts): - -- Default `catalogBudget` 4,000 -> **2,000** (user wants ~2k tokens of signatures - auto-inlined; round-robin fairness from Fix 8 spreads it across all namespaces). -- Console rule and files/images rule DROPPED from `## Rules`. Replaced by a single - `unknown`-treatment warning: "A result typed `Promise` has no guaranteed - shape - verify what actually came back before relying on its fields." (Deliberately - does NOT suggest console.log - user review: naming it there nudges models to log AND - return the same data; the prompt stays console-neutral, neither for nor against.) - The media-stripping MECHANISM is unchanged and still tested; only the prose about it - is gone - the `[N images attached]` marker is self-explanatory in context. -- Later revised: unconditional JSON parsing was removed because text results are not - necessarily JSON. The browse-namespace rule remains; the language section now states that - ambient `fetch` is unavailable and external operations go through Code Mode tools. -- Explicitly REJECTED for now: auto-parsing JSON-looking text results at the adapter - boundary ("could get weird" - type flips, program-sees vs tool-sent divergence). Logged - as a next-iteration follow-up below. - -**DSL-expansion pass - interpreter-surface batch from section 4** (the deferred medium-tier JS -parity items, done as one focused pass; no public API or limit changes): - -- **`instanceof` + real Error values**: the `errorConstructors` names (`Error`, - `TypeError`, `RangeError`, `SyntaxError`, `ReferenceError`, `EvalError`, `URIError`) are - bound globals (`ErrorConstructorReference`, callable with or without `new`; `typeof` -> - `"function"`). Error values stay the same plain `{ name, message }` null-prototype - objects as before - the constructor name additionally rides on a NON-ENUMERABLE symbol - key (`ErrorBrand`), which every `Object.entries`-based walk (copyIn/copyOut, spread, - JSON.stringify) is blind to, so serialization is byte-identical to the old shape and the - brand is lost on spread/boundary copies exactly like JS loses the prototype. - `caughtErrorValue` produces `{ name, message }` wrappers via `createErrorValue`, so - caught interpreter AND tool failures are `instanceof Error` and carry the `name` the - equivalent real-JS failure would have (follow-up fix, user-directed - "closest to real - JS"): `InterpreterRuntimeError` gained an `errorName` field ("Error" default) set - fluently at throw sites via `.as(name)` - `JSON.parse` failures are `"SyntaxError"` (and - now include the engine's position detail in the message; safe - derived from the - program-supplied string), invalid regex patterns/flags `"SyntaxError"`, unknown - identifiers and TDZ access `"ReferenceError"`, assignment to a constant `"TypeError"`, - a bad `normalize` form `"RangeError"`; a host Error reaching the catch path directly - keeps its own name when it is one of the standard seven. Tool failures and everything - without a specific analogue stay `"Error"` - internal class names never leak. Specific - names satisfy the specific `instanceof` (`e instanceof SyntaxError`), matching JS. - The operator is handled in `evaluateBinaryExpression` - BEFORE the data-only operand check (like `typeof`, it observes any lhs - promises and - functions included); recognized rhs: the error constructors (a specific type matches its - own brand or `Error`, never a sibling), `Date`/`RegExp`/`Map`/`Set` (sandbox classes), - `Array`, `Object` (any object/function-ish value), `Promise` (`SandboxPromise`), and - `Number`/`String`/`Boolean` (always false - no boxed values exist); anything else is a - catchable error naming the recognized constructors. -- **Array methods**: `splice` (mutating, returns the removed elements; insertions run - `rejectCircularInsertion` like push/unshift; one-arg form removes to the end, undefined - delete count removes nothing), `fill` (circular-checked value) and `copyWithin` - (host-delegated), and `keys`/`values`/`entries` returning **arrays** (the Map/Set - convention - for...of and spread work either way). The `retryableArrayMethods` - "rewrite using map/filter" hint set emptied out and was deleted with its branch; unknown - array properties still read `undefined`. -- **String methods**: `localeCompare(that)` (locale/options arguments ignored - host - default locale; the dominant use is a sort comparator), `normalize(form?)` (invalid form - -> catchable error naming the four valid forms), `trimLeft`/`trimRight` as - trimStart/trimEnd aliases. -- **Actionable regex failures**: `toHostRegex` and `constructRegExp` now show the - offending pattern (or flags) plus the engine reason (deduped "Invalid regular - expression:" prefix via `regexFailureReason`) and a shared escaping hint - (`escapeRegexHint`); flags failures list the valid flag letters; the - replaceAll/matchAll missing-`g` errors spell out the exact `/pattern/g` to write and - the single-match alternative. -- **copyIn split (the important one)**: `copyIn(value, label, preserveSandboxValues = -false)` - recursion moved to a private `copyBounded`; `boundedData` (every intra-sandbox - checkpoint: `Object.*` helpers, coercion/Array.from/join inputs, template - interpolation, expression-result checkpoints) is now `copyIn(value, label, true)`, - which passes `SandboxDate`/`SandboxRegExp`/`SandboxMap`/`SandboxSet` through **by - reference as leaves** (contents not walked - Map/Set members are validated at their - mutation sites) while keeping the depth (`MAX_VALUE_DEPTH`), circularity, - plain-objects-only, blocked-property, and data-only checks; un-awaited promises keep - the await-hinting rejection in BOTH modes (deliberate - JS-parity pass-through was - considered and skipped to preserve the nudge). The HOST boundary (final result, - tool-call arguments, `JSON.stringify`, tool-result intake) uses the default mode and - still serializes JSON forms (Date -> ISO, RegExp/Map/Set -> `{}`); host instances met on - the preserving path are defensively wrapped into sandbox equivalents. Ripple: the - `Object.*` helpers treat sandbox values as empty objects (`Object.keys(map)` -> `[]`, - assign sources contribute nothing, hasOwn -> false - JS has no own enumerable props - there), so interpreter internals (`.map`/`.time`/`.regex`) can never leak; the - template-literal sandbox carve-out collapsed into `boundedData`. Object/array spread - already preserved instances (reference copies, no checkpoint) - now tested. -- **Console formatting**: `formatConsoleArgument` is total and deep - (`formatConsoleValue`): numbers render via `String` (`NaN`/`Infinity`/`-Infinity` - literally - never the JSON `null`; finite numbers match their JSON form), nested - strings are JSON-quoted, sandbox values keep their friendly forms at ANY depth (ISO - date, `/regex/flags`, `Map(n) [...]`, `Set(n) [...]`), opaque references become - in-place `[CodeMode reference]` markers instead of collapsing the whole argument, - cycles render `[Circular]` (reachable via Map/Set members, which mutation never - checkpoints), and depth beyond `MAX_CONSOLE_DEPTH = 32` (fixed constant, not a knob) - degrades to `...` - console can no longer fail a program. `console.table` guards with - `containsOpaqueReference` (sandbox cells render, e.g. ISO dates) and its row/cell - walkers treat sandbox values as scalar cells. -- **Prose**: the instructions Syntax not-supported line dropped its `instanceof -Error`/splice mentions (nothing else reworded); README updated (checkpoint - preservation vs boundary serialization, error values/`instanceof`, new array/string - methods, regex-failure behavior); `supportedSyntaxMessage` left untouched (it lists - supported syntax, was already non-exhaustive, and stays accurate). -- **Tests**: package suite 169 -> 209 (parity: Error/instanceof + real-JS error-name - coverage, splice/fill/copyWithin/keys/values/entries, localeCompare/normalize/trim-alias - describes; stdlib: checkpoint survival incl. tool-arg boundary pinning, stdlib - `instanceof`, regex-message assertions; codemode: NaN/Infinity + nested/cyclic console - rendering, table cells, caught-tool-failure `instanceof`); adapter suites unchanged - (34 + 16, green); both packages `tsgo --noEmit` clean. - -**Truncation layering - CodeMode truncation off in OpenCode** (user direction; resolves the -section 4 outer-truncation item the OPPOSITE way from "kill the outer one"): - -- `maxOutputBytes` lost its 32,000 default and now behaves exactly like the other two - limits: absent = no truncation. All three limits are uniformly no-default - budgets are - host policy. `ResolvedExecutionLimits.maxOutputBytes` is `number | undefined`; - `boundOutput` only runs when the host set the limit. Explicit values validate as before - (safe integer >= 0). -- OpenCode continues to pass NO limits, which now also means no CodeMode truncation. - `execute` is a normal `Tool.define` tool, so OpenCode's native tool-output truncation - applies with no special-casing - verified by tracing `wrap()` (`tool.ts:130-144`, - 50KB/2000-line thresholds in `truncate.ts`, full output dumped to a file under - `tool-output/`): the `metadata.truncated` self-truncation exemption never fires for - `execute` (its metadata never sets that key). One truncation layer, the host's - and it - is the richer one (file dump + explore/grep hint vs an inline marker). -- Hosts without their own output bounding set `maxOutputBytes` explicitly; README table - and prose updated, adapter comment rewritten. Tests: codemode +1 (absent limit -> 100KB - value + 50KB log line pass through unbounded, `truncated` undefined); the adapter test - that relied on the old default now asserts the oversized result reaches the shared - wrapper un-truncated. Suites: 210 + 50, tsgo clean both. - -**Docs polish** (post-API-review): stale `CodeMode.DiscoveryOptions` JSDoc fixed (claimed default -4,000 and alphabetical cheapest-first - now 2,000 and round-robin, matching Fix 8/9 reality) -and the README's incorrect "`effect` as a peer dependency" line corrected (`effect` is a -regular dependency; hosts depend on it themselves because the API surface is Effect-typed). - -**Registry promotion + permission-aware catalog** (the "promote to a proper tool service" -restructure; fixes the section 4 permission-advertising bug): - -- **The adapter moved** `src/session/code-mode.ts` -> `src/tool/code-mode.ts` and is now a - registry-resident tool service on the TaskTool precedent: `CodeModeTool = -Tool.define(CODE_MODE_TOOL, ...)` whose init depends on `MCP.Service`, `Agent.Service`, - and `Session.Service`. It is yielded in `ToolRegistry.layer`, gated into `builtin` by - `flags.experimentalCodeMode` (like the lsp/plan experiments), and `MCP.node` joined the - registry's `node.deps` (`MCP.node` has no ToolRegistry dependency, so no cycle). The - session-level special-casing in `session/tools.ts` (ad-hoc `SessionCodeMode.define` + - append) is deleted; the early return that suppresses raw per-MCP registration when the - flag is on stays session-side, keyed on the same flag+tool-count condition. -- **Enablement** lives in `ToolRegistry.tools()` next to the WebSearchTool check: the MCP - tool count is consulted once (an Effect) before the synchronous filter, and code mode - passes the predicate iff `flags.experimentalCodeMode` && count > 0. -- **Description split on the `describeTask` precedent**: the tool's static base - description is a two-line summary; `describeCodeMode(agent)` in `registry.tools()` - appends the full CodeMode instructions (workflow/rules/syntax + grouped catalog, - `catalogInstructions` in the adapter) at the same composition point as task - so - `plugin.trigger("tool.definition")` sees the base description first. -- **Permission-aware catalog + dispatch** (the bug fix): the visibility predicate from - `llm/request.ts` `resolveTools` is hoisted to `Permission.visibleTools(tools, ruleset)` - (a record filter over `Permission.disabled` - only a hard `deny` with pattern `"*"` - hides a tool; ask-level rules stay fully visible and prompt at call time) and - `resolveTools` now uses it, so the two paths cannot drift. `describeCodeMode` filters - with the merged agent+session ruleset that `SessionTools.resolve` passes into the - registry before building the catalog/search index; `execute` rebuilds the runtime per - execution from a fresh, filtered `mcp.tools()` snapshot using the same merged ruleset - (`Agent.get(ctx.agent)` + `Session.get(ctx.sessionID)`, matching the merge - `SessionTools.context` wires into `ctx.ask`) - a denied tool is not dispatchable - even if the model guesses its name and yields the normal unknown-tool diagnostic. - Documented gap (out of scope by design): per-message `user.tools[key] === false` arrives - at request-prep after descriptions are built and has no child-call equivalent. -- **Preserved behavior**: cancellation race + pre-aborted-signal guard, `toSandboxResult` - unwrap order, attachment accumulation, `CODE_MODE_TOOL` at all title sites, no execution - limits (native truncation only), `displayInput`, per-child `ctx.ask` gating (now wired - through `Tool.Context` exactly like every registry tool). -- **Explicit non-goal**: memoizing the catalog builder keyed on (ToolsChanged generation, - permission ruleset) was considered and deliberately skipped - the per-turn rebuild is - cheap (grouping + string rendering); revisit only if profiling shows it matters. -- **Tests**: the two adapter suites moved to `test/tool/{code-mode,code-mode-integration} -.test.ts` (mocked `MCP.Service`/`Agent.Service`/`Session.Service` replacing the direct - `define(...)` construction; description assertions target `catalogInstructions`, the - registry's composition input) and gained permission coverage: deny excluded from - catalog/search, ask-level stays visible and callable, denied tool undispatchable - (unknown-tool diagnostic), `Permission.visibleTools` semantics. `test/tool/ -registry.test.ts` gained four registry-level tests: registered with flag+MCP tools, - excluded without MCP tools, excluded with flag off, and deny/ask catalog filtering - through `registry.tools()`. Suites: 43 + 16 adapter tests, 16 registry tests, all green. - -**Shared MCP invocation middle (`McpInvoke.invoke`)** (closes the section 4 "plugin hooks skip -child calls" gap): - -- `packages/opencode/src/mcp/invoke.ts` extracts the duplicated "invoke an MCP tool" - middle into one shared `McpInvoke.invoke(input)`: plugin `tool.execute.before` hook -> - permission ask (`{ permission: key, patterns: ["*"], always: ["*"] }` via the caller's - `ctx.ask`) -> dispatch through the ai-sdk tool's execute inside the `Tool.execute` - tracing span (`tool.name`/`tool.call_id`/`session.id`/`message.id` attributes) -> - plugin `tool.execute.after` hook. It returns the RAW result the ai-sdk execute - resolved with; each caller keeps its own shaping edge - the legacy per-MCP loop in - `SessionTools.resolve` applies its existing model-facing shaping/truncation, code - mode applies `toSandboxResult`. It lives under `src/mcp/` because both callers - already depend on MCP and the function is about invoking an MCP-backed ai-sdk tool, - not about sessions or code mode. -- **After-hook payload**: fired inside `McpInvoke.invoke` with the raw MCP result - - which is exactly what the legacy loop always passed (the raw `CallToolResult`, not - the shaped `{title, output, metadata}`), so legacy behavior is preserved bit-for-bit - and the hook payload cannot drift between callers. No callback/edge-firing design - was needed. -- **Synthetic child callID**: code-mode child calls pass `${parentCallID}/${n}` as the - hook/span callID (`parentCallID` = the `execute` call's `ctx.callID`, falling back to - the entry key; `n` = per-execution counter starting at 1, shared across all child - calls in one program). callID is an opaque string - nothing parses it. The ai-sdk - `toolCallId` (`options.toolCallId`) stays each caller's existing value - (`ctx.callID ?? entry.key` for code mode). -- **Child-scoped hook failures**: `CodeModeTool` (which now also yields - `Plugin.Service`) wraps the whole child call - hooks, ask, dispatch - in - `toCatchable` (the generalization of the old `askPermission` catchCause), so a plugin - hook failure fails ONLY that child call as a catchable in-program `toolError`; other - calls in the same program keep running and interruption still propagates as - interruption. Legacy semantics unchanged: a hook failure fails the tool call. -- **Tests**: `test/tool/code-mode.test.ts` +2 (child calls fire before/after with the - MCP key and `parent/1`, `parent/2` ids, after hook carries the raw MCP result; a - failing before hook is caught in-program, gates dispatch, and leaves the outer - execute ok) - both code-mode harnesses gained a `Plugin.Service` mock (pass-through - trigger by default, overridable). New `test/session/tools.test.ts` (3 tests) pins - `SessionTools.resolve` at the real-registry seam (LayerNode.compile, fake MCP layer): - flag on + MCP tools -> `execute` present, raw MCP keys suppressed; flag off -> raw - keys present, `execute` absent; and the legacy raw-MCP execute fires before/after - hooks keyed by the ai-sdk toolCallId with the raw result payload. Suites: adapter - 45 + 16, session/tool/permission all green; this package untouched (211 pass). - -**Signature rendering + compound-assignment parity fixes** (externally reported, both -verified real with failing tests before fixing): - -- **Non-identifier property names in rendered signatures** (`src/tool.ts`): `renderSchema` - emitted raw property names, so schema properties like `foo-bar`/`@type`/`x.y`/`123` - rendered invalid TypeScript (`{ foo-bar?: string }`). Fixed with a `renderKey` helper - - bare identifiers stay bare, everything else is `JSON.stringify`-quoted - applied in the - single `field` closure both the compact and pretty renderings share. The - `identifierSegment` regex now lives in `tool.ts` (exported) and `tool-runtime.ts`'s - bracket-notation `toolExpression` imports it: one source of truth for "is this a bare - identifier" across object keys and tool paths. Tests: `signature.test.ts` +4 (compact, - pretty with JSDoc on a quoted key, JSON Schema input+output, Effect Schema struct). -- **Numeric schema unions keep their real alternatives** (`src/tool.ts`): the old - `anyOf`/`oneOf` renderer collapsed any union containing `{ type: "number" }` to just - `number`, dropping real JSON Schema alternatives (`string | number`, `number | null`, - etc.). The collapse is now restricted to Effect's number-schema artifact - (`number | "NaN" | "Infinity" | "-Infinity"`, emitted as single-value string enums), - while raw JSON Schema unions render every branch. Tests: `signature.test.ts` +3. -- **Compound assignment now matches binary-operator semantics** (`src/codemode.ts`): - `applyCompoundAssignment` did raw JS ops on interpreter wrapper objects, so `x += y` - diverged from `x = x + y` (sandbox Date `d += 1` produced `"[object Object]1"`; - `d -= 400` gave `NaN` instead of epoch arithmetic). The operator table + coercion moved - verbatim out of `evaluateBinaryExpression` into a shared `applyBinaryOperator`; - compound assignment validates against a `compoundOperators` set (`+=` ... `>>>=`) and - dispatches through it (`operator.slice(0, -1)`). Logical assignments (`&&=`/`||=`/`??=`) - keep their separate short-circuit path (`evaluateLogicalAssignment`), and both - assignment call sites still wrap results in `boundedData`. Deliberate side effect: - compound assignment now rejects opaque references, consistent with binary operators. - Tests: `parity.test.ts` +5 (Date `+=` concat parity, Date `-=`/`/=` epoch parity, - string `+=` object/array, member-target compound, 13-case operator sweep vs real JS). - Package suite: 220 pass. - ---- - -## 4. Remaining work (detailed TODO) - -### Next DSL-expansion pass (done - see the DSL-expansion pass entry in section 3) - -Batch these together - per user direction: important, but deliberately deferred to one -focused interpreter-surface pass rather than picked off piecemeal. - -- [x] Medium-tier JS parity items deferred from the original audit: caught errors are plain - `{ name, message }` objects, not `instanceof Error` (and `Error` isn't a value - - `x instanceof Error` is unsupported syntax); `splice` (still a - "rewrite using map/filter" hint) and array `entries()/keys()/values()`; - `localeCompare`/`normalize`/`trimLeft`/`trimRight`; friendlier regex-y error messages. - (`fill`/`copyWithin` - which the hint set also covered - were implemented too since - they are trivial host delegations, so the hint set is gone entirely.) -- [x] `Date`/`Map`/`Set`/`RegExp` values passing through `Object.*` helpers and coercion - checkpoints take their JSON forms (e.g. `Object.values({ d: date })` yields the ISO - string, not the Date - calling `.getTime()` on it then fails). Currently deliberate - (documented in README) but flagged as important: fix in this pass by letting sandbox - values survive `Object.*`/spread checkpoints instead of JSON-serializing them. -- [x] `console.log(NaN)` prints `"null"` (goes through the boundary chokepoint) - could - special-case number formatting in `formatConsoleArgument`. -- [x] Sandbox values nested inside logged containers print `[CodeMode reference]` - (`console.log({ m: map })`) - could deep-format instead. - -### Next iteration: optional search input boundary - -- [ ] `SearchInput` uses Effect's exact `optionalKey`, so an omitted field is accepted but an - explicitly present `undefined` field is rejected. The previous handwritten validator - treated explicit `undefined` as omission. Decide whether search should preserve that - convenience locally or whether all tool arguments should adopt JSON-style undefined - normalization; do not broaden `copyOut` semantics solely to fix search. - -### Next iteration: text-result handling (deliberate follow-up, user-directed) - -- [ ] Revisit how MCP text results reach the program. Today: `structuredContent` when the - server sends it, else joined text as a plain string. Programs narrow unknown results - before use; the prompt no longer recommends unconditional JSON parsing. Considered and deferred: (a) conservative boundary - auto-parse (text starting with `{`/`[` that parses cleanly becomes an object) - - rejected for now as potentially confusing (type flips; program sees something other - than what the tool sent); (b) raw-envelope passthrough with the envelope shape - stamped into every output schema - rejected (more digging per call, verbose - signatures). Result quality is dominated by whether servers declare output schemas; - revisit once real usage shows which failure modes matter. - -### Next iteration: stdlib surface (prioritized) - -Current instructions say "usual Array/String/Object/Math/JSON methods," but the interpreter is -intentionally a subset. Keep CodeMode focused on orchestration and data shaping, not a full host -runtime, but close the high-friction gaps models are likely to reach for. - -- [ ] **P0: tighten wording first** - change instructions/docs to say "common stdlib subset" - until the surface is broader. This avoids misleading the model into assuming every JS - helper exists. -- [ ] **P1: URL parsing helpers** - add `URL` and `URLSearchParams`. These are high-value for - tool orchestration (query strings, ids in URLs, API links), deterministic, and do not add - ambient host authority. -- [ ] **P2: Math completion** - add the missing standard deterministic `Math` methods - (`sin`/`cos`/`tan`, inverse/hyperbolic variants, `atan2`, `log1p`, `expm1`, `imul`, - `fround`, `clz32`, etc.). Decide explicitly on `Math.random`: likely acceptable because - `Date.now()` is already exposed, but document the nondeterminism if enabled. -- [ ] **P3: base64 helpers** - add string-only `atob`/`btoa` equivalents. Useful for API/tool - payload cleanup and does not require opening the broader binary boundary. -- [ ] **P4: small crypto helper** - consider `crypto.randomUUID()` only, not full `crypto`. - UUID generation is a common orchestration need; broader crypto can wait until there is a - concrete use case and a clear capability boundary. -- [ ] **P5: text/binary primitives** - consider `TextEncoder`/`TextDecoder` first, then - `ArrayBuffer`/typed arrays/`DataView`/`Blob`/`File` only with an explicit boundary design - (serialization, size limits, and how values cross tool args/results). This is reasonable - but lower priority than URL/base64 because CodeMode is still plain-data oriented. -- [ ] **P6: date/formatting conveniences** - consider `Date` setters and common formatting - helpers (`toUTCString`, maybe `Intl` later). Lower priority; most orchestration can use - existing getters, `Date.parse`, `Date.UTC`, and ISO strings. -- [ ] **P7: environment/config access** - do not expose raw `process.env` as a global ambient - authority. If this becomes useful, add an explicit host-provided/whitelisted capability - (for example a small env/config tool or injected read-only object) so secrets are not - accidentally exposed to arbitrary CodeMode programs. - -Explicit non-goals for now: `structuredClone`, `WeakMap`/`WeakSet`, and timers -(`setTimeout`/`setInterval`/`queueMicrotask`). They do not materially improve the current tool -orchestration use case. - -### Wiring-review findings (subagent code review of the OpenCode integration, triaged) - -Pre-PR fixes (user-approved cut): - -- [x] **Cancellation does not interrupt the interpreter** - the no-limits rationale claimed - "user cancel interrupts the execution fiber," but `tools.ts` runs tools via - `run.promise` -> `Effect.runPromise` (`effect/bridge.ts:64-66`) with NO abort wiring; - on cancel the ai-sdk abandons the promise, child MCP calls abort (they hold - `ctx.abort`) but the interpreter fiber spun on - `while(true){}` or a try/catch - loop was uncancellable with no timeout backstop. Verified by hand, not just the - reviewer. FIXED in the adapter: `Effect.raceFirst(runtime.execute(code), cancelled)` - where `cancelled` is an `Effect.callback` abort-signal watcher (listener removed on - interruption) resuming with an `ok: false` "Execution cancelled." result - the abort - winning the race interrupts the execution fiber (interpreter auto-yield makes busy - loops preemptible, same mechanism as timeoutMs) and returning a value keeps the - runner's post-abort `completeToolCall` bookkeeping on its normal path. A pre-aborted - signal short-circuits at entry before the program starts (racing alone still lets - the loser run its first steps). Tests: +2 adapter (child call triggers abort - deterministically then the program enters `while(true){}` - would hang if - interruption broke; pre-aborted signal runs nothing). Adapter suite 34 -> 36. - (Wiring abort->interrupt into the shared `tools.ts` runner for ALL tools remains a - worthwhile separate change.) -- [x] **Permission-denied/disabled MCP tools are still advertised in the catalog** - the - non-code-mode path filters them from the model's view (`llm/request.ts:208-213`); - code mode builds the catalog from all of `mcp.tools()`, so the model is invited to - call tools that can only fail at permission time, and per-message `tools[key]=false` - disabling has no child-call equivalent. Fix: filter the catalog with the same - ruleset. - DONE (see the "Registry promotion + permission-aware catalog" entry in section 3): the - shared `Permission.visibleTools` predicate filters both the appended - catalog/description (`describeCodeMode`, agent ruleset) and the execute-time tool - tree (merged agent+session ruleset) - hard-denied tools are neither advertised nor - dispatchable. Ask-level tools stay visible/callable. Per-message - `tools[key] === false` remains a documented gap by design (it arrives at - request-prep, after descriptions are built). -- [x] Style: `code-mode.ts` is the only `src/session` sibling without the - `export * as ... from "./..."` self-reexport footer, forcing a star import at - `tools.ts:26` (AGENTS.md violation). Add footer + import the projection. - DONE: added `export * as SessionCodeMode from "./code-mode"` footer; `tools.ts` now - imports the named `SessionCodeMode` projection. -- [x] Trivial: latent `groupByServer` fallback bug - `key.slice(0, key.indexOf("_"))` is - `slice(0, -1)` when no underscore (unreachable today; guard or drop); dead - `CODE_MODE_TOOL` export (integration points hardcode `"execute"` - use it or inline - it). - DONE: no-underscore key now falls back to the whole key (test pins it); the four - `title: "execute"` sites in `code-mode.ts` now reference `CODE_MODE_TOOL`. - -Post-MVP (logged, not blocking an experimental flag): - -- [x] **Plugin `tool.execute.before/after` hooks skip child calls** - legacy MCP - registration fires them per tool (`tools.ts:419-441`); under code mode only the - outer `execute` fires them, so auditing/intercepting plugins silently lose MCP - coverage when the flag flips. - DONE (see the "Shared MCP invocation middle" entry in section 3): both paths now run - `McpInvoke.invoke` (`src/mcp/invoke.ts`) - hooks AND the `Tool.execute` span fire - for child calls with synthetic `${parentCallID}/${n}` callIDs; hook failures are - child-scoped, catchable in-program errors. -- [x] Description/preview rebuilt every assistant turn - `registry.tools()` re-runs - `groupByServer` + a throwaway `CodeMode.make(...).instructions()` per turn - (`describeCodeMode`). DECIDED as an explicit non-goal: memoizing the catalog - builder keyed on (ToolsChanged generation, permission ruleset) was considered and - deliberately skipped - the per-turn rebuild is cheap (grouping + string - rendering); revisit only if profiling shows it matters. A second `CodeMode.make` - per execution is inherent (description precedes execution). -- [ ] Child permission rejection round-trips through the defect channel - `ctx.ask` - defect (`tools.ts:90` orDie) recovered via `catchCause` + `Cause.squash` - (`code-mode.ts:238-245`). Works, interrupts preserved, but fragile coupling; - exposing the typed rejection on `Tool.Context.ask` would be cleaner. -- [ ] No collision guard on the `execute` tool id (a plugin/custom tool named `execute` - is silently shadowed; a log line would do). -- [ ] Style nits: triple-nested `yield*` in `tools.ts:101-107` argument position (bind - first, like neighbors); single-use micro-helpers (`toJsonSchema` is a bare cast); - comment density far above session-neighbor norm; adapter tests use raw - `Effect.runPromise` + hand-built layers with `as any` instead of the - `testEffect`/`LayerNode.compile` fixture pattern (`test/tool/grep.test.ts:25-31`) - and star-import `Truncate`. -- [ ] Reviewer observation worth keeping: MCP server instructions (`sys.mcp`, - `session/system.ts:110-126`) still inject prose referencing server-native tool - names that are no longer directly callable under code mode. - -### Backlog / loose ends (non-blocking, any order) - -- [ ] `evaluateUpdateExpression` (`++`/`--`) still uses raw `Number(current)`, so `d++` on a - sandbox Date yields `NaN` where `d += 1` now uses epoch semantics (and real JS `d++` - would give epoch+0 numeric). Pre-existing, out of scope of the compound-assignment - parity fix; route it through `applyBinaryOperator` if it ever matters. -- [ ] Media-only marker could name what it attached when MCP provides names: `image`/`audio` - blocks carry no filename (mime + data only) so the generic - `[N images attached to the result]` stays, but `resource`/`resource_link` blocks have - URIs/names we could surface, e.g. `[2 files attached: chart.png, data.csv]`. Minor. -- [x] Truncation layering decided (user direction): the OPPOSITE of killing the outer layer - - CodeMode truncation off in OpenCode (`maxOutputBytes` lost its default; absent = no - truncation, uniform with the other two limits), native tool-output truncation is the - single active layer (verified: `execute` flows through `tool.ts` `wrap()` like any - normal tool, no exemption). See the section 3 entry. -- [x] Flaky wall-clock assertion removed from `test/promise.test.ts`: the parallelism test - now relies solely on the deterministic `trace.maxActive > 1` counter (which proves - true temporal overlap). The timeout tests were never flaky - 100ms timeout vs 60s - tool sleeps (600x margin) with counter-based assertions. -- [ ] Attachment propagation believed correct but unverified end-to-end at the OpenCode - wiring layer (codemode strips -> `Tool.ExecuteResult.attachments` -> processor - normalizes -> `FilePart`s visible to the model). Code-reviewed as sound; confirm with - one interactive session (an image-returning MCP tool) when convenient. Same session - can eyeball TUI child-call rendering via `metadata.toolCalls`. -- [x] Commit hygiene: all work committed and pushed on `codemode-v2` as six commits, in - generic-package + OpenCode-integration pairs (waves 0-5; Fixes 4-9; DSL pass + - error names + truncation layering). Future work: commit only when explicitly asked; - push with `--no-verify` per repo convention. The scratch `.opencode/opencode.jsonc` - stays uncommitted. -- [ ] MVP scope decided (user direction): the interactive e2e eyeball is NOT required - - remaining pre-PR work is essentially just opening the PR. Attachment-propagation - verification (below) stays parked as post-MVP. - ---- - -## 5. Context and gotchas for whoever picks this up - -- **Motivating failure (why forgiving semantics + prompting matter):** in a real transcript, - the model wrote `me.result?.login ?? me.result` where the tool result was a JSON _string_ - - the old strict interpreter threw (`String property 'login' is not available`); then the - model returned a raw 105KB payload, which native truncation dumped to a file, costing a - subagent round-trip to extract one number. Interpreter forgiveness stops the crashes; - Wave 4 prompting stops the payload dumping. Both are needed. -- Realistically **all MCP tools render `Promise`** (no outputSchema), so the - instructions prose is the only lever for result-shape behavior in the dominant case. -- **`copyIn` has two roles, split by a mode flag** (DSL-expansion pass): host<->sandbox - boundary (default mode - final result, tool arguments, `JSON.stringify`, tool-result - intake; sandbox value types serialize to JSON forms) AND intra-sandbox data checkpoint - (`boundedData` = `copyIn(value, label, true)` - sandbox value instances pass through by - reference as leaves, everything else keeps the same plain-data validation). If you add a - new value type, follow the Wave 1b-i pattern: class in `values.ts`, opaque-by-default via - `isRuntimeReference`, explicit carve-outs, JSON form in `copyIn`'s boundary mode plus - pass-through in its preserving mode, console formatting (`formatConsoleValue`), tests - - and make sure the `Object.*` helpers treat it as an empty object so class fields never - leak. -- The interpreter throws synchronously inside `Effect.gen`/`Effect.sync` freely; everything is - normalized by `catchCause` -> `normalizeError` into `Diagnostic` data. Program failures are - **data, never Effect failures**; only interruption propagates. -- `parseProgram` wraps source in `async function __codemode__() { ... }`, transpiles TS, then - slices between the first `{` and last `}` - line/col diagnostics are offset accordingly - (`sourceLocation`). Don't inject prologue code; it breaks the offsets. -- OpenCode wraps every tool's output with auto-truncation (`Tool.define` wrapper, - `truncate.output`, 2000 lines / 50KB, saves full output to disk and appends a hint) unless - `metadata.truncated` is set. The `execute` tool currently rides that for free. -- Effect version: both repos pin `effect@4.0.0-beta.83` via bun catalogs. This package uses - v4-only APIs (`Schema.Decoder`, `Schema.toJsonSchemaDocument`, `Context.Service`, - `Cause.hasInterruptsOnly`, `Effect.timeoutOrElse`). The effect-smol checkout referenced in - the workspace is the implementation source of truth for v4 behavior questions. -- File map (this package): `src/codemode.ts` - types/limits/parser/Interpreter/execute/make; - `src/tool-runtime.ts` - tool tree, `copyIn`/`copyOut`, search/discovery, invoke path; - `src/tool.ts` - `Tool.make` + JSON-Schema->TS rendering; `src/values.ts` - sandbox value - types; `src/tool-error.ts` - `ToolError`; tests in `test/{codemode,parity,stdlib}.test.ts`. -- OpenCode file map (integration points): `src/tool/code-mode.ts` (the adapter, now a - registry tool service - `CodeModeTool` + `catalogInstructions`; formerly - `src/session/code-mode.ts`); `src/tool/registry.ts` (`describeCodeMode`, enablement in - `tools()`, `MCP.node` dep); `src/session/tools.ts` (raw-MCP-registration suppression - when the flag is on); `src/permission/index.ts` (`Permission.visibleTools`, the shared - visibility predicate, also used by `src/session/llm/request.ts` `resolveTools`); - `src/mcp/index.ts` (`MCP.tools()`/`MCP.defs()`); `src/mcp/catalog.ts` (`convertTool`, - `server_tool` naming); `src/tool/tool.ts` (`ExecuteResult.attachments`, truncation - wrapper); `src/session/message-v2.ts` (attachments -> vision); - `packages/tui/src/routes/session/index.tsx` (`Execute` progress component); - `src/effect/runtime-flags.ts` (feature flag). +Program results and tool arguments are JSON-like data. Dates become ISO strings at host boundaries; RegExp, Map, and +Set values become `{}` as they do under JSON serialization. Promise and runtime reference values cannot cross the +boundary. + +Unknown host failures and invalid outputs are sanitized. `ToolError` is the explicit channel for a safe message that a +tool wants the model to see. Diagnostic categories distinguish parsing, unsupported syntax, unknown tools, invalid +data, tool failures, limits, timeouts, and execution failures. + +Files and other attachment content stay outside the interpreter. A host may collect them while child tools execute and +attach them to the outer result, but the program receives only the structured tool output. + +### V2 OpenCode adapter + +This section describes the `v2` branch integration. On `dev`, CodeMode is integrated through +`packages/opencode/src/tool/code-mode.ts`, where nested MCP calls run the `tool.execute.before` and +`tool.execute.after` plugin hooks. + +CodeMode is integrated into V2 through `packages/core/src/tool/registry.ts` and +`packages/core/src/tool/execute.ts`: + +- Core has one canonical `Tool` representation. Location-scoped producers register direct or deferred tools through + `Tools.Service`. +- Each model step snapshots effective registrations, applies catalog visibility filtering, and exposes direct tools + normally. +- When visible deferred tools exist, Core reserves and materializes one `execute` tool. Grouped deferred tools become + CodeMode namespaces instead of flattened model-facing names. +- Each nested call checks that its captured registration is still current before dispatching it. +- Authorization and side-effect ordering remain responsibilities of the leaf tool. Catalog visibility is not execution + authorization. +- Structured child output enters the interpreter. File parts are collected host-side and attached to the outer result. +- Nested call statuses are returned as final `execute` metadata for the TUI. +- `execute` is the one model-facing tool invocation. Nested calls reuse its invocation context and do not independently + run registry hooks or model-output bounding; this keeps complete intermediate structured values available for + in-program filtering. The outer `execute` settlement is the single model-output bounding boundary. +- Core supplies no CodeMode timeout or tool-call limit. User cancellation interrupts the outer invocation and its + supervised children; the outer settlement applies Core's normal output-retention policy. + +MCP tools use this canonical path: they register as grouped tools and are deferred while CodeMode is enabled. Existing +output schemas are preserved in generated signatures. Direct Core tools remain direct and are not ambient globals +inside CodeMode. + +## Intentionally Unsupported + +These are product boundaries rather than DSL backlog: + +- Ambient filesystem, process, environment, network, credential, or application access. External work must go through + supplied tools. +- Modules, imports, dynamic imports, `eval`, arbitrary host globals, npm packages, and prototype mutation. +- Generic permission prompts, authorization policy, durable pause/resume, replay, storage, or exactly-once external + side effects. Hosts and tools own those concerns. +- Heuristic parsing of text tool results as JSON. A result should not silently change type based on its contents. + +The OpenAPI adapter may gain more transports and encodings, but it must continue skipping operations it cannot +represent accurately rather than guessing semantics. + +## Decisions and Rationale + +| Decision | Rationale | +| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Keep an owned tree-walking interpreter. | The product need is bounded tool orchestration, not arbitrary JavaScript. Owning the language surface keeps authority and behavior explicit. | +| Treat schemas as the model-facing interface. | Signatures drive correct calls; Effect Schema also provides the runtime validation boundary, while JSON Schema supports adapter interoperability. | +| Keep authority host-owned. | CodeMode can only confine programs to supplied tools. The host chooses those tools, and each tool enforces its own authorization and side-effect policy. | +| Use progressive catalog disclosure plus search. | Large tool sets should not consume the prompt, but every namespace must remain discoverable and speculative search calls should remain valid. | +| Start tool promises eagerly and supervise them. | This preserves normal call-time parallelism while giving each call run-once settlement and interruption safety. | +| Keep files outside the sandbox value space. | Models should compose structured data without routing binary payloads through generated code or context. | +| Treat `execute` as the model-facing invocation boundary. | Nested calls are implementation details of one orchestration program. Reusing the outer context and bounding only the final result preserves complete intermediate data without inventing durable child-call identities. | +| Return expected failures as data. | Models need actionable diagnostics without exposing private host causes; host interruption and defects must still propagate correctly. | +| Leave execution-limit defaults to hosts. | Appropriate budgets depend on the surrounding product and its own cancellation, retention, and output-bounding policies. | +| Skip unsupported OpenAPI operations. | Incorrect parameter encoding, authentication, or transport behavior is worse than a precise `skipped` reason. | + +## Remaining Work + +Keep only material unresolved work here. Small isolated defects should be GitHub issues; adapter-only work belongs in +the adapter TODO. Delete entries when completed. + +### DSL expansion + +The supported JavaScript subset should grow when common model-generated code improves tool orchestration. These are +current omissions to implement, not intentional product boundaries. + +- [ ] Design proper multi-stage promise pipelines. Supporting `.then`, `.catch`, and `.finally` should preserve promise + assimilation, cancellation, failure handling, and concurrent per-item pipelines rather than adding syntax-only + shims. Consider `Promise.any` in the same pass. +- [ ] Support async iteration and `for await...of`. Define behavior first for the runtime's supported promise and + collection values, then extend it to bounded host streams when a stream boundary exists. +- [ ] Support callback-bearing standard-library variants that models commonly generate: the mapper argument to + `Array.from(...)` and replacers for `JSON.stringify(...)`, including Effect-aware callbacks where needed. +- [ ] Close basic `Object` parity gaps: let `Object.values`/`Object.entries` accept arrays, make `Object.assign` validate + and mutate its target, add `Object.is`, and let `Object.fromEntries` consume every supported iterable. +- [ ] Add deterministic modern collection conveniences where they improve orchestration: `Object.groupBy`, Set + composition methods, and `Array.prototype.toSpliced`. +- [ ] Complete the deterministic `Math` surface beyond the current arithmetic, rounding, root, power, and logarithm + helpers. Decide separately whether nondeterministic `Math.random` belongs in the runtime. +- [ ] Refine diagnostics so user throws, expected tool failures, unexpected host/tool defects, and genuine interpreter + defects are distinguishable without leaking private causes. + +### Tool and result contracts + +- [ ] Design explicit tagged representations and size rules before allowing Blob, File, ArrayBuffer, typed arrays, or + host streams to cross the sandbox boundary. +- [ ] Define one consistent policy for tool path segments named `__proto__`, `constructor`, or `prototype`. They must + either be safely callable, rejected before catalog generation, or use one documented escaping rule. diff --git a/packages/codemode/package.json b/packages/codemode/package.json index b2d9ec3ea255..1830d59c8cd6 100644 --- a/packages/codemode/package.json +++ b/packages/codemode/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/codemode", - "version": "0.0.1", + "version": "1.17.14", "description": "Effect-native confined code execution over schema-described tools", "private": true, "type": "module", diff --git a/packages/codemode/src/codemode.ts b/packages/codemode/src/codemode.ts index 083f62e28b54..14782d7c8aae 100644 --- a/packages/codemode/src/codemode.ts +++ b/packages/codemode/src/codemode.ts @@ -1,22 +1,7 @@ -import { parse } from "acorn" -import { Cause, Effect, Exit, Fiber, Schema, Semaphore } from "effect" -import { DiagnosticCategory, ModuleKind, ScriptTarget, flattenDiagnosticMessageText, transpileModule } from "typescript" -import { - copyIn, - copyOut, - isBlockedMember, - ToolReference, - ToolRuntime, - ToolRuntimeError, - type HostTools, - type SafeObject, - type ToolCall, - type ToolDescription, - type Services, -} from "./tool-runtime.js" +import { Effect, Schema } from "effect" +import { executeWithLimits } from "./interpreter/runtime.js" +import { type HostTools, type Services, type ToolDescription, ToolRuntime } from "./tool-runtime.js" import type { Definition } from "./tool.js" -import { ToolError } from "./tool-error.js" -import { isSandboxValue, SandboxDate, SandboxMap, SandboxPromise, SandboxRegExp, SandboxSet } from "./values.js" /** A tool call admitted during an execution. */ export type { ToolCall, ToolCallEnded, ToolCallHooks, ToolCallStarted, ToolDescription } from "./tool-runtime.js" @@ -27,22 +12,13 @@ export type ExecutionLimits = { readonly timeoutMs?: number /** Maximum number of tool calls admitted by the runtime. No default: absent means unlimited. */ readonly maxToolCalls?: number - /** - * Maximum UTF-8 bytes of model-facing output: the serialized result value plus captured - * logs. Excess output is truncated with an explanatory marker instead of failing. No - * default: absent means no truncation (for hosts with their own output bounding). - */ + /** Maximum UTF-8 bytes of model-facing output. No default: absent means no truncation. */ readonly maxOutputBytes?: number } /** Controls how much of the tool catalog is inlined in agent instructions. */ export type DiscoveryOptions = { - /** - * Approximate budget, in estimated tokens (chars/4, default 2000), for full tool entries in - * the instruction catalog. Tool entries are selected round-robin across namespaces. Fixed - * instructions and namespace summaries do not count toward the budget, and - * `tools.$codemode.search` is always registered. - */ + /** Approximate token budget (chars/4, default 2000) for full catalog entries. */ readonly catalogBudget?: number } @@ -50,12 +26,9 @@ type ToolTree = { readonly [name: string]: Definition | ToolTree } -type ResolvedExecutionLimits = { - /** Undefined means no timeout. */ +export type ResolvedExecutionLimits = { readonly timeoutMs: number | undefined - /** Undefined means unlimited tool calls. */ readonly maxToolCalls: number | undefined - /** Undefined means no output truncation. */ readonly maxOutputBytes: number | undefined } @@ -138,287 +111,11 @@ export type Result = typeof Result.Type /** Reusable confined runtime over one explicit tool tree. */ export type Runtime = { - /** Lists schema-described tool paths provided by the host. */ readonly catalog: () => ReadonlyArray - /** Builds model-facing syntax guidance and visible tool signatures. */ readonly instructions: () => string - /** Executes a program using this runtime's configured host tools. */ readonly execute: (code: string) => Effect.Effect } -type SourcePosition = { - line: number - column: number -} - -type SourceLocation = { - start: SourcePosition - end: SourcePosition -} - -type AstNode = { - type: string - loc?: SourceLocation - [key: string]: unknown -} - -type ProgramNode = AstNode & { - type: "Program" - body: Array -} - -type Binding = { - mutable: boolean - value: unknown - // Absent means initialized. `false` marks a parameter binding seeded into its scope but not - // yet bound, so a default that forward-references a later parameter sees a TDZ error (as in JS) - // rather than silently resolving to an outer binding of the same name. - initialized?: boolean -} - -type StatementResult = - | { kind: "none" } - | { kind: "value"; value: unknown } - | { kind: "return"; value: unknown } - | { kind: "break" } - | { kind: "continue" } - -type MemberReference = { - target: SafeObject | Array - key: string | number -} - -class CodeModeFunction { - constructor( - readonly parameters: ReadonlyArray, - readonly body: AstNode, - readonly capturedScopes: ReadonlyArray>, - ) {} -} - -class IntrinsicReference { - constructor( - readonly receiver: unknown, - readonly name: string, - ) {} -} - -class ComputedValue { - constructor(readonly value: unknown) {} -} - -class PromiseNamespace {} - -type PromiseMethodName = "all" | "allSettled" | "race" | "resolve" | "reject" - -class PromiseMethodReference { - constructor(readonly name: PromiseMethodName) {} -} - -// A built-in global namespace (`Object`, `Math`, `JSON`, `Array`, ...); members resolve to a -// GlobalMethodReference, except known constants (e.g. `Math.PI`) which resolve to a value. -type GlobalNamespaceName = "Object" | "Math" | "JSON" | "Array" | "console" | "Date" | "RegExp" | "Map" | "Set" - -class GlobalNamespace { - constructor(readonly name: GlobalNamespaceName) {} -} - -class GlobalMethodReference { - constructor( - readonly namespace: GlobalNamespaceName | "Number" | "String", - readonly name: string, - ) {} -} - -class CoercionFunction { - constructor(readonly name: "Number" | "String" | "Boolean" | "parseInt" | "parseFloat") {} -} - -class ProgramThrow { - constructor(readonly value: unknown) {} -} - -class ErrorConstructorReference { - constructor(readonly name: string) {} -} - -// Non-enumerable so spread/copyOut preserve the plain `{ name, message }` data shape. -const ErrorBrand: unique symbol = Symbol("codemode.error") - -const brandError = (errorValue: SafeObject, name: string): SafeObject => { - Object.defineProperty(errorValue, ErrorBrand, { value: name }) - return errorValue -} - -const createErrorValue = (name: string, message: string): SafeObject => - brandError(Object.assign(Object.create(null) as SafeObject, { name, message }), name) - -const errorBrandName = (value: unknown): string | undefined => - value !== null && typeof value === "object" - ? ((value as Record)[ErrorBrand] as string | undefined) - : undefined - -const arrayMethods = new Set([ - "map", - "filter", - "find", - "findIndex", - "findLast", - "findLastIndex", - "some", - "every", - "includes", - "join", - "reduce", - "reduceRight", - "flatMap", - "forEach", - "sort", - "toSorted", - "slice", - "concat", - "indexOf", - "lastIndexOf", - "at", - "flat", - "reverse", - "toReversed", - "with", - "push", - "pop", - "shift", - "unshift", - "splice", - "fill", - "copyWithin", - "keys", - "values", - "entries", -]) - -const mathConstants = new Set(["PI", "E", "LN2", "LN10", "LOG2E", "LOG10E", "SQRT2", "SQRT1_2"]) - -const numberMethods = new Set(["toFixed", "toPrecision", "toExponential", "toString"]) - -const stringMethods = new Set([ - "toLowerCase", - "toUpperCase", - "trim", - "trimStart", - "trimEnd", - "trimLeft", - "trimRight", - "split", - "slice", - "substring", - "substr", - "includes", - "startsWith", - "endsWith", - "indexOf", - "lastIndexOf", - "replace", - "replaceAll", - "repeat", - "padStart", - "padEnd", - "charAt", - "charCodeAt", - "codePointAt", - "at", - "concat", - "toString", - "match", - "matchAll", - "search", - "localeCompare", - "normalize", -]) - -const numberConstants = new Set(["MAX_SAFE_INTEGER", "MIN_SAFE_INTEGER", "MAX_VALUE", "MIN_VALUE", "EPSILON"]) - -const numberStatics = new Set(["isInteger", "isFinite", "isNaN", "isSafeInteger", "parseInt", "parseFloat"]) - -const stringStatics = new Set(["fromCharCode", "fromCodePoint"]) - -const consoleMethods = new Set(["log", "info", "debug", "warn", "error", "dir", "table"]) - -const promiseStatics = new Set(["all", "allSettled", "race", "resolve", "reject"]) - -const errorConstructors = new Set([ - "Error", - "TypeError", - "RangeError", - "SyntaxError", - "ReferenceError", - "EvalError", - "URIError", -]) - -const valueConstructors = new Set(["Date", "RegExp", "Map", "Set"]) - -const dateMethods = new Set([ - "getTime", - "valueOf", - "toISOString", - "toJSON", - "toString", - "getFullYear", - "getMonth", - "getDate", - "getDay", - "getHours", - "getMinutes", - "getSeconds", - "getMilliseconds", - "getUTCFullYear", - "getUTCMonth", - "getUTCDate", - "getUTCDay", - "getUTCHours", - "getUTCMinutes", - "getUTCSeconds", - "getUTCMilliseconds", - "getTimezoneOffset", -]) -const dateStatics = new Set(["now", "parse", "UTC"]) - -const regexpMethods = new Set(["test", "exec", "toString"]) -// Read-only host regex fields surfaced as plain values. -const regexpProperties = new Set([ - "source", - "flags", - "lastIndex", - "global", - "ignoreCase", - "multiline", - "sticky", - "unicode", - "dotAll", -]) - -const mapMethods = new Set(["get", "set", "has", "delete", "clear", "forEach", "keys", "values", "entries"]) -const setMethods = new Set(["add", "has", "delete", "clear", "forEach", "keys", "values", "entries"]) - -const OptionalShortCircuit: unique symbol = Symbol("codemode.optional-short-circuit") - -const supportedSyntaxMessage = - "Supported orchestration syntax: tools.* calls (they return promises - resolve them with await), data literals, destructuring, optional chaining, template literals, conditionals, switch, loops (incl. for...of and for...in over object/array/tools keys), arrow functions, spread, try/catch, array methods (map/filter/find/findIndex/some/every/reduce/flatMap/forEach/sort/slice/concat/indexOf/lastIndexOf/at/flat/reverse/includes/join), string methods (incl. match/matchAll/replace/split with regular expressions), Date/RegExp/Map/Set, Object/Math/JSON helpers, captured console.log/warn/error/dir/table, and Promise.all/allSettled/race/resolve/reject over arrays mixing promises and plain values for parallel tool calls (promise chaining with .then/.catch is not supported - use await with try/catch)." - -const unsupportedSyntax = (kind: string, node: AstNode): InterpreterRuntimeError => - new InterpreterRuntimeError( - `Syntax '${kind}' is not supported in CodeMode. ${supportedSyntaxMessage}`, - node, - "UnsupportedSyntax", - [supportedSyntaxMessage], - ) - -/** How many eagerly forked tool calls may run at once. Fixed; not a configurable knob. */ -const TOOL_CALL_CONCURRENCY = 8 - -/** Console formatting recursion ceiling; deeper values render as "...". Fixed; not a knob. */ -const MAX_CONSOLE_DEPTH = 32 - const validateLimit = ( name: keyof ExecutionLimits, value: Value, @@ -430,3612 +127,13 @@ const validateLimit = ( return value } -// No limit has a default: absent means no timeout / unlimited calls / no output truncation - -// budgets are host policy, not library policy. A host without its own output bounding should -// pass maxOutputBytes explicitly, or oversized results flood model context. const resolveExecutionLimits = (limits?: ExecutionLimits): ResolvedExecutionLimits => ({ timeoutMs: validateLimit("timeoutMs", limits?.timeoutMs, 1), maxToolCalls: validateLimit("maxToolCalls", limits?.maxToolCalls, 0), maxOutputBytes: validateLimit("maxOutputBytes", limits?.maxOutputBytes, 0), }) -class InterpreterRuntimeError extends Error { - readonly node?: AstNode - /** - * The constructor name a program observes when it catches this failure (`caught.name`, and - * the brand behind `caught instanceof SyntaxError` etc.). "Error" unless the failing - * operation names a standard type in real JS - e.g. JSON.parse and invalid regex patterns - * throw SyntaxError, an unknown identifier is a ReferenceError, a bad normalize form is a - * RangeError. - */ - errorName: string = "Error" - - constructor( - message: string, - node?: AstNode, - readonly kind: DiagnosticKind = "ExecutionFailure", - readonly suggestions?: ReadonlyArray, - ) { - super(message) - this.name = "InterpreterRuntimeError" - - if (node) { - this.node = node - } - } - - as(errorName: string): this { - this.errorName = errorName - return this - } -} - -const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null - -const asNode = (value: unknown, context: string): AstNode => { - if (!isRecord(value) || typeof value.type !== "string") { - throw new InterpreterRuntimeError(`Invalid AST node while reading ${context}.`) - } - - return value as AstNode -} - -const getArray = (node: AstNode, key: string): Array => { - const value = node[key] - if (!Array.isArray(value)) { - throw new InterpreterRuntimeError(`Expected '${key}' to be an array.`, node) - } - - return value -} - -const getString = (node: AstNode, key: string): string => { - const value = node[key] - if (typeof value !== "string") { - throw new InterpreterRuntimeError(`Expected '${key}' to be a string.`, node) - } - - return value -} - -const getBoolean = (node: AstNode, key: string): boolean => { - const value = node[key] - if (typeof value !== "boolean") { - throw new InterpreterRuntimeError(`Expected '${key}' to be a boolean.`, node) - } - - return value -} - -const getOptionalNode = (node: AstNode, key: string): AstNode | undefined => { - const value = node[key] - if (value === undefined || value === null) { - return undefined - } - - return asNode(value, key) -} - -const getNode = (node: AstNode, key: string): AstNode => { - const value = node[key] - return asNode(value, key) -} - -const parseProgram = (code: string): ProgramNode => { - const transpiled = transpileModule(`async function __codemode__() {\n${code}\n}`, { - reportDiagnostics: true, - compilerOptions: { - target: ScriptTarget.ESNext, - module: ModuleKind.ESNext, - }, - }) - const diagnostic = transpiled.diagnostics?.find((item) => item.category === DiagnosticCategory.Error) - - if (diagnostic) { - throw new InterpreterRuntimeError( - `Failed to parse TypeScript: ${flattenDiagnosticMessageText(diagnostic.messageText, "\n")}`, - undefined, - "ParseError", - ) - } - - const bodyStart = transpiled.outputText.indexOf("{") + 1 - const bodyEnd = transpiled.outputText.lastIndexOf("}") - const executableCode = transpiled.outputText.slice(bodyStart, bodyEnd) - const parsed = parse(executableCode, { - ecmaVersion: "latest", - sourceType: "script", - allowReturnOutsideFunction: true, - allowAwaitOutsideFunction: true, - locations: true, - }) as unknown - - if (!isRecord(parsed) || parsed.type !== "Program" || !Array.isArray(parsed.body)) { - throw new InterpreterRuntimeError("Failed to parse script as a Program node.") - } - - return parsed as ProgramNode -} - -const formatLocation = (node?: AstNode): string => { - if (!node || !node.loc) { - return "" - } - - const location = sourceLocation(node) - return ` (line ${location.line}, col ${location.column})` -} - -const sourceLocation = (node: AstNode): { readonly line: number; readonly column: number } => ({ - line: Math.max(1, (node.loc?.start.line ?? 2) - 1), - column: Math.max(1, (node.loc?.start.column ?? 4) - 3), -}) - -const publicErrorMessage = (message: string): string => - message.replace(/\/(?:Users|home|private|tmp|var\/folders)\/[^\s"'`]+/g, "") - -const normalizeError = (error: unknown): Diagnostic => { - if (error instanceof InterpreterRuntimeError) { - return { - kind: error.kind, - message: `${error.message}${formatLocation(error.node)}`, - ...(error.node?.loc ? { location: sourceLocation(error.node) } : {}), - ...(error.suggestions ? { suggestions: error.suggestions } : {}), - } - } - - if (error instanceof ToolRuntimeError) { - return { - kind: error.kind, - message: error.message, - ...(error.suggestions.length > 0 ? { suggestions: error.suggestions } : {}), - } - } - - if (error instanceof ToolError) { - return { kind: "ToolFailure", message: publicErrorMessage(error.message) } - } - - if (error instanceof ProgramThrow) { - const value = error.value - let message: string - if (containsRuntimeReference(value)) { - // A thrown tool/function reference must not leak its internal structure. - message = "a non-data value" - } else if (typeof value === "string") { - message = value - } else if ( - value !== null && - typeof value === "object" && - typeof (value as { message?: unknown }).message === "string" - ) { - message = (value as { message: string }).message - } else { - try { - message = JSON.stringify(copyOut(value)) ?? String(value) - } catch { - message = String(value) - } - } - return { kind: "ExecutionFailure", message: `Uncaught: ${message}` } - } - - if (error instanceof RangeError && /call stack|recursion/i.test(error.message)) { - return { - kind: "ExecutionFailure", - message: "Execution exceeded the maximum nesting depth.", - } - } - - if (error instanceof Error) { - return { - kind: error.name === "SyntaxError" ? "ParseError" : "ExecutionFailure", - message: publicErrorMessage(error.message), - } - } - - // A non-Error thrown by a host tool (raw string / number / Symbol) still routes through - // path redaction so filesystem paths can never leak through the catch-all branch. - return { - kind: "ExecutionFailure", - message: publicErrorMessage(String(error)), - } -} - -// Shared by catch bindings, Promise.allSettled rejection reasons, and Promise.race losers. -const caughtErrorValue = (thrown: unknown): unknown => { - if (thrown instanceof ProgramThrow) return thrown.value - if (thrown instanceof InterpreterRuntimeError) return createErrorValue(thrown.errorName, thrown.message) - const name = thrown instanceof Error && errorConstructors.has(thrown.name) ? thrown.name : "Error" - return createErrorValue(name, normalizeError(thrown).message) -} - -const boundedData = (value: unknown, label: string): unknown => copyIn(value, label, true) - -const isRuntimeReference = (value: unknown): boolean => - value instanceof CodeModeFunction || - value instanceof ToolReference || - value instanceof IntrinsicReference || - value instanceof GlobalNamespace || - value instanceof GlobalMethodReference || - value instanceof PromiseNamespace || - value instanceof PromiseMethodReference || - value instanceof SandboxPromise || - value instanceof CoercionFunction || - value instanceof ErrorConstructorReference || - isSandboxValue(value) - -const containsRuntimeReference = (value: unknown, seen = new Set()): boolean => { - if (isRuntimeReference(value)) return true - if (value === null || typeof value !== "object") return false - if (seen.has(value)) return false - seen.add(value) - const contains = Array.isArray(value) - ? value.some((item) => containsRuntimeReference(item, seen)) - : Object.values(value).some((item) => containsRuntimeReference(item, seen)) - seen.delete(value) - return contains -} - -// Like containsRuntimeReference, but sandbox value types (Date/RegExp/Map/Set) count as data: -// operators and switch treat them as ordinary object operands (identity equality, ToPrimitive -// coercion) rather than rejecting them as opaque interpreter machinery. -const containsOpaqueReference = (value: unknown, seen = new Set()): boolean => { - if (isSandboxValue(value)) return false - if (isRuntimeReference(value)) return true - if (value === null || typeof value !== "object") return false - if (seen.has(value)) return false - seen.add(value) - const contains = Array.isArray(value) - ? value.some((item) => containsOpaqueReference(item, seen)) - : Object.values(value).some((item) => containsOpaqueReference(item, seen)) - seen.delete(value) - return contains -} - -// `typeof` never throws in JS; map every interpreter value to its JS-visible category. -// A SandboxPromise falls through to the final `typeof value` and reports "object", exactly -// like a real JS promise. -const typeofValue = (value: unknown): string => { - if ( - value instanceof CodeModeFunction || - value instanceof CoercionFunction || - value instanceof IntrinsicReference || - value instanceof GlobalMethodReference || - value instanceof PromiseMethodReference || - value instanceof PromiseNamespace || - value instanceof ErrorConstructorReference - ) - return "function" - if (value instanceof ToolReference) return value.path.length > 0 ? "function" : "object" - if (value instanceof GlobalNamespace) { - return value.name === "Math" || value.name === "JSON" || value.name === "console" ? "object" : "function" - } - return typeof value -} - -// `x instanceof C` against the constructors CodeMode knows. Like `typeof`, it observes any -// left-hand value (opaque references included) without coercing it. Error checks use the -// error brand: `instanceof Error` accepts every branded error; a specific error type matches -// its own brand only (as in JS, where TypeError instances are also Error instances). -const instanceofValue = (lhs: unknown, rhs: unknown, node: AstNode): boolean => { - if (rhs instanceof ErrorConstructorReference) { - const brand = errorBrandName(lhs) - return brand !== undefined && (rhs.name === "Error" || brand === rhs.name) - } - if (rhs instanceof GlobalNamespace) { - switch (rhs.name) { - case "Date": - return lhs instanceof SandboxDate - case "RegExp": - return lhs instanceof SandboxRegExp - case "Map": - return lhs instanceof SandboxMap - case "Set": - return lhs instanceof SandboxSet - case "Array": - return Array.isArray(lhs) - case "Object": - return lhs !== null && (typeof lhs === "object" || typeofValue(lhs) === "function") - } - } - if (rhs instanceof PromiseNamespace) return lhs instanceof SandboxPromise - // Number/String/Boolean wrap primitives in JS; no boxed values exist in CodeMode, so - // `x instanceof Number` is always false - exactly what it is for primitives in JS. - if (rhs instanceof CoercionFunction && (rhs.name === "Number" || rhs.name === "String" || rhs.name === "Boolean")) { - return false - } - throw new InterpreterRuntimeError( - "The right-hand side of 'instanceof' must be a constructor CodeMode knows: Error (or a specific error type like TypeError), Date, RegExp, Map, Set, Array, Object, or Promise.", - node, - ) -} - -// A regex engine failure message without the engine's own "Invalid regular expression:" -// prefix, so composed diagnostics read as one sentence instead of stuttering the phrase. -const regexFailureReason = (error: unknown): string => - (error instanceof Error ? error.message : String(error)).replace(/^Invalid regular expression:\s*/i, "") - -const escapeRegexHint = - 'To match special characters like ( ) [ ] { } + * ? . literally, escape them with a backslash (e.g. "\\\\(") or test for them with String.includes instead.' - -// A string method's pattern argument as a host regex: a sandbox regex passes its own host -// instance through (so `g` lastIndex semantics follow the spec across calls); a string becomes -// a pattern, exactly as String.prototype.match/matchAll/search do (`extraFlags` adds matchAll's -// implicit `g`). Invalid patterns fail as catchable program errors that say what was wrong -// with the pattern and how to fix it. -const toHostRegex = (arg: unknown, method: string, node: AstNode, extraFlags = ""): RegExp => { - if (arg instanceof SandboxRegExp) return arg.regex - if (typeof arg === "string") { - try { - return new RegExp(arg, extraFlags) - } catch (error) { - throw new InterpreterRuntimeError( - `String.${method} received the string ${JSON.stringify(arg)}, which is not a valid regular expression pattern (${regexFailureReason(error)}). ${escapeRegexHint}`, - node, - ).as("SyntaxError") - } - } - throw new InterpreterRuntimeError( - `String.${method} expects a regular expression (a /pattern/flags literal or new RegExp(...)) or a string pattern, not ${arg === null ? "null" : typeof arg}.`, - node, - ) -} - -// A host match result as a sandbox value: a plain array of the full match and captures, with -// `index` and named `groups` attached as own array properties (readable, and dropped at data -// boundaries exactly like JSON.stringify drops them in JS). `input` is omitted - it duplicates -// the whole subject string per match. -const matchToValue = (match: RegExpMatchArray): Array => { - const result: Array = Array.from(match, (group) => group) - if (match.index !== undefined) (result as Record & Array).index = match.index - if (match.groups) { - const groups: SafeObject = Object.create(null) as SafeObject - for (const [key, group] of Object.entries(match.groups)) { - if (!isBlockedMember(key)) groups[key] = group - } - ;(result as Record & Array).groups = groups - } - return result -} - -const invokeStringMethod = (value: string, name: string, args: Array, node: AstNode): unknown => { - const str = (index: number): string => { - const arg = args[index] - if (typeof arg !== "string") - throw new InterpreterRuntimeError(`String.${name} expects argument ${index + 1} to be a string.`, node) - return arg - } - const num = (index: number): number => { - const arg = args[index] - if (typeof arg !== "number") - throw new InterpreterRuntimeError(`String.${name} expects argument ${index + 1} to be a number.`, node) - return arg - } - const optNum = (index: number): number | undefined => (args[index] === undefined ? undefined : num(index)) - const optStr = (index: number): string | undefined => (args[index] === undefined ? undefined : str(index)) - - let result: unknown - switch (name) { - case "toLowerCase": - result = value.toLowerCase() - break - case "toUpperCase": - result = value.toUpperCase() - break - case "trim": - result = value.trim() - break - // trimLeft/trimRight are the legacy aliases of trimStart/trimEnd, kept because models write them. - case "trimStart": - case "trimLeft": - result = value.trimStart() - break - case "trimEnd": - case "trimRight": - result = value.trimEnd() - break - // Locale/options arguments are ignored: comparison runs with the host default locale, and - // the common use is a sort comparator where any consistent order works. - case "localeCompare": - result = value.localeCompare(str(0)) - break - case "normalize": { - const form = optStr(0) - try { - result = value.normalize(form) - } catch { - throw new InterpreterRuntimeError( - `String.normalize expects the form "NFC", "NFD", "NFKC", or "NFKD" (got ${JSON.stringify(form)}).`, - node, - ).as("RangeError") - } - break - } - case "split": { - if (args.length === 0) { - result = [value] - break - } - if (args[0] instanceof SandboxRegExp) { - result = value.split((args[0] as SandboxRegExp).regex, optNum(1)) - break - } - const requestedLimit = optNum(1) - result = value.split(str(0), requestedLimit === undefined ? undefined : requestedLimit >>> 0) - break - } - case "slice": - result = value.slice(optNum(0), optNum(1)) - break - case "includes": - result = value.includes(str(0), optNum(1)) - break - case "startsWith": - result = value.startsWith(str(0), optNum(1)) - break - case "endsWith": - result = value.endsWith(str(0), optNum(1)) - break - case "indexOf": - result = value.indexOf(str(0), optNum(1)) - break - case "lastIndexOf": - result = value.lastIndexOf(str(0), optNum(1)) - break - case "replace": - case "replaceAll": { - if (args[0] instanceof CodeModeFunction || args[1] instanceof CodeModeFunction) { - throw new InterpreterRuntimeError( - `String.${name} does not support function replacers in CodeMode; use match/matchAll and rebuild the string instead.`, - node, - "UnsupportedSyntax", - [supportedSyntaxMessage], - ) - } - if (args[0] instanceof SandboxRegExp) { - const pattern = (args[0] as SandboxRegExp).regex - const replacement = str(1) - if (name === "replaceAll" && !pattern.global) { - throw new InterpreterRuntimeError( - `String.replaceAll requires a regular expression with the global (g) flag: write /${pattern.source}/${pattern.flags}g, or use String.replace to replace only the first match.`, - node, - ) - } - result = name === "replace" ? value.replace(pattern, replacement) : value.replaceAll(pattern, replacement) - break - } - if (name === "replace") { - result = value.replace(str(0), str(1)) - break - } - result = value.replaceAll(str(0), str(1)) - break - } - case "match": { - const pattern = toHostRegex(args[0], name, node) - const matched = value.match(pattern) - if (matched === null) return null - // A global match is a plain array of matched strings; a non-global match carries - // index/groups own properties, so bypass the copying data checkpoint to keep them. - if (pattern.global) return boundedData(matched, "String.match result") - return matchToValue(matched) - } - case "matchAll": { - const pattern = toHostRegex(args[0], name, node, "g") - if (!pattern.global) { - throw new InterpreterRuntimeError( - `String.matchAll requires a regular expression with the global (g) flag: write /${pattern.source}/${pattern.flags}g, or use String.match for a single match.`, - node, - ) - } - // Materialized as an array (not an iterator); each entry is a match array with - // index/groups own properties. Match count is bounded by the subject length. - return Array.from(value.matchAll(pattern), matchToValue) - } - case "search": { - result = value.search(toHostRegex(args[0], name, node)) - break - } - case "repeat": { - const count = num(0) - if (!Number.isFinite(count) || count < 0) - throw new InterpreterRuntimeError("String.repeat expects a finite non-negative count.", node) - result = value.repeat(count) - break - } - case "padStart": - result = value.padStart(num(0), optStr(1)) - break - case "padEnd": - result = value.padEnd(num(0), optStr(1)) - break - case "charAt": - result = value.charAt(optNum(0) ?? 0) - break - case "at": - result = value.at(optNum(0) ?? 0) - break - case "substring": - result = value.substring(optNum(0) ?? 0, optNum(1)) - break - case "substr": - result = value.substr(optNum(0) ?? 0, optNum(1)) - break - // JS charCodeAt returns NaN out of range; NaN flows as an ordinary in-sandbox value - // (normalized to null only at the data boundary - see copyOut), so return it as-is. - case "charCodeAt": - result = value.charCodeAt(optNum(0) ?? 0) - break - case "codePointAt": - result = value.codePointAt(optNum(0) ?? 0) - break - case "toString": - result = value - break - case "concat": { - result = value.concat(...args.map((_, index) => str(index))) - break - } - default: - throw new InterpreterRuntimeError(`String method '${name}' is not available in CodeMode.`, node) - } - return boundedData(result, `String.${name} result`) -} - -const invokeNumberMethod = (value: number, name: string, args: Array, node: AstNode): unknown => { - const optNum = (index: number): number | undefined => { - const arg = args[index] - if (arg === undefined) return undefined - if (typeof arg !== "number") throw new InterpreterRuntimeError(`Number.${name} expects a number argument.`, node) - return arg - } - let result: unknown - switch (name) { - case "toFixed": - result = value.toFixed(optNum(0)) - break - case "toExponential": - result = value.toExponential(optNum(0)) - break - case "toPrecision": { - const digits = optNum(0) - result = digits === undefined ? value.toString() : value.toPrecision(digits) - break - } - case "toString": { - const radix = optNum(0) - if (radix !== undefined && (radix < 2 || radix > 36)) { - throw new InterpreterRuntimeError("Number.toString radix must be between 2 and 36.", node) - } - result = value.toString(radix) - break - } - default: - throw new InterpreterRuntimeError(`Number method '${name}' is not available in CodeMode.`, node) - } - return boundedData(result, `Number.${name} result`) -} - -// JavaScript's String(...) without tripping over CodeMode's null-prototype data objects. -const coerceToString = (value: unknown): string => { - if (value === null) return "null" - if (value === undefined) return "undefined" - // Sandbox values stringify deterministically: Date as ISO (not the host's locale/timezone - // toString), RegExp as its literal form, Map/Set with their JS Object.prototype tags. - if (value instanceof SandboxDate) - return Number.isFinite(value.time) ? new Date(value.time).toISOString() : "Invalid Date" - if (value instanceof SandboxRegExp) return `/${value.regex.source}/${value.regex.flags}` - if (value instanceof SandboxMap) return "[object Map]" - if (value instanceof SandboxSet) return "[object Set]" - if (typeof value === "object") { - return Array.isArray(value) - ? value.map((item) => (item === null || item === undefined ? "" : coerceToString(item))).join(",") - : "[object Object]" - } - return String(value) -} - -/** Compound assignment operators (`x op= y`), each applying the binary operator `op`. */ -const compoundOperators = new Set(["+=", "-=", "*=", "/=", "%=", "**=", "&=", "|=", "^=", "<<=", ">>=", ">>>="]) - -const coerceToNumber = (value: unknown): number => { - if (value instanceof SandboxDate) return value.time - if (isSandboxValue(value)) return Number.NaN - return value !== null && typeof value === "object" && !Array.isArray(value) ? Number.NaN : Number(value) -} - -const invokeCoercion = (ref: CoercionFunction, args: Array, node: AstNode): unknown => { - // Sandbox values coerce before the data checkpoint (which would JSON-serialize them): - // Number(date) is its time value, String(date) its ISO form, Boolean(x) is true. - const raw = args[0] - if (isSandboxValue(raw)) { - if (ref.name === "Boolean") return true - if (ref.name === "Number") return coerceToNumber(raw) - if (ref.name === "String") return coerceToString(raw) - if (ref.name === "parseInt") return parseInt(coerceToString(raw)) - return parseFloat(coerceToString(raw)) - } - const value = boundedData(args[0], `${ref.name} input`) - if (ref.name === "Number") return coerceToNumber(value) - if (ref.name === "Boolean") return Boolean(value) - if (ref.name === "parseInt") { - const radix = args[1] - if (radix !== undefined && typeof radix !== "number") - throw new InterpreterRuntimeError("parseInt expects a numeric radix.", node) - return parseInt(coerceToString(value), radix) - } - if (ref.name === "parseFloat") return parseFloat(coerceToString(value)) - return coerceToString(value) -} - -const invokeObjectMethod = (name: string, args: Array, node: AstNode): unknown => { - const requireObject = (): Record => { - const value = boundedData(args[0], `Object.${name} input`) - // Sandbox values (Date/RegExp/Map/Set) have no own enumerable properties in JS, so the - // Object.* helpers see them as empty objects - never their interpreter internals. - if (isSandboxValue(value)) return {} - if (value === null || typeof value !== "object" || Array.isArray(value)) { - throw new InterpreterRuntimeError(`Object.${name} expects a data object.`, node) - } - return value as Record - } - const guardedSet = (out: Record, key: string, item: unknown): void => { - if (isBlockedMember(key)) throw new InterpreterRuntimeError(`Property '${key}' is not available in CodeMode.`, node) - out[key] = item - } - switch (name) { - case "keys": { - // Object.keys(array) yields index strings (["0", "1", ...]) exactly as in JS; objects - // yield their own enumerable keys. (Tool references never reach here - the interpreter - // resolves them against the host tool tree first.) - const value = boundedData(args[0], "Object.keys input") - if (isSandboxValue(value)) return [] - if (Array.isArray(value)) return Object.keys(value) - if (value === null || typeof value !== "object") { - throw new InterpreterRuntimeError("Object.keys expects a data object or array.", node) - } - return Object.keys(value) - } - case "values": - return Object.values(requireObject()) - case "entries": - return Object.entries(requireObject()).map(([key, item]) => [key, item]) - case "hasOwn": - return Object.hasOwn(requireObject(), String(args[1])) - case "assign": { - const out: Record = Object.create(null) - for (const source of args) { - if (source === null || source === undefined) continue - const value = boundedData(source, "Object.assign input") - // A sandbox value source contributes nothing (no own enumerable properties in JS). - if (isSandboxValue(value)) continue - if (value === null || typeof value !== "object" || Array.isArray(value)) - throw new InterpreterRuntimeError("Object.assign expects data objects.", node) - for (const [key, item] of Object.entries(value)) guardedSet(out, key, item) - } - return out - } - case "fromEntries": { - // A Map is the idiomatic fromEntries source; use its entries directly (the data - // checkpoint would serialize a Map to {}). - if (args[0] instanceof SandboxMap) { - const out: Record = Object.create(null) - for (const [key, item] of (args[0] as SandboxMap).map.entries()) guardedSet(out, coerceToString(key), item) - return out - } - const pairs = boundedData(args[0], "Object.fromEntries input") - if (!Array.isArray(pairs)) - throw new InterpreterRuntimeError("Object.fromEntries expects an array of [key, value] pairs.", node) - const out: Record = Object.create(null) - for (const pair of pairs) { - if (!Array.isArray(pair)) - throw new InterpreterRuntimeError("Object.fromEntries expects [key, value] pairs.", node) - guardedSet(out, String(pair[0]), pair[1]) - } - return out - } - default: - throw new InterpreterRuntimeError(`Object.${name} is not available in CodeMode.`, node) - } -} - -const invokeMathMethod = (name: string, args: Array, node: AstNode): number => { - const nums = args.map((arg) => { - if (typeof arg !== "number") throw new InterpreterRuntimeError(`Math.${name} expects number arguments.`, node) - return arg - }) - const [a = Number.NaN, b = Number.NaN] = nums - switch (name) { - case "max": - return Math.max(...nums) - case "min": - return Math.min(...nums) - case "abs": - return Math.abs(a) - case "floor": - return Math.floor(a) - case "ceil": - return Math.ceil(a) - case "round": - return Math.round(a) - case "trunc": - return Math.trunc(a) - case "sign": - return Math.sign(a) - case "sqrt": - return Math.sqrt(a) - case "cbrt": - return Math.cbrt(a) - case "pow": - return Math.pow(a, b) - case "hypot": - return Math.hypot(...nums) - case "log": - return Math.log(a) - case "log2": - return Math.log2(a) - case "log10": - return Math.log10(a) - case "exp": - return Math.exp(a) - default: - throw new InterpreterRuntimeError(`Math.${name} is not available in CodeMode.`, node) - } -} - -const invokeJsonMethod = (name: string, args: Array, node: AstNode): unknown => { - switch (name) { - case "stringify": { - const replacer = args[1] - if (Array.isArray(replacer) || replacer instanceof CodeModeFunction) { - throw new InterpreterRuntimeError( - "JSON.stringify replacers are not supported in CodeMode.", - node, - "UnsupportedSyntax", - [supportedSyntaxMessage], - ) - } - const space = args[2] - const indent = typeof space === "number" || typeof space === "string" ? space : undefined - // copyIn first so only Data Values serialize, never a CodeModeFunction/ToolReference. - return JSON.stringify(copyOut(copyIn(args[0], "JSON.stringify value")), null, indent) - } - case "parse": { - const text = args[0] - if (typeof text !== "string") throw new InterpreterRuntimeError("JSON.parse expects a string.", node) - let parsed: unknown - try { - parsed = JSON.parse(text) - } catch (error) { - // The engine reason is derived from the program-supplied string (token/position), so - // it is safe to surface - and the position is exactly what a model needs to fix it. - throw new InterpreterRuntimeError( - `JSON.parse received invalid JSON: ${error instanceof Error ? error.message : String(error)}`, - node, - ).as("SyntaxError") - } - return copyIn(parsed, "JSON.parse result") - } - default: - throw new InterpreterRuntimeError(`JSON.${name} is not available in CodeMode.`, node) - } -} - -const invokeArrayStatic = (name: string, args: Array, node: AstNode): unknown => { - switch (name) { - case "isArray": - return Array.isArray(args[0]) - case "of": - return [...args] - case "from": { - if (args.length > 1) { - throw new InterpreterRuntimeError( - "Array.from(...) does not support a map function in CodeMode; call .map() on the result instead.", - node, - "UnsupportedSyntax", - [supportedSyntaxMessage], - ) - } - // Map/Set materialize directly (the data checkpoint would serialize them to {}). - if (args[0] instanceof SandboxMap) - return Array.from((args[0] as SandboxMap).map.entries(), ([key, item]) => [key, item]) - if (args[0] instanceof SandboxSet) return Array.from((args[0] as SandboxSet).set.values()) - const source = boundedData(args[0], "Array.from input") - if (typeof source === "string") return Array.from(source) - if (Array.isArray(source)) return [...source] - if ( - source !== null && - typeof source === "object" && - typeof (source as { length?: unknown }).length === "number" - ) { - return Array.from(source as ArrayLike) - } - throw new InterpreterRuntimeError("Array.from expects an array, string, Map, Set, or array-like value.", node) - } - default: - throw new InterpreterRuntimeError(`Array.${name} is not available in CodeMode.`, node) - } -} - -const invokeNumberStatic = (name: string, args: Array, node: AstNode): unknown => { - const value = args[0] - switch (name) { - case "isInteger": - return Number.isInteger(value) - case "isFinite": - return Number.isFinite(value) - case "isNaN": - return Number.isNaN(value) - case "isSafeInteger": - return Number.isSafeInteger(value) - case "parseInt": { - const radix = args[1] - if (radix !== undefined && typeof radix !== "number") - throw new InterpreterRuntimeError("Number.parseInt expects a numeric radix.", node) - return parseInt(coerceToString(value), radix) - } - case "parseFloat": - return parseFloat(coerceToString(value)) - default: - throw new InterpreterRuntimeError(`Number.${name} is not available in CodeMode.`, node) - } -} - -const invokeStringStatic = (name: string, args: Array, node: AstNode): unknown => { - const codes = args.map((arg) => { - if (typeof arg !== "number") throw new InterpreterRuntimeError(`String.${name} expects number arguments.`, node) - return arg - }) - switch (name) { - case "fromCharCode": - return String.fromCharCode(...codes) - case "fromCodePoint": - return String.fromCodePoint(...codes) - default: - throw new InterpreterRuntimeError(`String.${name} is not available in CodeMode.`, node) - } -} - -const invokeDateStatic = (name: string, args: Array, node: AstNode): number => { - switch (name) { - case "now": - return Date.now() - case "parse": - return Date.parse(coerceToString(args[0])) - case "UTC": { - const parts = args.map((arg) => coerceToNumber(arg)) - return Date.UTC(...(parts as Parameters)) - } - default: - throw new InterpreterRuntimeError(`Date.${name} is not available in CodeMode.`, node) - } -} - -const invokeDateMethod = (value: SandboxDate, name: string, node: AstNode): unknown => { - const hosted = new Date(value.time) - switch (name) { - case "getTime": - case "valueOf": - return value.time - case "toISOString": { - if (!Number.isFinite(value.time)) throw new InterpreterRuntimeError("Invalid time value.", node) - return hosted.toISOString() - } - // toJSON of an invalid date is null in JS (never a throw); toString stays ISO for - // determinism across host timezones/locales. - case "toJSON": - return Number.isFinite(value.time) ? hosted.toISOString() : null - case "toString": - return coerceToString(value) - case "getFullYear": - return hosted.getFullYear() - case "getMonth": - return hosted.getMonth() - case "getDate": - return hosted.getDate() - case "getDay": - return hosted.getDay() - case "getHours": - return hosted.getHours() - case "getMinutes": - return hosted.getMinutes() - case "getSeconds": - return hosted.getSeconds() - case "getMilliseconds": - return hosted.getMilliseconds() - case "getUTCFullYear": - return hosted.getUTCFullYear() - case "getUTCMonth": - return hosted.getUTCMonth() - case "getUTCDate": - return hosted.getUTCDate() - case "getUTCDay": - return hosted.getUTCDay() - case "getUTCHours": - return hosted.getUTCHours() - case "getUTCMinutes": - return hosted.getUTCMinutes() - case "getUTCSeconds": - return hosted.getUTCSeconds() - case "getUTCMilliseconds": - return hosted.getUTCMilliseconds() - case "getTimezoneOffset": - return hosted.getTimezoneOffset() - default: - throw new InterpreterRuntimeError(`Date method '${name}' is not available in CodeMode.`, node) - } -} - -const invokeRegExpMethod = (value: SandboxRegExp, name: string, args: Array, node: AstNode): unknown => { - switch (name) { - // test/exec run on the sandbox regex's own host instance, so `g`-flag lastIndex advances - // across calls per the spec. - case "test": - return value.regex.test(coerceToString(args[0])) - case "exec": { - const matched = value.regex.exec(coerceToString(args[0])) - if (matched === null) return null - return matchToValue(matched) - } - case "toString": - return coerceToString(value) - default: - throw new InterpreterRuntimeError(`RegExp method '${name}' is not available in CodeMode.`, node) - } -} - -const invokeGlobalMethod = (ref: GlobalMethodReference, args: Array, node: AstNode): unknown => { - if (ref.namespace === "console") - throw new InterpreterRuntimeError(`console.${ref.name} is not available in CodeMode.`, node) - if (ref.namespace === "Object") return invokeObjectMethod(ref.name, args, node) - if (ref.namespace === "Math") return invokeMathMethod(ref.name, args, node) - if (ref.namespace === "Array") return invokeArrayStatic(ref.name, args, node) - if (ref.namespace === "Number") return invokeNumberStatic(ref.name, args, node) - if (ref.namespace === "String") return invokeStringStatic(ref.name, args, node) - if (ref.namespace === "Date") { - if (!dateStatics.has(ref.name)) - throw new InterpreterRuntimeError(`Date.${ref.name} is not available in CodeMode.`, node) - return invokeDateStatic(ref.name, args, node) - } - if (ref.namespace === "RegExp" || ref.namespace === "Map" || ref.namespace === "Set") { - throw new InterpreterRuntimeError(`${ref.namespace}.${ref.name} is not available in CodeMode.`, node) - } - return invokeJsonMethod(ref.name, args, node) -} - -// Iterable spread sources: arrays, strings (code points), Maps (entry pairs), and Sets (values). -const spreadItems = (spread: unknown): Array | undefined => { - if (Array.isArray(spread)) return spread - if (typeof spread === "string") return Array.from(spread) - if (spread instanceof SandboxMap) - return Array.from(spread.map.entries(), ([key, item]): Array => [key, item]) - if (spread instanceof SandboxSet) return Array.from(spread.set.values()) - return undefined -} - -// Every identifier a parameter pattern binds, used to seed TDZ slots before defaults run. -const collectPatternNames = (pattern: AstNode, out: Array = []): Array => { - switch (pattern.type) { - case "Identifier": - out.push(getString(pattern, "name")) - break - case "AssignmentPattern": - collectPatternNames(getNode(pattern, "left"), out) - break - case "RestElement": - collectPatternNames(getNode(pattern, "argument"), out) - break - case "ArrayPattern": - for (const element of getArray(pattern, "elements")) { - if (element !== null) collectPatternNames(asNode(element, "elements"), out) - } - break - case "ObjectPattern": - for (const property of getArray(pattern, "properties")) { - const prop = asNode(property, "properties") - collectPatternNames(prop.type === "RestElement" ? getNode(prop, "argument") : getNode(prop, "value"), out) - } - break - } - return out -} - -class Interpreter { - private scopes: Array> - private readonly invokeTool: (path: ReadonlyArray, args: Array) => Effect.Effect - // Enumerable namespace/tool names at a node of the host tool tree, threaded from - // ToolRuntime.make like invokeTool: the interpreter never holds the tree itself. - private readonly toolKeys: (path: ReadonlyArray) => ReadonlyArray - private readonly logs: Array - private lastValue: unknown - // Caps how many eagerly forked tool calls run at once (the parallel-call concurrency cap). - private readonly callPermits: Semaphore.Semaphore - // Fiber-backed promises whose settlement no program construct has observed yet. Successful - // program completion drains these (like a runtime waiting on in-flight work at exit) and - // surfaces a never-awaited failure as an unhandled-rejection diagnostic. - private readonly pendingSettlements = new Set() - - constructor( - invokeTool: (path: ReadonlyArray, args: Array) => Effect.Effect, - toolKeys: (path: ReadonlyArray) => ReadonlyArray, - logs: Array = [], - ) { - const globalScope = new Map() - this.scopes = [globalScope] - this.invokeTool = invokeTool - this.toolKeys = toolKeys - this.logs = logs - this.lastValue = undefined - this.callPermits = Semaphore.makeUnsafe(TOOL_CALL_CONCURRENCY) - globalScope.set("tools", { mutable: false, value: new ToolReference([]) }) - globalScope.set("Promise", { mutable: false, value: new PromiseNamespace() }) - globalScope.set("undefined", { mutable: false, value: undefined }) - globalScope.set("Object", { mutable: false, value: new GlobalNamespace("Object") }) - globalScope.set("Math", { mutable: false, value: new GlobalNamespace("Math") }) - globalScope.set("JSON", { mutable: false, value: new GlobalNamespace("JSON") }) - globalScope.set("Number", { mutable: false, value: new CoercionFunction("Number") }) - globalScope.set("String", { mutable: false, value: new CoercionFunction("String") }) - globalScope.set("Boolean", { mutable: false, value: new CoercionFunction("Boolean") }) - globalScope.set("Array", { mutable: false, value: new GlobalNamespace("Array") }) - globalScope.set("console", { mutable: false, value: new GlobalNamespace("console") }) - globalScope.set("parseInt", { mutable: false, value: new CoercionFunction("parseInt") }) - globalScope.set("parseFloat", { mutable: false, value: new CoercionFunction("parseFloat") }) - globalScope.set("Date", { mutable: false, value: new GlobalNamespace("Date") }) - globalScope.set("RegExp", { mutable: false, value: new GlobalNamespace("RegExp") }) - globalScope.set("Map", { mutable: false, value: new GlobalNamespace("Map") }) - globalScope.set("Set", { mutable: false, value: new GlobalNamespace("Set") }) - // Error constructors are real values, so `x instanceof Error` works and `Error("msg")` - // (with or without `new`) constructs a branded { name, message } error object. - for (const name of errorConstructors) { - globalScope.set(name, { mutable: false, value: new ErrorConstructorReference(name) }) - } - // NaN/Infinity flow as ordinary in-sandbox values (normalized to null only at the data - // boundary - see copyOut), so their global bindings must exist too, e.g. `reduce(max, -Infinity)`. - globalScope.set("NaN", { mutable: false, value: NaN }) - globalScope.set("Infinity", { mutable: false, value: Infinity }) - } - - run(program: ProgramNode): Effect.Effect { - const self = this - // Run the program body in its own module scope on top of the builtin global scope, so - // top-level declarations (`let undefined = 5`, `const Object = ...`) shadow builtins like - // JS module scope, instead of colliding with the seeded globals. - this.pushScope() - return Effect.gen(function* () { - self.hoistFunctions(program.body) - let value: unknown = undefined - let returned = false - for (const statement of program.body) { - const result = yield* self.evaluateStatement(statement) - - if (result.kind === "return") { - value = result.value - returned = true - break - } - - if (result.kind === "break" || result.kind === "continue") { - throw new InterpreterRuntimeError(`Unexpected '${result.kind}' outside of a loop.`, statement) - } - - if (result.kind === "value") { - self.lastValue = result.value - } - } - if (!returned) value = self.lastValue - - // The program body runs inside an implicit async function, so a returned promise - // resolves before crossing the data boundary - `return tools.ns.tool(...)` works - // without an explicit await, exactly as in JS. - if (value instanceof SandboxPromise) value = yield* self.settlePromise(value) - yield* self.drainPendingSettlements() - return value - }).pipe(Effect.ensuring(Effect.sync(() => self.popScope()))) - } - - // Awaits every fiber-backed promise the program abandoned (fire-and-forget tool calls), so - // their work completes before the execution ends - mirroring a JS runtime waiting on - // in-flight I/O at exit. A failure nobody could have handled becomes an unhandled-rejection - // diagnostic (interrupted calls, e.g. Promise.race losers, are ignored). - private drainPendingSettlements(): Effect.Effect { - const self = this - return Effect.gen(function* () { - for (const promise of [...self.pendingSettlements]) { - const exit = yield* self.observePromise(promise) - if (Exit.isSuccess(exit) || Cause.hasInterruptsOnly(exit.cause)) continue - const failure = normalizeError(Cause.squash(exit.cause)) - throw new InterpreterRuntimeError( - `Unhandled rejection from an un-awaited tool call: ${failure.message}`, - undefined, - failure.kind, - ["Await tool calls - `const result = await tools.ns.tool(...)` - so failures can be caught and handled."], - ) - } - }) - } - - // Eagerly starts a tool call on a supervised child fiber (so the execution timeout and - // scope teardown interrupt it) gated by the concurrency semaphore, and wraps the fiber in a - // first-class promise value. `startImmediately` makes the runtime admit the call - charging - // the tool-call budget and firing onToolCallStart - at the call site, before any await. - private createToolCallPromise( - path: ReadonlyArray, - args: Array, - ): Effect.Effect { - const self = this - return Effect.map( - Effect.forkChild(this.callPermits.withPermit(Effect.suspend(() => self.invokeTool(path, args))), { - startImmediately: true, - }), - (fiber) => { - const promise = new SandboxPromise(fiber) - self.pendingSettlements.add(promise) - return promise - }, - ) - } - - // The promise's settlement as an Exit, marking it observed for unhandled-rejection tracking. - // Fiber settlement is idempotent, so observing the same promise repeatedly (await twice, - // Promise.all([p, p])) never re-runs the underlying call. - private observePromise(promise: SandboxPromise): Effect.Effect> { - this.pendingSettlements.delete(promise) - return promise.fiber !== undefined ? Fiber.await(promise.fiber) : Effect.exit(promise.immediate ?? Effect.void) - } - - // `await promise`: succeed with the fulfilled value or re-raise the failure so try/catch - // observes it exactly like a synchronous throw at the await site. - private settlePromise(promise: SandboxPromise, node?: AstNode): Effect.Effect { - const self = this - return Effect.flatMap(this.observePromise(promise), (exit) => self.unwrapPromiseExit(promise, exit, node)) - } - - private unwrapPromiseExit( - promise: SandboxPromise | undefined, - exit: Exit.Exit, - node?: AstNode, - ): Effect.Effect { - if (Exit.isSuccess(exit)) return Effect.succeed(exit.value) - // A call Promise.race interrupted after losing settles as a catchable program failure; - // any other interruption is execution teardown (timeout/host) and must keep propagating - // as interruption rather than becoming program-visible data. - if (promise?.interrupted === true && Cause.hasInterruptsOnly(exit.cause)) { - return Effect.fail( - new InterpreterRuntimeError( - "This tool call was interrupted because another value settled a Promise.race first.", - node, - ), - ) - } - return Effect.failCause(exit.cause) - } - - private evaluateStatement(node: AstNode): Effect.Effect { - switch (node.type) { - case "ExpressionStatement": - return Effect.map(this.evaluateExpression(getNode(node, "expression")), (value) => ({ kind: "value", value })) - case "VariableDeclaration": - return Effect.map(this.evaluateVariableDeclaration(node), () => ({ kind: "none" })) - case "ReturnStatement": { - const argumentNode = getOptionalNode(node, "argument") - return argumentNode - ? Effect.map(this.evaluateExpression(argumentNode), (value) => ({ kind: "return", value })) - : Effect.succeed({ kind: "return", value: undefined }) - } - case "BlockStatement": - return this.evaluateBlock(node) - case "IfStatement": - return this.evaluateIfStatement(node) - case "SwitchStatement": - return this.evaluateSwitchStatement(node) - case "WhileStatement": - return this.evaluateWhileStatement(node) - case "DoWhileStatement": - return this.evaluateDoWhileStatement(node) - case "ForStatement": - return this.evaluateForStatement(node) - case "ForOfStatement": - return this.evaluateForOfStatement(node) - case "ForInStatement": - return this.evaluateForInStatement(node) - case "BreakStatement": - return Effect.succeed(this.evaluateBreakStatement(node)) - case "ContinueStatement": - return Effect.succeed(this.evaluateContinueStatement(node)) - case "ThrowStatement": - return this.evaluateThrowStatement(node) - case "TryStatement": - return this.evaluateTryStatement(node) - case "EmptyStatement": - return Effect.succeed({ kind: "none" }) - case "FunctionDeclaration": - return Effect.succeed({ kind: "none" }) // bound ahead of time by hoistFunctions - default: - throw unsupportedSyntax(node.type, node) - } - } - - private evaluateBlock(node: AstNode): Effect.Effect { - this.pushScope() - const self = this - return Effect.gen(function* () { - const body = getArray(node, "body") - self.hoistFunctions(body) - - for (const statementValue of body) { - const statement = asNode(statementValue, "body") - const result = yield* self.evaluateStatement(statement) - - if (result.kind === "value") { - self.lastValue = result.value - continue - } - - if (result.kind !== "none") { - return result - } - } - - return { kind: "none" } satisfies StatementResult - }).pipe(Effect.ensuring(Effect.sync(() => self.popScope()))) - } - - private createFunction(node: AstNode): CodeModeFunction { - if (node.generator === true) { - throw new InterpreterRuntimeError( - "Generator functions are not supported in CodeMode.", - node, - "UnsupportedSyntax", - [supportedSyntaxMessage], - ) - } - return new CodeModeFunction( - getArray(node, "params").map((parameter, index) => asNode(parameter, `params[${index}]`)), - getNode(node, "body"), - this.scopes.slice(), - ) - } - - // Function declarations are hoisted: bound in their scope before the body runs, so a - // program can call a helper defined further down (matching JavaScript). - private hoistFunctions(statements: Array): void { - for (const statementValue of statements) { - if (!isRecord(statementValue) || statementValue.type !== "FunctionDeclaration") continue - const node = statementValue as AstNode - this.declare(getString(getNode(node, "id"), "name"), this.createFunction(node), true, node) - } - } - - private evaluateIfStatement(node: AstNode): Effect.Effect { - const testNode = getNode(node, "test") - const consequentNode = getNode(node, "consequent") - const alternateNode = getOptionalNode(node, "alternate") - - return Effect.flatMap(this.evaluateExpression(testNode), (test) => - test - ? this.evaluateStatement(consequentNode) - : alternateNode - ? this.evaluateStatement(alternateNode) - : Effect.succeed({ kind: "none" }), - ) - } - - private evaluateSwitchStatement(node: AstNode): Effect.Effect { - const self = this - this.pushScope() - return Effect.gen(function* () { - const discriminant = yield* self.evaluateExpression(getNode(node, "discriminant")) - if (containsOpaqueReference(discriminant)) { - throw new InterpreterRuntimeError( - "Switch discriminants must be data values in CodeMode.", - node, - "InvalidDataValue", - ) - } - const cases = getArray(node, "cases").map((value, index) => asNode(value, `cases[${index}]`)) - let defaultIndex: number | undefined - let selected: number | undefined - for (const [index, branch] of cases.entries()) { - const test = getOptionalNode(branch, "test") - if (!test) { - defaultIndex = index - continue - } - const candidate = yield* self.evaluateExpression(test) - if (containsOpaqueReference(candidate)) { - throw new InterpreterRuntimeError( - "Switch case values must be data values in CodeMode.", - test, - "InvalidDataValue", - ) - } - if (candidate === discriminant) { - selected = index - break - } - } - const start = selected ?? defaultIndex - if (start === undefined) return { kind: "none" } satisfies StatementResult - for (let index = start; index < cases.length; index += 1) { - for (const statementValue of getArray(cases[index]!, "consequent")) { - const result = yield* self.evaluateStatement(asNode(statementValue, "consequent")) - if (result.kind === "break") return { kind: "none" } satisfies StatementResult - if (result.kind === "return" || result.kind === "continue") return result - if (result.kind === "value") self.lastValue = result.value - } - } - return { kind: "none" } satisfies StatementResult - }).pipe(Effect.ensuring(Effect.sync(() => self.popScope()))) - } - - private evaluateWhileStatement(node: AstNode): Effect.Effect { - const testNode = getNode(node, "test") - const bodyNode = getNode(node, "body") - - const self = this - return Effect.gen(function* () { - while (yield* self.evaluateExpression(testNode)) { - const result = yield* self.evaluateStatement(bodyNode) - - if (result.kind === "continue") { - continue - } - - if (result.kind === "break") { - return { kind: "none" } satisfies StatementResult - } - - if (result.kind === "return") { - return result - } - - if (result.kind === "value") { - self.lastValue = result.value - } - } - - return { kind: "none" } satisfies StatementResult - }) - } - - private evaluateDoWhileStatement(node: AstNode): Effect.Effect { - const bodyNode = getNode(node, "body") - const testNode = getNode(node, "test") - - const self = this - return Effect.gen(function* () { - do { - const result = yield* self.evaluateStatement(bodyNode) - - if (result.kind === "continue") { - continue - } - - if (result.kind === "break") { - return { kind: "none" } satisfies StatementResult - } - - if (result.kind === "return") { - return result - } - - if (result.kind === "value") { - self.lastValue = result.value - } - } while (yield* self.evaluateExpression(testNode)) - - return { kind: "none" } satisfies StatementResult - }) - } - - private evaluateForStatement(node: AstNode): Effect.Effect { - this.pushScope() - const self = this - return Effect.gen(function* () { - const initNode = getOptionalNode(node, "init") - const testNode = getOptionalNode(node, "test") - const updateNode = getOptionalNode(node, "update") - const bodyNode = getNode(node, "body") - - if (initNode) { - if (initNode.type === "VariableDeclaration") { - yield* self.evaluateVariableDeclaration(initNode) - } else { - yield* self.evaluateExpression(initNode) - } - } - - const perIterationBindings = - initNode?.type === "VariableDeclaration" && getString(initNode, "kind") !== "var" - ? Array.from(self.currentScope().keys()) - : [] - - while (testNode ? yield* self.evaluateExpression(testNode) : true) { - let iterationScope: Map | undefined - if (perIterationBindings.length > 0) { - iterationScope = new Map( - perIterationBindings.map((name) => { - const binding = self.currentScope().get(name)! - return [name, { ...binding }] - }), - ) - self.scopes.push(iterationScope) - } - const result = yield* self.evaluateStatement(bodyNode).pipe( - Effect.ensuring( - Effect.sync(() => { - if (iterationScope) self.popScope() - }), - ), - ) - - if (result.kind === "return") { - return result - } - - if (result.kind === "break") { - return { kind: "none" } satisfies StatementResult - } - - if (result.kind === "value") { - self.lastValue = result.value - } - - if (iterationScope) { - const loopScope = self.currentScope() - for (const name of perIterationBindings) { - loopScope.set(name, { ...iterationScope.get(name)! }) - } - } - - if (updateNode) { - yield* self.evaluateExpression(updateNode) - } - - if (result.kind === "continue") { - continue - } - } - - return { kind: "none" } satisfies StatementResult - }).pipe(Effect.ensuring(Effect.sync(() => self.popScope()))) - } - - private evaluateForOfStatement(node: AstNode): Effect.Effect { - if (getBoolean(node, "await")) { - throw new InterpreterRuntimeError("for await...of is not supported.", node) - } - - const self = this - return Effect.gen(function* () { - const left = getNode(node, "left") - const right = yield* self.evaluateExpression(getNode(node, "right")) - const body = getNode(node, "body") - - // Arrays iterate in place; strings iterate code points; Maps iterate [key, value] - // pairs and Sets iterate values over a snapshot (mutation during iteration is safe). - const iterable = Array.isArray(right) ? right : spreadItems(right) - if (iterable === undefined) { - throw new InterpreterRuntimeError("for...of requires an array, string, Map, or Set value in CodeMode.", node) - } - - let declaration: { readonly pattern: AstNode; readonly mutable: boolean } | undefined - let assignmentName: string | undefined - - if (left.type === "VariableDeclaration") { - const declarations = getArray(left, "declarations") - if (declarations.length !== 1) { - throw new InterpreterRuntimeError("for...of supports one declared binding.", left) - } - - const declarator = asNode(declarations[0], "declarations[0]") - declaration = { pattern: getNode(declarator, "id"), mutable: getString(left, "kind") !== "const" } - } else if (left.type === "Identifier") { - assignmentName = getString(left, "name") - } else { - throw new InterpreterRuntimeError("Unsupported for...of binding.", left) - } - - for (const value of iterable) { - if (declaration) { - self.pushScope() - yield* self.declarePattern(declaration.pattern, value, declaration.mutable, left) - } else if (assignmentName) { - self.setIdentifierValue(assignmentName, value, left) - } - - const result = yield* self.evaluateStatement(body).pipe( - Effect.ensuring( - Effect.sync(() => { - if (declaration) self.popScope() - }), - ), - ) - - if (result.kind === "return") { - return result - } - - if (result.kind === "break") { - return { kind: "none" } - } - - if (result.kind === "value") { - self.lastValue = result.value - } - - if (result.kind === "continue") { - continue - } - } - - return { kind: "none" } - }) - } - - // Own enumerable string keys of a value, shared by `for...in` and `Object.keys` over tool - // references: plain data objects enumerate their own keys, arrays their index strings (plus - // any own non-index properties, e.g. match results' index/groups - exactly Object.keys in - // JS), and a tool reference the namespace/tool names at its path in the host tool tree. - // Returns undefined for everything else so callers can raise a contextual error. - private enumerableKeys(value: unknown): Array | undefined { - if (value instanceof ToolReference) { - return [...this.toolKeys(value.path)] - } - if (Array.isArray(value)) { - return Object.keys(value) - } - if (value !== null && typeof value === "object" && !isRuntimeReference(value)) { - return Object.keys(value) - } - return undefined - } - - private evaluateForInStatement(node: AstNode): Effect.Effect { - const self = this - return Effect.gen(function* () { - const left = getNode(node, "left") - const right = yield* self.evaluateExpression(getNode(node, "right")) - const body = getNode(node, "body") - - // Keys are snapshotted up front (mutation during iteration is safe): plain objects - // enumerate their own keys, arrays their index strings, and tool references the - // namespace/tool names at that node - the same enumeration Object.keys performs. - // Anything else (strings, Maps, Sets, numbers, null, ...) is a deliberate error rather - // than real JS's surprising behavior (indices for strings, zero iterations for - // Maps/Sets/null): the hint points at the constructs that do what the program means. - const keys = self.enumerableKeys(right) - if (keys === undefined) { - throw new InterpreterRuntimeError( - "for...in requires a plain object, array, or tools reference in CodeMode. Use for...of for arrays/strings/Maps/Sets, or Object.keys(value) for a key list.", - node, - ) - } - - let declaration: { readonly pattern: AstNode; readonly mutable: boolean } | undefined - let assignmentName: string | undefined - - if (left.type === "VariableDeclaration") { - const declarations = getArray(left, "declarations") - if (declarations.length !== 1) { - throw new InterpreterRuntimeError("for...in supports one declared binding.", left) - } - - const declarator = asNode(declarations[0], "declarations[0]") - declaration = { pattern: getNode(declarator, "id"), mutable: getString(left, "kind") !== "const" } - } else if (left.type === "Identifier") { - assignmentName = getString(left, "name") - } else { - throw new InterpreterRuntimeError("Unsupported for...in binding.", left) - } - - for (const key of keys) { - if (declaration) { - self.pushScope() - yield* self.declarePattern(declaration.pattern, key, declaration.mutable, left) - } else if (assignmentName) { - self.setIdentifierValue(assignmentName, key, left) - } - - const result = yield* self.evaluateStatement(body).pipe( - Effect.ensuring( - Effect.sync(() => { - if (declaration) self.popScope() - }), - ), - ) - - if (result.kind === "return") { - return result - } - - if (result.kind === "break") { - return { kind: "none" } - } - - if (result.kind === "value") { - self.lastValue = result.value - } - - if (result.kind === "continue") { - continue - } - } - - return { kind: "none" } - }) - } - - private evaluateBreakStatement(node: AstNode): StatementResult { - const labelNode = getOptionalNode(node, "label") - - if (labelNode) { - throw new InterpreterRuntimeError("Labeled break is not supported in v1.", node) - } - - return { kind: "break" } - } - - private evaluateContinueStatement(node: AstNode): StatementResult { - const labelNode = getOptionalNode(node, "label") - - if (labelNode) { - throw new InterpreterRuntimeError("Labeled continue is not supported in v1.", node) - } - - return { kind: "continue" } - } - - private evaluateThrowStatement(node: AstNode): Effect.Effect { - const argument = getNode(node, "argument") - return Effect.flatMap(this.evaluateExpression(argument), (value) => Effect.fail(new ProgramThrow(value))) - } - - private evaluateTryStatement(node: AstNode): Effect.Effect { - const body = getNode(node, "block") - const handler = getOptionalNode(node, "handler") - const finalizer = getOptionalNode(node, "finalizer") - const self = this - - const attempted = Effect.matchCauseEffect(this.evaluateStatement(body), { - onFailure: (cause) => { - if (cause.reasons.some(Cause.isInterruptReason) || !handler) { - return Effect.failCause(cause) - } - - // The program sees a plain { message } error (or the thrown value itself) - see - // caughtErrorValue, shared with Promise.allSettled rejection reasons. - const caught = caughtErrorValue(Cause.squash(cause)) - const parameter = getOptionalNode(handler, "param") - self.pushScope() - return Effect.gen(function* () { - if (parameter) yield* self.declarePattern(parameter, caught, true, handler) - return yield* self.evaluateStatement(getNode(handler, "body")) - }).pipe(Effect.ensuring(Effect.sync(() => self.popScope()))) - }, - onSuccess: Effect.succeed, - }) - - if (!finalizer) return attempted - - const isAbrupt = (result: StatementResult): boolean => - result.kind === "return" || result.kind === "break" || result.kind === "continue" - - return Effect.matchCauseEffect(attempted, { - onFailure: (cause) => - cause.reasons.some(Cause.isInterruptReason) - ? Effect.failCause(cause) - : Effect.flatMap(this.evaluateStatement(finalizer), (final) => - isAbrupt(final) ? Effect.succeed(final) : Effect.failCause(cause), - ), - onSuccess: (result) => - Effect.flatMap(this.evaluateStatement(finalizer), (final) => - isAbrupt(final) ? Effect.succeed(final) : Effect.succeed(result), - ), - }) - } - - private evaluateVariableDeclaration(node: AstNode): Effect.Effect { - const kind = getString(node, "kind") - const declarations = getArray(node, "declarations") - const self = this - return Effect.gen(function* () { - for (const declarationValue of declarations) { - const declaration = asNode(declarationValue, "declarations") - - if (declaration.type !== "VariableDeclarator") { - throw new InterpreterRuntimeError("Unsupported variable declaration shape.", declaration) - } - - const init = getOptionalNode(declaration, "init") - const value = init ? yield* self.evaluateExpression(init) : undefined - yield* self.declarePattern(getNode(declaration, "id"), value, kind !== "const", declaration) - } - }) - } - - private declarePattern( - pattern: AstNode, - value: unknown, - mutable: boolean, - node: AstNode, - ): Effect.Effect { - const self = this - return Effect.gen(function* () { - if (pattern.type === "Identifier") { - self.declare(getString(pattern, "name"), value, mutable, node) - return - } - - // Default values: `x = expr` / `{ a = 1 }` - the default is evaluated only when the value is undefined. - if (pattern.type === "AssignmentPattern") { - const resolved = value === undefined ? yield* self.evaluateExpression(getNode(pattern, "right")) : value - yield* self.declarePattern(getNode(pattern, "left"), resolved, mutable, node) - return - } - - if (pattern.type === "ObjectPattern") { - if (value === null || typeof value !== "object" || Array.isArray(value) || isRuntimeReference(value)) { - throw new InterpreterRuntimeError( - "Object destructuring requires a data object value.", - pattern, - "InvalidDataValue", - ) - } - - const consumed = new Set() - for (const propertyValue of getArray(pattern, "properties")) { - const property = asNode(propertyValue, "properties") - - // Object rest: `{ a, ...others }` - gather the not-yet-consumed own keys. - if (property.type === "RestElement") { - const rest: SafeObject = Object.create(null) as SafeObject - for (const [key, item] of Object.entries(value as SafeObject)) { - if (!consumed.has(key) && !isBlockedMember(key)) rest[key] = item - } - yield* self.declarePattern(getNode(property, "argument"), rest, mutable, property) - continue - } - - if ( - property.type !== "Property" || - getBoolean(property, "computed") || - getString(property, "kind") !== "init" - ) { - throw new InterpreterRuntimeError("Only named object destructuring properties are supported.", property) - } - - const keyNode = getNode(property, "key") - const key = keyNode.type === "Identifier" ? getString(keyNode, "name") : String(keyNode.value) - if (isBlockedMember(key)) { - throw new InterpreterRuntimeError(`Property '${key}' is not available in CodeMode.`, keyNode) - } - consumed.add(key) - yield* self.declarePattern(getNode(property, "value"), (value as SafeObject)[key], mutable, property) - } - return - } - - if (pattern.type === "ArrayPattern") { - if (!Array.isArray(value)) { - throw new InterpreterRuntimeError("Array destructuring requires an array value.", pattern) - } - - for (const [index, item] of getArray(pattern, "elements").entries()) { - if (item === null) continue - const element = asNode(item, `elements[${index}]`) - // Array rest: `[head, ...tail]` - binds the remaining elements (must be last). - if (element.type === "RestElement") { - yield* self.declarePattern(getNode(element, "argument"), value.slice(index), mutable, element) - break - } - yield* self.declarePattern(element, value[index], mutable, pattern) - } - return - } - - throw new InterpreterRuntimeError(`Unsupported binding pattern '${pattern.type}'.`, pattern) - }) - } - - private evaluateExpression(node: AstNode): Effect.Effect { - switch (node.type) { - case "Literal": { - // A regex literal parses as a Literal node carrying { pattern, flags }; construct the - // sandbox regex from those (the host `value` instance is never exposed). - const regex = node.regex - if (isRecord(regex) && typeof regex.pattern === "string") { - return Effect.sync(() => - this.constructRegExp([regex.pattern, typeof regex.flags === "string" ? regex.flags : ""], node), - ) - } - return Effect.sync(() => boundedData(node.value, "Literal")) - } - case "Identifier": - return Effect.sync(() => this.getIdentifierValue(getString(node, "name"), node)) - case "BinaryExpression": - return this.evaluateBinaryExpression(node) - case "LogicalExpression": - return this.evaluateLogicalExpression(node) - case "UnaryExpression": - return this.evaluateUnaryExpression(node) - case "AssignmentExpression": - return this.evaluateAssignmentExpression(node) - case "CallExpression": - return this.evaluateCallExpression(node) - case "ArrowFunctionExpression": - case "FunctionExpression": - return Effect.sync(() => this.createFunction(node)) - case "MemberExpression": - return this.readMember(node) - case "ChainExpression": - return Effect.map(this.evaluateExpression(getNode(node, "expression")), (value) => - value === OptionalShortCircuit ? undefined : value, - ) - case "ObjectExpression": - return this.evaluateObjectExpression(node) - case "ArrayExpression": - return this.evaluateArrayExpression(node) - case "TemplateLiteral": - return this.evaluateTemplateLiteral(node) - case "ConditionalExpression": - return this.evaluateConditionalExpression(node) - case "UpdateExpression": - return this.evaluateUpdateExpression(node) - case "AwaitExpression": { - // `await` resolves a promise value; awaiting anything else is a passthrough no-op, - // matching real JS semantics for non-thenables. - const self = this - return Effect.flatMap(this.evaluateExpression(getNode(node, "argument")), (value) => - value instanceof SandboxPromise ? self.settlePromise(value, node) : Effect.succeed(value), - ) - } - case "NewExpression": - return this.evaluateNewExpression(node) - default: - throw unsupportedSyntax(node.type, node) - } - } - - private evaluateNewExpression(node: AstNode): Effect.Effect { - const callee = getNode(node, "callee") - if (callee.type !== "Identifier") { - throw unsupportedSyntax("NewExpression", node) - } - const name = getString(callee, "name") - const argNodes = getArray(node, "arguments") - const self = this - if (name === "Promise") { - throw new InterpreterRuntimeError( - "new Promise(...) is not supported in CodeMode; tool calls already return promises - call the tool and await the result.", - node, - "UnsupportedSyntax", - [supportedSyntaxMessage], - ) - } - if (errorConstructors.has(name)) { - return Effect.gen(function* () { - const arg = - argNodes.length > 0 ? yield* self.evaluateExpression(asNode(argNodes[0], "arguments[0]")) : undefined - return createErrorValue(name, arg === undefined ? "" : coerceToString(arg)) - }) - } - if (valueConstructors.has(name)) { - return Effect.gen(function* () { - const args = yield* self.evaluateCallArguments(argNodes) - switch (name) { - case "Date": - return self.constructDate(args) - case "RegExp": - return self.constructRegExp(args, node) - case "Map": - return self.constructMap(args[0], node) - default: - return self.constructSet(args[0], node) - } - }) - } - throw unsupportedSyntax("NewExpression", node) - } - - private constructDate(args: Array): SandboxDate { - if (args.length === 0) return new SandboxDate(Date.now()) - if (args.length === 1) { - const arg = args[0] - if (arg instanceof SandboxDate) return new SandboxDate(arg.time) - if (typeof arg === "number") return new SandboxDate(new Date(arg).getTime()) - if (typeof arg === "string") return new SandboxDate(Date.parse(arg)) - return new SandboxDate(Number.NaN) - } - // new Date(year, month, day?, hours?, ...) - local-time component form. - const parts = args.map((arg) => coerceToNumber(arg)) - return new SandboxDate(new Date(...(parts as [number, number])).getTime()) - } - - private constructRegExp(args: Array, node: AstNode): SandboxRegExp { - const first = args[0] - const pattern = - first instanceof SandboxRegExp ? first.regex.source : first === undefined ? "" : coerceToString(first) - const flagsArg = args[1] - if (flagsArg !== undefined && typeof flagsArg !== "string") { - throw new InterpreterRuntimeError( - `RegExp flags must be a string of flag characters (e.g. "g", "gi"), not ${flagsArg === null ? "null" : typeof flagsArg}.`, - node, - ) - } - const flags = flagsArg ?? (first instanceof SandboxRegExp ? first.regex.flags : "") - try { - return new SandboxRegExp(pattern, flags) - } catch (error) { - // Say which part was rejected and how to fix it, instead of passing the engine - // message through bare. A flags failure names the flags; a pattern failure gets the - // escaping hint (the usual cause is an unescaped metacharacter in a built-up string). - const reason = regexFailureReason(error) - throw new InterpreterRuntimeError( - /flag/i.test(reason) - ? `new RegExp(...) received invalid flags ${JSON.stringify(flags)} (${reason}). Valid flags are d, g, i, m, s, u, v, and y.` - : `new RegExp(...) received ${JSON.stringify(pattern)}, which is not a valid regular expression pattern (${reason}). ${escapeRegexHint}`, - node, - ).as("SyntaxError") - } - } - - private constructMap(init: unknown, node: AstNode): SandboxMap { - const target = new SandboxMap() - if (init === undefined || init === null) return target - const entries = Array.isArray(init) - ? init - : init instanceof SandboxMap - ? Array.from(init.map.entries(), ([key, item]): Array => [key, item]) - : undefined - if (entries === undefined) { - throw new InterpreterRuntimeError( - "new Map(...) expects an array of [key, value] pairs, a Map, or no argument.", - node, - ) - } - for (const pair of entries) { - if (!Array.isArray(pair)) { - throw new InterpreterRuntimeError("new Map(...) expects [key, value] pairs.", node) - } - target.map.set(pair[0], pair[1]) - } - return target - } - - private constructSet(init: unknown, node: AstNode): SandboxSet { - const target = new SandboxSet() - if (init === undefined || init === null) return target - const items = Array.isArray(init) - ? init - : init instanceof SandboxSet - ? Array.from(init.set.values()) - : typeof init === "string" - ? Array.from(init) - : undefined - if (items === undefined) { - throw new InterpreterRuntimeError("new Set(...) expects an array, Set, string, or no argument.", node) - } - for (const item of items) target.set.add(item) - return target - } - - private evaluateBinaryExpression(node: AstNode): Effect.Effect { - const operator = getString(node, "operator") - const self = this - return Effect.gen(function* () { - const lhs = yield* self.evaluateExpression(getNode(node, "left")) - const rhs = yield* self.evaluateExpression(getNode(node, "right")) - // Like `typeof`, `instanceof` observes any value without coercing it (a promise or - // function operand is a legitimate question, not an error), so it is handled before - // the data-only operand check. - if (operator === "instanceof") return instanceofValue(lhs, rhs, node) - return boundedData(self.applyBinaryOperator(operator, lhs, rhs, node), "Binary expression result") - }) - } - - /** - * Applies a binary operator to two already-evaluated operands with CodeMode's coercion - * semantics. Shared by binary expressions and compound assignment (`x op= y` must behave - * exactly like `x = x op y`, coercion included). - */ - private applyBinaryOperator(operator: string, lhs: unknown, rhs: unknown, node: AstNode): unknown { - if (containsOpaqueReference(lhs) || containsOpaqueReference(rhs)) { - throw new InterpreterRuntimeError("Binary operators require data values in CodeMode.", node, "InvalidDataValue") - } - // Data objects/arrays are null-prototype, so JS's ToPrimitive throws an opaque host - // "No default value" TypeError when an operator coerces them. Coerce to their JS string - // form first (as String(x) / template literals do) so operators behave like JavaScript. - // A Date follows its ToPrimitive hints: string for `+` (concatenation), its time value - // for arithmetic and ordering - so `end - start` and `a < b` work as in JS. - // Identity (=== / !==) and the right operand of `in` keep their raw object value. - const coerceOperand = (operand: unknown): unknown => { - if (operand instanceof SandboxDate) return operator === "+" ? coerceToString(operand) : operand.time - return operand !== null && typeof operand === "object" ? coerceToString(operand) : operand - } - const bothObjects = lhs !== null && typeof lhs === "object" && rhs !== null && typeof rhs === "object" - const l = coerceOperand(lhs) - const r = coerceOperand(rhs) - switch (operator) { - case "+": - return (l as string) + (r as string) - case "-": - return (l as number) - (r as number) - case "*": - return (l as number) * (r as number) - case "/": - return (l as number) / (r as number) - case "%": - return (l as number) % (r as number) - case "**": - return (l as number) ** (r as number) - // Two objects compare by identity in JS (no ToPrimitive); only object-vs-primitive coerces. - case "==": - return bothObjects ? lhs === rhs : l == r - case "===": - return lhs === rhs - case "!=": - return bothObjects ? lhs !== rhs : l != r - case "!==": - return lhs !== rhs - case "<": - return (l as string) < (r as string) - case "<=": - return (l as string) <= (r as string) - case ">": - return (l as string) > (r as string) - case ">=": - return (l as string) >= (r as string) - case "&": - return (l as number) & (r as number) - case "|": - return (l as number) | (r as number) - case "^": - return (l as number) ^ (r as number) - case "<<": - return (l as number) << (r as number) - case ">>": - return (l as number) >> (r as number) - case ">>>": - return (l as number) >>> (r as number) - case "in": - if (rhs === null || typeof rhs !== "object") { - throw new InterpreterRuntimeError("The 'in' operator requires a data object on the right-hand side.", node) - } - // Own properties only, so arrays don't leak the host Array.prototype (map/constructor/...). - return Object.hasOwn(rhs as object, coerceOperand(lhs) as PropertyKey) - default: - throw new InterpreterRuntimeError(`Unsupported binary operator '${operator}'.`, node) - } - } - - private evaluateLogicalExpression(node: AstNode): Effect.Effect { - const operator = getString(node, "operator") - return Effect.flatMap(this.evaluateExpression(getNode(node, "left")), (left) => { - if (operator === "&&") return left ? this.evaluateExpression(getNode(node, "right")) : Effect.succeed(left) - if (operator === "||") return left ? Effect.succeed(left) : this.evaluateExpression(getNode(node, "right")) - if (operator === "??") - return left !== null && left !== undefined - ? Effect.succeed(left) - : this.evaluateExpression(getNode(node, "right")) - throw new InterpreterRuntimeError(`Unsupported logical operator '${operator}'.`, node) - }) - } - - private evaluateUnaryExpression(node: AstNode): Effect.Effect { - const operator = getString(node, "operator") - const argument = getNode(node, "argument") - // `typeof undeclaredIdentifier` is `"undefined"` in JS (never a ReferenceError), so - // feature-detection guards like `typeof x !== "undefined"` don't crash. Short-circuit before - // evaluating the argument; a declared-but-TDZ binding still falls through to the normal throw. - if (operator === "typeof" && argument.type === "Identifier" && !this.resolveBinding(getString(argument, "name"))) { - return Effect.succeed("undefined") - } - return Effect.map(this.evaluateExpression(argument), (value) => { - // `typeof` and `!` never throw in JS - they observe any value (functions and runtime - // references included) without coercing it, so feature detection and negation work. - if (operator === "typeof") return typeofValue(value) - if (operator === "!") return !value - if (containsOpaqueReference(value)) { - throw new InterpreterRuntimeError("Unary operators require data values in CodeMode.", node, "InvalidDataValue") - } - // Numeric/bitwise unary operators ToPrimitive their operand; a Date yields its time value - // (`+date` is the epoch-ms idiom), other null-prototype data objects/arrays coerce to - // their JS string form first (see evaluateBinaryExpression). - const operand = - value instanceof SandboxDate - ? value.time - : value !== null && typeof value === "object" - ? coerceToString(value) - : value - let result: unknown - switch (operator) { - case "+": - result = +(operand as number) - break - case "-": - result = -(operand as number) - break - case "~": - result = ~(operand as number) - break - default: - throw new InterpreterRuntimeError(`Unsupported unary operator '${operator}'.`, node) - } - return boundedData(result, "Unary expression result") - }) - } - - private evaluateAssignmentExpression(node: AstNode): Effect.Effect { - const left = getNode(node, "left") - const operator = getString(node, "operator") - const self = this - return Effect.gen(function* () { - if (operator === "??=" || operator === "||=" || operator === "&&=") { - return yield* self.evaluateLogicalAssignment(node, left, operator) - } - const rightValue = yield* self.evaluateExpression(getNode(node, "right")) - if (left.type === "Identifier") { - const name = getString(left, "name") - if (operator === "=") return self.setIdentifierValue(name, rightValue, left) - const next = boundedData( - self.applyCompoundAssignment(operator, self.getIdentifierValue(name, left), rightValue, node), - "Assignment result", - ) - return self.setIdentifierValue(name, next, left) - } - if (left.type === "MemberExpression") { - if (operator === "=") return yield* self.writeMember(left, rightValue) - return yield* self.modifyMember(left, (current) => { - const next = boundedData( - self.applyCompoundAssignment(operator, current, rightValue, node), - "Assignment result", - ) - return Effect.succeed({ write: true, next, result: next }) - }) - } - throw new InterpreterRuntimeError("Assignment target must be an Identifier or MemberExpression.", left) - }) - } - - private evaluateLogicalAssignment( - node: AstNode, - left: AstNode, - operator: string, - ): Effect.Effect { - const self = this - const shouldAssign = (current: unknown): boolean => - operator === "??=" ? current === null || current === undefined : operator === "||=" ? !current : Boolean(current) - if (left.type === "Identifier") { - const name = getString(left, "name") - return Effect.gen(function* () { - const current = self.getIdentifierValue(name, left) - if (!shouldAssign(current)) return current - const rightValue = yield* self.evaluateExpression(getNode(node, "right")) - return self.setIdentifierValue(name, rightValue, left) - }) - } - if (left.type === "MemberExpression") { - // Resolve the member exactly once; evaluate the RHS only if we actually assign. - return self.modifyMember(left, (current) => - shouldAssign(current) - ? Effect.map(self.evaluateExpression(getNode(node, "right")), (rightValue) => ({ - write: true, - next: rightValue, - result: rightValue, - })) - : Effect.succeed({ write: false, next: current, result: current }), - ) - } - throw new InterpreterRuntimeError("Assignment target must be an Identifier or MemberExpression.", left) - } - - private evaluateUpdateExpression(node: AstNode): Effect.Effect { - const operator = getString(node, "operator") - const argument = getNode(node, "argument") - const prefix = getBoolean(node, "prefix") - - const increment = operator === "++" ? 1 : operator === "--" ? -1 : undefined - - if (increment === undefined) { - throw new InterpreterRuntimeError(`Unsupported update operator '${operator}'.`, node) - } - - if (argument.type === "Identifier") { - return Effect.sync(() => { - const name = getString(argument, "name") - const current = Number(this.getIdentifierValue(name, argument)) - const next = current + increment - this.setIdentifierValue(name, next, argument) - return prefix ? next : current - }) - } - - if (argument.type === "MemberExpression") { - return this.modifyMember(argument, (current) => { - const value = Number(current) - const next = value + increment - return Effect.succeed({ write: true, next, result: prefix ? next : value }) - }) - } - - throw new InterpreterRuntimeError("Update target must be an Identifier or MemberExpression.", argument) - } - - private evaluateCallExpression(node: AstNode): Effect.Effect { - const callee = getNode(node, "callee") - const argNodes = getArray(node, "arguments") - - const self = this - return Effect.gen(function* () { - const callable = yield* self.evaluateExpression(callee) - if (callable === OptionalShortCircuit) return OptionalShortCircuit - if ((callable === null || callable === undefined) && node.optional === true) return OptionalShortCircuit - - const args = yield* self.evaluateCallArguments(argNodes) - - if (callable instanceof ToolReference) { - if (callable.path.length === 0) throw new InterpreterRuntimeError("The tools root is not callable.", callee) - // An un-awaited tool call is a first-class promise value; the call itself starts now. - return yield* self.createToolCallPromise(callable.path, args) - } - if (callable instanceof PromiseMethodReference) { - return yield* self.invokePromiseMethod(callable, args, node) - } - if (callable instanceof CodeModeFunction) { - return yield* self.invokeFunction(callable, args) - } - if (callable instanceof IntrinsicReference) { - return yield* self.invokeIntrinsic(callable, args, node) - } - if (callable instanceof GlobalMethodReference) { - if (callable.namespace === "console") return self.invokeConsole(callable.name, args, node) - if (callable.namespace === "Object" && args[0] instanceof ToolReference) { - return self.invokeObjectMethodOnTools(callable.name, args[0] as ToolReference, node) - } - return boundedData(invokeGlobalMethod(callable, args, node), `${callable.namespace}.${callable.name} result`) - } - if (callable instanceof CoercionFunction) { - return boundedData(invokeCoercion(callable, args, node), `${callable.name} result`) - } - // `Error("msg")` without `new` constructs an error exactly like `new Error("msg")`, as in JS. - if (callable instanceof ErrorConstructorReference) { - return createErrorValue(callable.name, args[0] === undefined ? "" : coerceToString(args[0])) - } - throw new InterpreterRuntimeError("Only tools are callable in CodeMode.", callee) - }) - } - - // Object.* over a tool reference: `Object.keys(tools)` / `Object.keys(tools.ns)` enumerate - // namespace/tool names from the host tool tree - the discovery idiom a model reaches for - // first. Every other Object helper cannot produce data from a tool reference, so it fails - // with a pointer at the working idioms instead of the generic plain-objects-only message. - private invokeObjectMethodOnTools(name: string, ref: ToolReference, node: AstNode): unknown { - if (name === "keys") { - return boundedData(this.enumerableKeys(ref)!, "Object.keys result") - } - throw new InterpreterRuntimeError( - `Object.${name}(...) cannot read tool references: they are not plain data. Use Object.keys(tools) for names, or tools.$codemode.search({ query }) for signatures.`, - node, - "InvalidDataValue", - ) - } - - private invokeConsole(name: string, args: Array, node: AstNode): undefined { - if (!consoleMethods.has(name)) - throw new InterpreterRuntimeError(`console.${name} is not available in CodeMode.`, node) - this.logs.push(publicErrorMessage(this.formatConsoleMessage(name, args, node))) - return undefined - } - - private formatConsoleMessage(name: string, args: Array, node: AstNode): string { - if (name === "dir") return args.length === 0 ? "undefined" : this.formatConsoleArgument(args[0]) - if (name === "table") return this.formatConsoleTable(args[0], args[1], node) - const prefix = name === "warn" ? "[warn] " : name === "error" ? "[error] " : name === "debug" ? "[debug] " : "" - return `${prefix}${args.map((arg) => this.formatConsoleArgument(arg)).join(" ")}` - } - - // Console arguments format deeply and totally: values render as a debugger would show them - // rather than as boundary JSON - numbers keep NaN/Infinity (JSON would say null), sandbox - // values keep their friendly forms at ANY depth (ISO date, /regex/flags, Map(n) [...], - // Set(n) [...]), opaque runtime references become "[CodeMode reference]" markers in place, - // and plain objects/arrays render JSON-style. Formatting never fails the program: cycles - // render "[Circular]" and extreme depth degrades to "...". - private formatConsoleArgument(value: unknown): string { - if (value === undefined) return "undefined" - // A top-level string prints bare; nested strings are JSON-quoted (see formatConsoleValue). - if (typeof value === "string") return value - return this.formatConsoleValue(value, new Set(), 0) - } - - private formatConsoleValue(value: unknown, seen: Set, depth: number): string { - // Nested undefined renders as null, matching what JSON boundary output would show. - if (value === null || value === undefined) return "null" - if (typeof value === "string") return JSON.stringify(value) - // String(value) keeps NaN/Infinity/-Infinity readable; finite numbers match their JSON form. - if (typeof value === "number" || typeof value === "boolean") return String(value) - if (typeof value !== "object") return String(value) - if (value instanceof SandboxPromise) return "[Promise (await it to get its value)]" - if (value instanceof SandboxDate) return coerceToString(value) - if (value instanceof SandboxRegExp) return coerceToString(value) - if (depth > MAX_CONSOLE_DEPTH) return "..." - if (seen.has(value)) return "[Circular]" - if (value instanceof SandboxMap) { - seen.add(value) - try { - const entries = Array.from(value.map.entries(), ([key, item]): Array => [key, item]) - return `Map(${value.map.size}) ${this.formatConsoleValue(entries, seen, depth + 1)}` - } finally { - seen.delete(value) - } - } - if (value instanceof SandboxSet) { - seen.add(value) - try { - return `Set(${value.set.size}) ${this.formatConsoleValue(Array.from(value.set.values()), seen, depth + 1)}` - } finally { - seen.delete(value) - } - } - if (isRuntimeReference(value)) return "[CodeMode reference]" - seen.add(value) - try { - if (Array.isArray(value)) { - return `[${value.map((item) => this.formatConsoleValue(item, seen, depth + 1)).join(",")}]` - } - return `{${Object.entries(value) - .map(([key, item]) => `${JSON.stringify(key)}:${this.formatConsoleValue(item, seen, depth + 1)}`) - .join(",")}}` - } finally { - seen.delete(value) - } - } - - private formatConsoleTable(value: unknown, columnsArgument: unknown, node: AstNode): string { - if (value === undefined) return "undefined" - // Sandbox values are legitimate table data (cells render their friendly forms); only - // truly opaque references (functions, tools, promises) collapse to the marker. - if (containsOpaqueReference(value)) return "[CodeMode reference]" - const data = boundedData(value, "console.table argument") - const columns = this.consoleTableColumns(columnsArgument, node) - const rows = this.consoleTableRows(data, columns) - const keys = columns ?? Array.from(new Set(rows.flatMap((row) => Object.keys(row.values)))) - const header = ["(index)", ...keys].join("\t") - return [ - header, - ...rows.map((row) => [row.index, ...keys.map((key) => this.formatConsoleTableCell(row.values[key]))].join("\t")), - ].join("\n") - } - - private consoleTableColumns(value: unknown, node: AstNode): ReadonlyArray | undefined { - if (value === undefined) return undefined - if (containsRuntimeReference(value)) return undefined - const columns = copyOut(copyIn(value, "console.table columns"), true) - return Array.isArray(columns) ? columns.map((column) => String(column)) : undefined - } - - private consoleTableRows( - data: unknown, - columns: ReadonlyArray | undefined, - ): Array<{ readonly index: string; readonly values: Record }> { - if (Array.isArray(data)) { - return data.map((item, index) => ({ index: String(index), values: this.consoleTableValues(item, columns) })) - } - if (data !== null && typeof data === "object" && !isSandboxValue(data)) { - return Object.entries(data).map(([index, item]) => ({ index, values: this.consoleTableValues(item, columns) })) - } - return [{ index: "0", values: { Value: data } }] - } - - private consoleTableValues(value: unknown, columns: ReadonlyArray | undefined): Record { - if (value !== null && typeof value === "object" && !Array.isArray(value) && !isSandboxValue(value)) { - const source = value as Record - if (columns !== undefined) return Object.fromEntries(columns.map((column) => [column, source[column]])) - return Object.fromEntries(Object.entries(source)) - } - return { Value: value } - } - - private formatConsoleTableCell(value: unknown): string { - if (value === undefined) return "" - if (typeof value === "string") return value - return this.formatConsoleValue(value, new Set(), 0) - } - - private evaluateCallArguments(argNodes: Array): Effect.Effect, unknown, R> { - const self = this - return Effect.gen(function* () { - const args: Array = [] - for (const [index, arg] of argNodes.entries()) { - const argNode = asNode(arg, `arguments[${index}]`) - if (argNode.type === "SpreadElement") { - const spread = yield* self.evaluateExpression(getNode(argNode, "argument")) - const items = spreadItems(spread) - if (items === undefined) - throw new InterpreterRuntimeError( - "Spread arguments require an array, string, Map, or Set in CodeMode.", - argNode, - ) - args.push(...items) - } else { - args.push(yield* self.evaluateExpression(argNode)) - } - } - return args - }) - } - - // Promise.* over ordinary runtime values. Combinators accept ANY array (or spreadable - // collection) mixing promise values and plain data - built inline, beforehand, via spread, - // whatever - because tool calls already run eagerly on their own fibers; the combinators - // only observe settlements. Joining is therefore sequential (no extra fibers) without - // costing parallelism, and the concurrency cap stays where the work is: the fork semaphore. - private invokePromiseMethod( - ref: PromiseMethodReference, - args: Array, - node: AstNode, - ): Effect.Effect { - const self = this - if (ref.name === "resolve") { - // Promise.resolve of a promise is that promise (JS flattens); anything else is a - // promise already fulfilled with the value. - const value = args[0] - return Effect.succeed( - value instanceof SandboxPromise ? value : new SandboxPromise(undefined, Effect.succeed(value)), - ) - } - if (ref.name === "reject") { - return Effect.sync(() => new SandboxPromise(undefined, Effect.fail(new ProgramThrow(args[0])))) - } - - const items = Array.isArray(args[0]) ? args[0] : spreadItems(args[0]) - if (items === undefined) { - throw new InterpreterRuntimeError( - `Promise.${ref.name} expects an array of promises or plain values (e.g. Promise.${ref.name}(items.map((item) => tools.ns.tool(item)))).`, - node, - ) - } - - switch (ref.name) { - case "all": { - // Mark every promise element observed up-front (Promise.all handles all of its - // members' failures, as in JS), then join in index order; the first failure rejects - // the whole call while unrelated in-flight members keep running. - const settles = items.map((item) => - item instanceof SandboxPromise ? this.settlePromise(item, node) : Effect.succeed(item), - ) - return Effect.gen(function* () { - const values: Array = [] - for (const settle of settles) values.push(yield* settle) - return values - }) - } - case "allSettled": { - const observations = items.map((item) => - item instanceof SandboxPromise - ? Effect.map(this.observePromise(item), (exit) => ({ promise: item as SandboxPromise | undefined, exit })) - : Effect.succeed({ promise: undefined as SandboxPromise | undefined, exit: Exit.succeed(item as unknown) }), - ) - return Effect.gen(function* () { - const outcomes: Array = [] - for (const observation of observations) { - const { exit, promise } = yield* observation - if (Exit.isSuccess(exit)) { - outcomes.push( - Object.assign(Object.create(null) as SafeObject, { status: "fulfilled", value: exit.value }), - ) - continue - } - const raceInterrupted = promise?.interrupted === true && Cause.hasInterruptsOnly(exit.cause) - if (Cause.hasInterruptsOnly(exit.cause) && !raceInterrupted) { - // Execution teardown (timeout/host interruption), not a program-level rejection. - return yield* Effect.failCause(exit.cause) - } - const thrown = raceInterrupted - ? new InterpreterRuntimeError( - "This tool call was interrupted because another value settled a Promise.race first.", - node, - ) - : Cause.squash(exit.cause) - outcomes.push( - Object.assign(Object.create(null) as SafeObject, { - status: "rejected", - reason: caughtErrorValue(thrown), - }), - ) - } - return outcomes - }) - } - case "race": { - if (items.length === 0) { - throw new InterpreterRuntimeError( - "Promise.race([]) would never settle; provide at least one promise or value.", - node, - ) - } - const observations = items.map((item, index) => - item instanceof SandboxPromise - ? Effect.map(this.observePromise(item), (exit) => ({ index, exit })) - : Effect.succeed({ index, exit: Exit.succeed(item as unknown) }), - ) - return Effect.gen(function* () { - // First settlement (fulfilled OR rejected) wins; the observations never fail, so - // racing them yields exactly that. Losing in-flight calls are then interrupted. - const winner = yield* Effect.raceAll(observations) - for (const [index, item] of items.entries()) { - if (index === winner.index || !(item instanceof SandboxPromise) || item.fiber === undefined) continue - item.interrupted = true - yield* Fiber.interrupt(item.fiber) - } - const winningItem = items[winner.index] - return yield* self.unwrapPromiseExit( - winningItem instanceof SandboxPromise ? winningItem : undefined, - winner.exit, - node, - ) - }) - } - } - } - - private invokeFunction(fn: CodeModeFunction, args: Array): Effect.Effect { - const self = this - return Effect.suspend(() => { - const savedScopes = self.scopes - self.scopes = [...fn.capturedScopes, new Map()] - const run = Effect.gen(function* () { - // Seed every parameter name into the scope as a TDZ slot first, so a default that - // references another parameter resolves to that (uninitialized) param rather than - // silently falling through to an outer binding of the same name - matching JS. - const paramScope = self.currentScope() - for (const parameter of fn.parameters) { - for (const name of collectPatternNames(parameter)) { - paramScope.set(name, { mutable: true, value: undefined, initialized: false }) - } - } - for (const [index, parameter] of fn.parameters.entries()) { - if (parameter.type === "RestElement") { - yield* self.declarePattern(getNode(parameter, "argument"), args.slice(index), true, parameter) - break - } - yield* self.declarePattern(parameter, args[index], true, parameter) - } - - if (fn.body.type === "BlockStatement") { - const result = yield* self.evaluateStatement(fn.body) - return result.kind === "return" || result.kind === "value" ? result.value : undefined - } - - return yield* self.evaluateExpression(fn.body) - }) - return run.pipe( - Effect.ensuring( - Effect.sync(() => { - self.scopes = savedScopes - }), - ), - ) - }) - } - - private invokeIntrinsic( - ref: IntrinsicReference, - args: Array, - node: AstNode, - ): Effect.Effect { - if (typeof ref.receiver === "string") { - return Effect.succeed(invokeStringMethod(ref.receiver, ref.name, args, node)) - } - if (typeof ref.receiver === "number") { - return Effect.succeed(invokeNumberMethod(ref.receiver, ref.name, args, node)) - } - if (Array.isArray(ref.receiver)) { - return this.invokeArrayMethod(ref.receiver, ref.name, args, node) - } - if (ref.receiver instanceof SandboxDate) { - return Effect.succeed(invokeDateMethod(ref.receiver, ref.name, node)) - } - if (ref.receiver instanceof SandboxRegExp) { - return Effect.succeed(invokeRegExpMethod(ref.receiver, ref.name, args, node)) - } - if (ref.receiver instanceof SandboxMap) { - return this.invokeMapMethod(ref.receiver, ref.name, args, node) - } - if (ref.receiver instanceof SandboxSet) { - return this.invokeSetMethod(ref.receiver, ref.name, args, node) - } - throw new InterpreterRuntimeError(`Method '${ref.name}' is not available in CodeMode.`, node) - } - - // Runs a Map/Set callback (forEach) accepting a user function or a builtin coercion, - // mirroring the array-method callback contract. - private applyCollectionCallback( - callback: unknown, - name: string, - node: AstNode, - ): (args: Array) => Effect.Effect { - if (!(callback instanceof CodeModeFunction) && !(callback instanceof CoercionFunction)) { - throw new InterpreterRuntimeError(`${name} expects a function callback.`, node) - } - return (callbackArgs) => - callback instanceof CoercionFunction - ? Effect.succeed(invokeCoercion(callback, callbackArgs, node)) - : this.invokeFunction(callback, callbackArgs) - } - - private invokeMapMethod( - target: SandboxMap, - name: string, - args: Array, - node: AstNode, - ): Effect.Effect { - switch (name) { - case "get": - return Effect.succeed(target.map.get(args[0])) - case "has": - return Effect.succeed(target.map.has(args[0])) - case "set": - return Effect.sync(() => { - target.map.set(args[0], args[1]) - return target - }) - case "delete": - return Effect.sync(() => target.map.delete(args[0])) - case "clear": - return Effect.sync(() => { - target.map.clear() - return undefined - }) - case "keys": - return Effect.sync(() => Array.from(target.map.keys())) - case "values": - return Effect.sync(() => Array.from(target.map.values())) - case "entries": - return Effect.sync(() => Array.from(target.map.entries(), ([key, item]): Array => [key, item])) - case "forEach": { - const apply = this.applyCollectionCallback(args[0], "Map.forEach", node) - return Effect.gen(function* () { - // Snapshot iteration, matching the array-method callback contract. - for (const [key, item] of Array.from(target.map.entries())) yield* apply([item, key, target]) - return undefined - }) - } - default: - throw new InterpreterRuntimeError(`Map method '${name}' is not available in CodeMode.`, node) - } - } - - private invokeSetMethod( - target: SandboxSet, - name: string, - args: Array, - node: AstNode, - ): Effect.Effect { - switch (name) { - case "has": - return Effect.succeed(target.set.has(args[0])) - case "add": - return Effect.sync(() => { - target.set.add(args[0]) - return target - }) - case "delete": - return Effect.sync(() => target.set.delete(args[0])) - case "clear": - return Effect.sync(() => { - target.set.clear() - return undefined - }) - case "keys": - case "values": - return Effect.sync(() => Array.from(target.set.values())) - case "entries": - return Effect.sync(() => Array.from(target.set.values(), (item): Array => [item, item])) - case "forEach": { - const apply = this.applyCollectionCallback(args[0], "Set.forEach", node) - return Effect.gen(function* () { - for (const item of Array.from(target.set.values())) yield* apply([item, item, target]) - return undefined - }) - } - default: - throw new InterpreterRuntimeError(`Set method '${name}' is not available in CodeMode.`, node) - } - } - - private invokeArrayMethod( - target: Array, - name: string, - args: Array, - node: AstNode, - ): Effect.Effect { - const optNumber = (value: unknown, label: string): number | undefined => { - if (value === undefined) return undefined - if (typeof value !== "number") - throw new InterpreterRuntimeError(`Array.${name} expects ${label} to be a number.`, node) - return value - } - switch (name) { - case "join": { - if (args.length > 1 || (args.length === 1 && typeof args[0] !== "string")) { - throw new InterpreterRuntimeError("Array.join expects zero arguments or one string separator.", node) - } - const input = boundedData(target, "Array.join input") as Array - return Effect.succeed( - input.map((item) => coerceToString(item ?? "")).join(args.length === 0 ? "," : (args[0] as string)), - ) - } - case "includes": - if (args.length === 0 || args.length > 2) - throw new InterpreterRuntimeError("Array.includes expects a value and optional start index.", node) - return Effect.succeed(target.includes(args[0], optNumber(args[1], "start index"))) - case "indexOf": - return Effect.succeed(target.indexOf(args[0], optNumber(args[1], "start index"))) - case "lastIndexOf": - return Effect.succeed( - args[1] === undefined - ? target.lastIndexOf(args[0]) - : target.lastIndexOf(args[0], optNumber(args[1], "start index")), - ) - case "at": - return Effect.succeed(target.at(optNumber(args[0], "index") ?? 0)) - case "slice": - return Effect.succeed(target.slice(optNumber(args[0], "start"), optNumber(args[1], "end"))) - case "concat": - return Effect.succeed(target.concat(...args)) - case "flat": - return Effect.succeed(target.flat(optNumber(args[0], "depth") ?? 1)) - case "reverse": - return Effect.succeed([...target].reverse()) - case "sort": - case "toSorted": - return this.sortArray(target, args[0], node) - case "toReversed": - return Effect.succeed([...target].reverse()) - case "with": { - const index = optNumber(args[0], "index") ?? 0 - const resolved = index < 0 ? target.length + index : index - if (resolved < 0 || resolved >= target.length) { - throw new InterpreterRuntimeError("Array.with index is out of range.", node) - } - const copied = [...target] - copied[resolved] = args[1] - return Effect.succeed(copied) - } - case "push": { - // Validate before mutating (so no rollback is needed): inserting a container into - // itself would create a cycle no later walk could survive. - for (const item of args) this.rejectCircularInsertion(target, item, "Array.push result", node) - target.push(...args) - return Effect.succeed(target.length) - } - case "unshift": { - for (const item of args) this.rejectCircularInsertion(target, item, "Array.unshift result", node) - target.unshift(...args) - return Effect.succeed(target.length) - } - case "pop": - return Effect.succeed(target.pop()) - case "shift": - return Effect.succeed(target.shift()) - case "splice": { - // Mutates in place and returns the removed elements, exactly like JS: one argument - // removes to the end, an undefined delete count removes nothing. - if (args.length === 0) return Effect.succeed(target.splice(0, 0)) - const start = optNumber(args[0], "start") ?? 0 - if (args.length === 1) return Effect.succeed(target.splice(start)) - const deleteCount = optNumber(args[1], "delete count") ?? 0 - const inserted = args.slice(2) - for (const item of inserted) this.rejectCircularInsertion(target, item, "Array.splice result", node) - return Effect.succeed(target.splice(start, deleteCount, ...inserted)) - } - case "fill": { - this.rejectCircularInsertion(target, args[0], "Array.fill result", node) - return Effect.succeed(target.fill(args[0], optNumber(args[1], "start"), optNumber(args[2], "end"))) - } - case "copyWithin": - return Effect.succeed( - target.copyWithin( - optNumber(args[0], "target index") ?? 0, - optNumber(args[1], "start") ?? 0, - optNumber(args[2], "end"), - ), - ) - // keys/values/entries return arrays (not iterators), matching the Map/Set convention; - // they work with for...of and spread either way. - case "keys": - return Effect.succeed(Array.from(target.keys())) - case "values": - return Effect.succeed([...target]) - case "entries": - return Effect.succeed(Array.from(target.entries(), ([index, item]): Array => [index, item])) - } - - const callback = args[0] - if (!(callback instanceof CodeModeFunction) && !(callback instanceof CoercionFunction)) { - throw new InterpreterRuntimeError(`Array.${name} expects a function callback.`, node) - } - const self = this - // Accept a user arrow function or a builtin coercion callable (Boolean/String/Number), so the - // idioms `filter(Boolean)` / `map(String)` / `map(Number)` work as in JS. Coercions are - // synchronous; only CodeModeFunctions can await tool calls. - const apply = (callbackArgs: Array): Effect.Effect => - callback instanceof CoercionFunction - ? Effect.succeed(invokeCoercion(callback, callbackArgs, node)) - : self.invokeFunction(callback, callbackArgs) - return Effect.gen(function* () { - // Iterate a snapshot taken at call time so a callback that mutates the array can't - // self-extend the loop - matching JS, where elements appended during iteration are not visited. - const items = target.slice() - switch (name) { - case "map": { - const values: Array = [] - for (const [index, item] of items.entries()) values.push(yield* apply([item, index, items])) - return values - } - case "flatMap": { - const values: Array = [] - for (const [index, item] of items.entries()) { - const mapped = yield* apply([item, index, items]) - if (Array.isArray(mapped)) values.push(...mapped) - else values.push(mapped) - } - return values - } - case "filter": { - const values: Array = [] - for (const [index, item] of items.entries()) { - if (yield* apply([item, index, items])) values.push(item) - } - return values - } - case "find": - for (const [index, item] of items.entries()) { - if (yield* apply([item, index, items])) return item - } - return undefined - case "findIndex": - for (const [index, item] of items.entries()) { - if (yield* apply([item, index, items])) return index - } - return -1 - case "some": - for (const [index, item] of items.entries()) { - if (yield* apply([item, index, items])) return true - } - return false - case "every": - for (const [index, item] of items.entries()) { - if (!(yield* apply([item, index, items]))) return false - } - return true - case "forEach": - for (const [index, item] of items.entries()) yield* apply([item, index, items]) - return undefined - case "reduce": { - let accumulator: unknown - let start: number - if (args.length >= 2) { - accumulator = args[1] - start = 0 - } else { - if (items.length === 0) - throw new InterpreterRuntimeError("Array.reduce of an empty array with no initial value.", node) - accumulator = items[0] - start = 1 - } - for (let index = start; index < items.length; index += 1) { - accumulator = yield* apply([accumulator, items[index], index, items]) - } - return accumulator - } - case "reduceRight": { - let accumulator: unknown - let start: number - if (args.length >= 2) { - accumulator = args[1] - start = items.length - 1 - } else { - if (items.length === 0) - throw new InterpreterRuntimeError("Array.reduceRight of an empty array with no initial value.", node) - accumulator = items[items.length - 1] - start = items.length - 2 - } - for (let index = start; index >= 0; index -= 1) { - accumulator = yield* apply([accumulator, items[index], index, items]) - } - return accumulator - } - case "findLast": - for (let index = items.length - 1; index >= 0; index -= 1) { - if (yield* apply([items[index], index, items])) return items[index] - } - return undefined - case "findLastIndex": - for (let index = items.length - 1; index >= 0; index -= 1) { - if (yield* apply([items[index], index, items])) return index - } - return -1 - } - throw new InterpreterRuntimeError(`Array method '${name}' is not available in CodeMode.`, node) - }) - } - - private sortArray( - target: Array, - comparator: unknown, - node: AstNode, - ): Effect.Effect, unknown, R> { - if (comparator !== undefined && !(comparator instanceof CodeModeFunction)) { - throw new InterpreterRuntimeError("Array.sort expects an arrow function comparator.", node) - } - if (!(comparator instanceof CodeModeFunction)) { - return Effect.sync(() => - [...target].sort((a, b) => { - const left = coerceToString(a) - const right = coerceToString(b) - return left < right ? -1 : left > right ? 1 : 0 - }), - ) - } - const self = this - const mergeSort = (items: Array): Effect.Effect, unknown, R> => { - if (items.length <= 1) return Effect.succeed(items) - const midpoint = Math.floor(items.length / 2) - return Effect.gen(function* () { - const left = yield* mergeSort(items.slice(0, midpoint)) - const right = yield* mergeSort(items.slice(midpoint)) - const merged: Array = [] - let leftIndex = 0 - let rightIndex = 0 - while (leftIndex < left.length && rightIndex < right.length) { - // Coerce the comparator's result like JS ToNumber (data objects -> NaN, never a host - // crash) and treat NaN as 0 - the spec's "no consistent order" -> keep the left element. - const order = coerceToNumber(yield* self.invokeFunction(comparator, [left[leftIndex], right[rightIndex]])) - if (Number.isNaN(order) || order <= 0) merged.push(left[leftIndex++]) - else merged.push(right[rightIndex++]) - } - return [...merged, ...left.slice(leftIndex), ...right.slice(rightIndex)] - }) - } - // Per spec, undefined elements sort to the end and the comparator is never called on them. - const defined = target.filter((item) => item !== undefined) - const undefinedCount = target.length - defined.length - return Effect.map(mergeSort(defined), (items) => [...items, ...Array(undefinedCount).fill(undefined)]) - } - - private evaluateObjectExpression(node: AstNode): Effect.Effect, unknown, R> { - const objectValue: Record = Object.create(null) as Record - const properties = getArray(node, "properties") - const self = this - return Effect.gen(function* () { - for (const propertyValue of properties) { - const property = asNode(propertyValue, "properties") - - if (property.type === "SpreadElement") { - const spread = yield* self.evaluateExpression(getNode(property, "argument")) - // JS treats `{ ...null }` / `{ ...undefined }` as a no-op, so the common - // `{ ...maybeOpts, override }` merge works when the operand is absent. Sandbox values - // (Date/RegExp/Map/Set) have no own enumerable properties in JS, so they are no-ops too. - if (spread === null || spread === undefined || isSandboxValue(spread)) continue - if (typeof spread !== "object" || Array.isArray(spread) || isRuntimeReference(spread)) { - throw new InterpreterRuntimeError( - "Object spread requires a data object in CodeMode.", - property, - "InvalidDataValue", - ) - } - for (const [key, value] of Object.entries(spread)) { - if (isBlockedMember(key)) - throw new InterpreterRuntimeError(`Property '${key}' is not available in CodeMode.`, property) - objectValue[key] = value - } - continue - } - - if (property.type !== "Property") { - throw new InterpreterRuntimeError("Only standard object properties are supported.", property) - } - - if (getString(property, "kind") !== "init") { - throw new InterpreterRuntimeError("Only init object properties are supported.", property) - } - - const keyNode = getNode(property, "key") - const valueNode = getNode(property, "value") - const computed = getBoolean(property, "computed") - - let key: PropertyKey - - if (computed) { - key = self.toPropertyKey(yield* self.evaluateExpression(keyNode), keyNode) - } else if (keyNode.type === "Identifier") { - key = getString(keyNode, "name") - } else if (keyNode.type === "Literal") { - key = self.toPropertyKey(keyNode.value, keyNode) - } else { - throw new InterpreterRuntimeError("Unsupported object property key shape.", keyNode) - } - - if (isBlockedMember(String(key))) { - throw new InterpreterRuntimeError(`Property '${String(key)}' is not available in CodeMode.`, keyNode) - } - objectValue[String(key)] = yield* self.evaluateExpression(valueNode) - } - - return objectValue - }) - } - - private evaluateArrayExpression(node: AstNode): Effect.Effect, unknown, R> { - const elements = getArray(node, "elements") - const values: Array = [] - - const self = this - return Effect.gen(function* () { - for (const elementValue of elements) { - if (elementValue === null) { - values.push(undefined) - continue - } - const element = asNode(elementValue, "elements") - if (element.type === "SpreadElement") { - const spread = yield* self.evaluateExpression(getNode(element, "argument")) - const items = spreadItems(spread) - if (items === undefined) - throw new InterpreterRuntimeError( - "Array spread requires an array, string, Map, or Set in CodeMode.", - element, - ) - values.push(...items) - } else { - values.push(yield* self.evaluateExpression(element)) - } - } - return values - }) - } - - private evaluateTemplateLiteral(node: AstNode): Effect.Effect { - const quasis = getArray(node, "quasis") - const expressions = getArray(node, "expressions") - - let output = "" - - const self = this - return Effect.gen(function* () { - for (let index = 0; index < quasis.length; index += 1) { - const quasi = asNode(quasis[index], "quasis") - const rawValue = quasi.value - - if (!isRecord(rawValue) || typeof rawValue.cooked !== "string") { - throw new InterpreterRuntimeError("Invalid template literal quasi.", quasi) - } - - output += rawValue.cooked - - if (index < expressions.length) { - const raw = yield* self.evaluateExpression(asNode(expressions[index], "expressions")) - // The preserving checkpoint keeps sandbox values intact, so coerceToString renders - // them directly (ISO date, /regex/ literal form) instead of a JSON-serialized husk. - output += coerceToString(boundedData(raw, "Template interpolation")) - } - } - - return output - }) - } - - private evaluateConditionalExpression(node: AstNode): Effect.Effect { - return Effect.flatMap(this.evaluateExpression(getNode(node, "test")), (test) => - this.evaluateExpression(getNode(node, test ? "consequent" : "alternate")), - ) - } - - private applyCompoundAssignment(operator: string, current: unknown, incoming: unknown, node: AstNode): unknown { - // `x op= y` is `x = x op y`: dispatch through the shared binary operator implementation - // so compound assignment inherits the same coercion semantics (Dates, data objects, ...). - // Only the arithmetic/bitwise operators are compoundable; logical assignments (&&=/||=/??=) - // short-circuit and are handled by evaluateLogicalAssignment before reaching here. - if (!compoundOperators.has(operator)) { - throw new InterpreterRuntimeError(`Unsupported assignment operator '${operator}'.`, node) - } - return this.applyBinaryOperator(operator.slice(0, -1), current, incoming, node) - } - - private getMemberReference( - node: AstNode, - ): Effect.Effect< - | MemberReference - | ToolReference - | PromiseMethodReference - | IntrinsicReference - | GlobalMethodReference - | ComputedValue - | typeof OptionalShortCircuit - | undefined, - unknown, - R - > { - const objectNode = getNode(node, "object") - const propertyNode = getNode(node, "property") - const computed = getBoolean(node, "computed") - const optional = node.optional === true - const self = this - return Effect.gen(function* () { - const objectValue = yield* self.evaluateExpression(objectNode) - if (objectValue === OptionalShortCircuit) return OptionalShortCircuit - if ((objectValue === null || objectValue === undefined) && optional) return OptionalShortCircuit - - const key = computed - ? self.toPropertyKey(yield* self.evaluateExpression(propertyNode), propertyNode) - : propertyNode.type === "Identifier" - ? getString(propertyNode, "name") - : self.toPropertyKey(yield* self.evaluateExpression(propertyNode), propertyNode) - - if (objectValue instanceof ToolReference) { - if (typeof key !== "string" || isBlockedMember(key)) { - throw new InterpreterRuntimeError("Tool paths must use safe string property names.", propertyNode) - } - return new ToolReference([...objectValue.path, key]) - } - - if (objectValue instanceof PromiseNamespace) { - if (typeof key === "string" && promiseStatics.has(key as PromiseMethodName)) { - return new PromiseMethodReference(key as PromiseMethodName) - } - throw new InterpreterRuntimeError( - `Promise.${String(key)} is not available in CodeMode. Available: Promise.all, Promise.allSettled, Promise.race, Promise.resolve, and Promise.reject; consume promises with await.`, - propertyNode, - ) - } - - if (objectValue instanceof GlobalNamespace) { - if (typeof key !== "string" || isBlockedMember(key)) { - throw new InterpreterRuntimeError( - `${objectValue.name}.${String(key)} is not available in CodeMode.`, - propertyNode, - ) - } - if (objectValue.name === "Math" && mathConstants.has(key)) { - return new ComputedValue((Math as unknown as Record)[key]) - } - return new GlobalMethodReference(objectValue.name, key) - } - - if (typeof objectValue === "string") { - if (key === "length") return new ComputedValue(objectValue.length) - if (typeof key === "number") return new ComputedValue(objectValue[key]) - if (typeof key === "string" && /^\d+$/.test(key)) return new ComputedValue(objectValue[Number(key)]) - if (typeof key === "string" && stringMethods.has(key)) return new IntrinsicReference(objectValue, key) - // Unknown property on a string reads as `undefined`, matching JS (`"x".foo === undefined`), - // instead of throwing - so defensive access like `result?.login ?? result` on a JSON-string - // tool result doesn't crash. (Optional chaining only guards null/undefined receivers, so a - // real string still reaches here.) Only the method allowlist above yields callables. - return new ComputedValue(undefined) - } - - if (typeof objectValue === "number") { - if (typeof key === "string" && numberMethods.has(key)) return new IntrinsicReference(objectValue, key) - // Unknown property on a number reads as `undefined`, matching JS, rather than throwing. - return new ComputedValue(undefined) - } - - // Number / String expose a small allowlist of statics; everything else stays opaque. - if (objectValue instanceof CoercionFunction && typeof key === "string" && !isBlockedMember(key)) { - if (objectValue.name === "Number" && numberConstants.has(key)) { - return new ComputedValue((Number as unknown as Record)[key]) - } - if (objectValue.name === "Number" && numberStatics.has(key)) return new GlobalMethodReference("Number", key) - if (objectValue.name === "String" && stringStatics.has(key)) return new GlobalMethodReference("String", key) - } - - // Sandbox value types expose their method/property allowlists; any other key reads as - // `undefined`, consistent with unknown-property reads on strings/numbers/arrays. - if (objectValue instanceof SandboxDate) { - if (typeof key === "string" && dateMethods.has(key)) return new IntrinsicReference(objectValue, key) - return new ComputedValue(undefined) - } - if (objectValue instanceof SandboxRegExp) { - if (typeof key === "string" && regexpProperties.has(key)) { - return new ComputedValue((objectValue.regex as unknown as Record)[key]) - } - if (typeof key === "string" && regexpMethods.has(key)) return new IntrinsicReference(objectValue, key) - return new ComputedValue(undefined) - } - if (objectValue instanceof SandboxMap) { - if (key === "size") return new ComputedValue(objectValue.map.size) - if (typeof key === "string" && mapMethods.has(key)) return new IntrinsicReference(objectValue, key) - return new ComputedValue(undefined) - } - if (objectValue instanceof SandboxSet) { - if (key === "size") return new ComputedValue(objectValue.set.size) - if (typeof key === "string" && setMethods.has(key)) return new IntrinsicReference(objectValue, key) - return new ComputedValue(undefined) - } - - // Any property access on a promise is a confused program (`p.then(...)`, `p.value`); - // reading `undefined` here would hide the missing await, so both paths get an explicit, - // await-hinting error instead of the forgiving unknown-property fallthrough. - if (objectValue instanceof SandboxPromise) { - if (key === "then" || key === "catch" || key === "finally") { - throw new InterpreterRuntimeError( - `Promise.prototype.${String(key)} is not supported in CodeMode; use await instead (with try/catch to handle failures) - e.g. \`const result = await tools.ns.tool(...)\`.`, - propertyNode, - "UnsupportedSyntax", - [supportedSyntaxMessage], - ) - } - throw new InterpreterRuntimeError( - "This value is an un-awaited Promise and has no readable properties; await it first - e.g. `const result = await tools.ns.tool(...)`.", - objectNode, - "InvalidDataValue", - ) - } - - if (isRuntimeReference(objectValue)) { - throw new InterpreterRuntimeError( - "CodeMode runtime references are opaque and do not expose properties.", - objectNode, - "InvalidDataValue", - ) - } - - if (typeof objectValue !== "object" || objectValue === null) { - throw new InterpreterRuntimeError("Cannot access a property on a non-object value.", objectNode) - } - - if (typeof key === "string" && isBlockedMember(key)) { - throw new InterpreterRuntimeError(`Property '${key}' is not available in CodeMode.`, propertyNode) - } - - if (Array.isArray(objectValue)) { - if ( - key !== "length" && - !(typeof key === "string" && arrayMethods.has(key)) && - typeof key !== "number" && - !/^\d+$/.test(key) - ) { - // Own non-index properties read through (match results carry index/groups); like JS, - // they are readable in place and dropped by JSON at data boundaries. - if (typeof key === "string" && Object.hasOwn(objectValue, key)) { - return new ComputedValue((objectValue as Record & Array)[key]) - } - // Unknown property on an array reads as `undefined`, matching JS (`[1,2].foo === undefined`), - // instead of throwing - so defensive access under optional chaining behaves as expected. - return new ComputedValue(undefined) - } - return { target: objectValue, key } - } - - return { target: objectValue as SafeObject, key } - }) - } - - private readMember(node: AstNode): Effect.Effect { - return Effect.map(this.getMemberReference(node), (reference) => { - if (reference === OptionalShortCircuit) return OptionalShortCircuit - if (reference instanceof ComputedValue) return reference.value - if ( - reference === undefined || - reference instanceof ToolReference || - reference instanceof PromiseMethodReference || - reference instanceof IntrinsicReference || - reference instanceof GlobalMethodReference - ) - return reference - if (Array.isArray(reference.target)) { - if (typeof reference.key === "string" && arrayMethods.has(reference.key)) { - return new IntrinsicReference(reference.target, reference.key) - } - return reference.key === "length" ? reference.target.length : reference.target[Number(reference.key)] - } - return reference.target[String(reference.key)] - }) - } - - private writeMember(node: AstNode, value: unknown): Effect.Effect { - return this.modifyMember(node, () => Effect.succeed({ write: true, next: value, result: value })) - } - - // Resolves the member reference EXACTLY ONCE (so a side-effecting object/key expression - // runs once), then lets `compute` decide whether to write - enabling compound assignment, - // updates, plain writes, and short-circuiting logical assignment to share one safe path. - private modifyMember( - node: AstNode, - compute: (current: unknown) => Effect.Effect<{ write: boolean; next: unknown; result: unknown }, unknown, R>, - ): Effect.Effect { - const self = this - return Effect.gen(function* () { - const reference = yield* self.getMemberReference(node) - if ( - reference === OptionalShortCircuit || - reference instanceof ComputedValue || - reference === undefined || - reference instanceof ToolReference || - reference instanceof PromiseMethodReference || - reference instanceof IntrinsicReference || - reference instanceof GlobalMethodReference - ) { - throw new InterpreterRuntimeError("Only data fields may be assigned in CodeMode.", node) - } - if (Array.isArray(reference.target)) { - if (reference.key === "length") - throw new InterpreterRuntimeError("Array length cannot be assigned in CodeMode.", node) - if (typeof reference.key === "string" && arrayMethods.has(reference.key)) { - throw new InterpreterRuntimeError("Array methods cannot be assigned in CodeMode.", node) - } - } - const key = Array.isArray(reference.target) ? Number(reference.key) : String(reference.key) - const current = (reference.target as Record)[key] - const { write, next, result } = yield* compute(current) - if (write) self.assignToReference(reference, key, next, node) - return result - }) - } - - // Rejects inserting a value that (transitively) contains the container it is being inserted - // into - the mutation that would create a circular structure no later walk could survive. - private rejectCircularInsertion( - container: object, - value: unknown, - label: string, - node: AstNode, - seen = new Set(), - ): void { - if (value === container) - throw new InterpreterRuntimeError(`${label} contains a circular value.`, node, "InvalidDataValue") - if (value === null || typeof value !== "object" || isRuntimeReference(value) || seen.has(value)) return - seen.add(value) - const items = Array.isArray(value) ? value : Object.values(value) - for (const item of items) this.rejectCircularInsertion(container, item, label, node, seen) - seen.delete(value) - } - - private assignToReference(reference: MemberReference, key: number | string, next: unknown, node: AstNode): void { - if (Array.isArray(reference.target)) { - const target = reference.target - const index = key as number - if (!Number.isInteger(index) || index < 0) { - throw new InterpreterRuntimeError( - "Array assignment index must be a non-negative integer.", - node, - "InvalidDataValue", - ) - } - this.rejectCircularInsertion(target, next, "Array assignment result", node) - target[index] = next - return - } - const target = reference.target as SafeObject - const objectKey = key as string - this.rejectCircularInsertion(target, next, "Object assignment result", node) - target[objectKey] = next - } - - private toPropertyKey(value: unknown, node: AstNode): string | number { - if (typeof value === "string" || typeof value === "number") { - return value - } - - throw new InterpreterRuntimeError("Property key must be a string or number.", node) - } - - private declare(name: string, value: unknown, mutable: boolean, node: AstNode): void { - const scope = this.currentScope() - - // A pre-seeded parameter slot (initialized === false) is being bound for the first time; - // anything else already present is a genuine duplicate declaration. - const existing = scope.get(name) - if (existing && existing.initialized !== false) { - throw new InterpreterRuntimeError(`Identifier '${name}' has already been declared.`, node) - } - - scope.set(name, { mutable, value, initialized: true }) - } - - private getIdentifierValue(name: string, node: AstNode): unknown { - const binding = this.resolveBinding(name) - - if (!binding) { - throw new InterpreterRuntimeError(`Unknown identifier '${name}'.`, node).as("ReferenceError") - } - - // A parameter default that forward-references a later (not-yet-bound) parameter - JS TDZ. - if (binding.initialized === false) { - throw new InterpreterRuntimeError(`Cannot access '${name}' before initialization.`, node).as("ReferenceError") - } - - return binding.value - } - - private setIdentifierValue(name: string, value: unknown, node: AstNode): unknown { - const binding = this.resolveBinding(name) - - if (!binding) { - throw new InterpreterRuntimeError(`Unknown identifier '${name}'.`, node).as("ReferenceError") - } - - if (!binding.mutable) { - throw new InterpreterRuntimeError(`Cannot assign to constant '${name}'.`, node).as("TypeError") - } - - binding.value = value - return value - } - - private resolveBinding(name: string): Binding | undefined { - for (let index = this.scopes.length - 1; index >= 0; index -= 1) { - const scope = this.scopes[index] - const binding = scope?.get(name) - - if (binding) { - return binding - } - } - - return undefined - } - - private currentScope(): Map { - const scope = this.scopes[this.scopes.length - 1] - - if (!scope) { - throw new InterpreterRuntimeError("Interpreter scope stack is empty.") - } - - return scope - } - - private pushScope(): void { - this.scopes.push(new Map()) - } - - private popScope(): void { - this.scopes.pop() - } -} - -/** - * Executes one Effect-native CodeMode program without constructing a reusable runtime. - * - * @example - * ```ts - * const result = yield* CodeMode.execute({ - * tools: { lookup }, - * code: `return await tools.lookup({ id: "order_42" })`, - * }) - * ``` - */ -const executeWithLimits = >( - options: ExecuteOptions, - limits: ResolvedExecutionLimits, - searchIndex: ToolRuntime.DiscoveryPlan["searchIndex"], -): Effect.Effect> => { - const hooks = { - ...(options.onToolCallStart === undefined ? {} : { onToolCallStart: options.onToolCallStart }), - ...(options.onToolCallEnd === undefined ? {} : { onToolCallEnd: options.onToolCallEnd }), - } - const tools = ToolRuntime.make( - (options.tools ?? {}) as HostTools>, - limits.maxToolCalls, - searchIndex, - hooks, - ) - const logs: Array = [] - const logged = () => (logs.length > 0 ? { logs: [...logs] } : {}) - - if (options.code.trim().length === 0) { - return Effect.succeed({ - ok: false, - error: { kind: "ParseError", message: "Code cannot be empty." }, - toolCalls: tools.calls, - }) - } - - const operation = Effect.gen(function* () { - const program = parseProgram(options.code) - const interpreter = new Interpreter>(tools.invoke, tools.keys, logs) - const value = yield* interpreter.run(program) - const result = copyOut(copyIn(value, "Execution result"), true) as DataValue - return { - ok: true, - value: result, - ...logged(), - toolCalls: tools.calls, - } satisfies Result - }).pipe((program) => { - const timeoutMs = limits.timeoutMs - if (timeoutMs === undefined) return program - return program.pipe( - Effect.timeoutOrElse({ - duration: timeoutMs, - orElse: () => - Effect.succeed({ - ok: false, - error: { kind: "TimeoutExceeded", message: `Execution timed out after ${timeoutMs}ms.` }, - ...logged(), - toolCalls: tools.calls, - } satisfies Result), - }), - ) - }) - - return operation.pipe( - Effect.catchCause((cause) => - Cause.hasInterruptsOnly(cause) - ? Effect.interrupt - : Effect.succeed({ - ok: false, - error: normalizeError(Cause.squash(cause)), - ...logged(), - toolCalls: tools.calls, - } satisfies Result), - ), - Effect.map((result) => (limits.maxOutputBytes === undefined ? result : boundOutput(result, limits.maxOutputBytes))), - ) -} - -const utf8ByteLength = (value: string): number => new TextEncoder().encode(value).byteLength - -// Truncates to a UTF-8 byte budget without splitting a code point (a split multi-byte -// sequence decodes to a replacement character, which is dropped). -const utf8Truncate = (value: string, maxBytes: number): string => { - const bytes = new TextEncoder().encode(value) - if (bytes.byteLength <= maxBytes) return value - const text = new TextDecoder("utf-8").decode(bytes.slice(0, Math.max(0, maxBytes))) - return text.endsWith("\uFFFD") ? text.slice(0, -1) : text -} - -/** - * Bounds the model-facing output (serialized result value plus logs) to `maxOutputBytes`. - * Oversized values are replaced by their truncated serialized text with an explanatory marker, - * and logs are kept from the start until the remaining budget is exhausted. Truncation never - * fails the execution; `truncated: true` marks affected results. Only runs when the host set - * `maxOutputBytes` - with the limit absent, output passes through unbounded. - */ -const boundOutput = (result: Result, maxOutputBytes: number): Result => { - let truncated = false - - let value: DataValue = null - let valueBytes = 0 - if (result.ok) { - const serialized = JSON.stringify(result.value) ?? "null" - const bytes = utf8ByteLength(serialized) - if (bytes > maxOutputBytes) { - truncated = true - value = `${utf8Truncate(serialized, maxOutputBytes)} [result truncated: ${bytes} bytes exceeds the ${maxOutputBytes}-byte output limit; return a smaller value]` - valueBytes = maxOutputBytes - } else { - value = result.value - valueBytes = bytes - } - } - - const logs = result.logs ?? [] - const kept: Array = [] - const logBudget = Math.max(0, maxOutputBytes - valueBytes) - let logBytes = 0 - for (const line of logs) { - const lineBytes = utf8ByteLength(line) + 1 - if (logBytes + lineBytes > logBudget) break - logBytes += lineBytes - kept.push(line) - } - if (kept.length < logs.length) { - truncated = true - kept.push(`[logs truncated: showing ${kept.length} of ${logs.length} lines]`) - } - - if (!truncated) return result - const logsPart = kept.length > 0 ? { logs: kept } : {} - return result.ok - ? { ok: true, value, ...logsPart, truncated: true, toolCalls: result.toolCalls } - : { ok: false, error: result.error, ...logsPart, truncated: true, toolCalls: result.toolCalls } -} - +/** Executes one Effect-native CodeMode program without constructing a reusable runtime. */ export const execute = >( options: ExecuteOptions, ): Effect.Effect> => { @@ -4044,17 +142,7 @@ export const execute = >( return executeWithLimits(options, resolveExecutionLimits(options.limits), ToolRuntime.searchIndex(tools)) } -/** - * Creates an Effect-native runtime over explicit, schema-described tools. - * - * Use `execute` for host-driven execution. Tool requirements remain in the returned Effect environment. - * - * @example - * ```ts - * const runtime = CodeMode.make({ tools: { orders: { lookup } } }) - * const result = runtime.execute("return await tools.orders.lookup({ id: 'order_42' })") - * ``` - */ +/** Creates an Effect-native runtime over explicit, schema-described tools. */ export const make = = {}>( options: Options = {} as Options, ): Runtime> => { @@ -4062,13 +150,10 @@ export const make = = {}>( ToolRuntime.assertValidTools(tools) const limits = resolveExecutionLimits(options.limits) const prepared = ToolRuntime.prepare(tools, options.discovery?.catalogBudget) - const executeProgram = (code: string) => executeWithLimits({ ...options, code }, limits, prepared.searchIndex) - const catalog = prepared.catalog - const instructions = prepared.instructions return { - catalog: () => catalog, - instructions: () => instructions, - execute: executeProgram, + catalog: () => prepared.catalog, + instructions: () => prepared.instructions, + execute: (code) => executeWithLimits({ ...options, code }, limits, prepared.searchIndex), } } diff --git a/packages/codemode/src/index.ts b/packages/codemode/src/index.ts index 3fdfcd45e7fd..92019825004b 100644 --- a/packages/codemode/src/index.ts +++ b/packages/codemode/src/index.ts @@ -1,4 +1,4 @@ export * as CodeMode from "./codemode.js" -export * as Tool from "./tool-api.js" +export * as Tool from "./tool.js" export * as OpenAPI from "./openapi/index.js" export { ToolError, toolError } from "./tool-error.js" diff --git a/packages/codemode/src/interpreter/model.ts b/packages/codemode/src/interpreter/model.ts new file mode 100644 index 000000000000..d95351451035 --- /dev/null +++ b/packages/codemode/src/interpreter/model.ts @@ -0,0 +1,201 @@ +import type { SafeObject } from "../tool-runtime.js" +import type { SandboxURL } from "../values.js" + +export type SourcePosition = { + line: number + column: number +} + +export type SourceLocation = { + start: SourcePosition + end: SourcePosition +} + +export type AstNode = { + type: string + loc?: SourceLocation + [key: string]: unknown +} + +export type ProgramNode = AstNode & { + type: "Program" + body: Array +} + +export type Binding = { + mutable: boolean + value: unknown + initialized?: boolean +} + +export type StatementResult = + | { kind: "none" } + | { kind: "value"; value: unknown } + | { kind: "return"; value: unknown } + | { kind: "break" } + | { kind: "continue" } + +export type MemberReference = { + target: SafeObject | Array | SandboxURL + key: string | number +} + +export class CodeModeFunction { + constructor( + readonly parameters: ReadonlyArray, + readonly body: AstNode, + readonly capturedScopes: ReadonlyArray>, + ) {} +} + +export class IntrinsicReference { + constructor( + readonly receiver: unknown, + readonly name: string, + ) {} +} + +export class ComputedValue { + constructor(readonly value: unknown) {} +} + +export class PromiseNamespace {} + +export type PromiseMethodName = "all" | "allSettled" | "race" | "resolve" | "reject" + +export class PromiseMethodReference { + constructor(readonly name: PromiseMethodName) {} +} + +export type GlobalNamespaceName = + | "Object" + | "Math" + | "JSON" + | "Array" + | "console" + | "Date" + | "RegExp" + | "Map" + | "Set" + | "URL" + | "URLSearchParams" + +export class GlobalNamespace { + constructor(readonly name: GlobalNamespaceName) {} +} + +export class GlobalMethodReference { + constructor( + readonly namespace: GlobalNamespaceName | "Number" | "String", + readonly name: string, + ) {} +} + +export class CoercionFunction { + constructor(readonly name: "Number" | "String" | "Boolean" | "parseInt" | "parseFloat") {} +} + +export class UriFunction { + constructor(readonly name: "encodeURI" | "encodeURIComponent" | "decodeURI" | "decodeURIComponent") {} +} + +export class ProgramThrow { + constructor(readonly value: unknown) {} +} + +export class ErrorConstructorReference { + constructor(readonly name: string) {} +} + +export type DiagnosticKind = + | "ParseError" + | "UnsupportedSyntax" + | "UnknownTool" + | "InvalidToolInput" + | "InvalidToolOutput" + | "InvalidDataValue" + | "ToolCallLimitExceeded" + | "TimeoutExceeded" + | "ToolFailure" + | "ExecutionFailure" + +export const OptionalShortCircuit: unique symbol = Symbol("codemode.optional-short-circuit") + +export const supportedSyntaxMessage = + "Supported orchestration syntax: tools.* calls (they return promises - resolve them with await), data literals, destructuring, optional chaining, template literals, conditionals, switch, loops (incl. for...of and for...in over object/array/tools keys), arrow functions, spread, try/catch, array methods (map/filter/find/findIndex/some/every/reduce/flatMap/forEach/sort/slice/concat/indexOf/lastIndexOf/at/flat/reverse/includes/join), string methods (incl. match/matchAll/replace/split with regular expressions), Date/RegExp/Map/Set/URL/URLSearchParams, URI encoding helpers, Object/Math/JSON helpers, captured console.log/warn/error/dir/table, and Promise.all/allSettled/race/resolve/reject over arrays mixing promises and plain values for parallel tool calls (promise chaining with .then/.catch is not supported - use await with try/catch)." + +export class InterpreterRuntimeError extends Error { + readonly node?: AstNode + errorName: string = "Error" + + constructor( + message: string, + node?: AstNode, + readonly kind: DiagnosticKind = "ExecutionFailure", + readonly suggestions?: ReadonlyArray, + ) { + super(message) + this.name = "InterpreterRuntimeError" + if (node) this.node = node + } + + as(errorName: string): this { + this.errorName = errorName + return this + } +} + +export const unsupportedSyntax = (kind: string, node: AstNode): InterpreterRuntimeError => + new InterpreterRuntimeError( + `Syntax '${kind}' is not supported in CodeMode. ${supportedSyntaxMessage}`, + node, + "UnsupportedSyntax", + [supportedSyntaxMessage], + ) + +export const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null + +export const asNode = (value: unknown, context: string): AstNode => { + if (!isRecord(value) || typeof value.type !== "string") { + throw new InterpreterRuntimeError(`Invalid AST node while reading ${context}.`) + } + return value as AstNode +} + +export const getArray = (node: AstNode, key: string): Array => { + const value = node[key] + if (!Array.isArray(value)) throw new InterpreterRuntimeError(`Expected '${key}' to be an array.`, node) + return value +} + +export const getString = (node: AstNode, key: string): string => { + const value = node[key] + if (typeof value !== "string") throw new InterpreterRuntimeError(`Expected '${key}' to be a string.`, node) + return value +} + +export const getBoolean = (node: AstNode, key: string): boolean => { + const value = node[key] + if (typeof value !== "boolean") throw new InterpreterRuntimeError(`Expected '${key}' to be a boolean.`, node) + return value +} + +export const getOptionalNode = (node: AstNode, key: string): AstNode | undefined => { + const value = node[key] + if (value === undefined || value === null) return undefined + return asNode(value, key) +} + +export const getNode = (node: AstNode, key: string): AstNode => asNode(node[key], key) + +export const sourceLocation = (node: AstNode): { readonly line: number; readonly column: number } => ({ + line: Math.max(1, (node.loc?.start.line ?? 2) - 1), + column: Math.max(1, (node.loc?.start.column ?? 4) - 3), +}) + +export const formatLocation = (node?: AstNode): string => { + if (!node?.loc) return "" + const location = sourceLocation(node) + return ` (line ${location.line}, col ${location.column})` +} diff --git a/packages/codemode/src/interpreter/runtime.ts b/packages/codemode/src/interpreter/runtime.ts new file mode 100644 index 000000000000..093f57776510 --- /dev/null +++ b/packages/codemode/src/interpreter/runtime.ts @@ -0,0 +1,3465 @@ +import { parse } from "acorn" +import { Cause, Effect, Exit, Fiber, Semaphore } from "effect" +import { DiagnosticCategory, ModuleKind, ScriptTarget, flattenDiagnosticMessageText, transpileModule } from "typescript" +import { + copyIn, + copyOut, + isBlockedMember, + ToolReference, + ToolRuntime, + ToolRuntimeError, + type HostTools, + type SafeObject, + type Services, +} from "../tool-runtime.js" +import { ToolError } from "../tool-error.js" +import type { + DataValue, + Diagnostic, + DiagnosticKind, + ExecuteOptions, + ResolvedExecutionLimits, + Result, +} from "../codemode.js" +import { + type AstNode, + asNode, + type Binding, + CodeModeFunction, + CoercionFunction, + ComputedValue, + ErrorConstructorReference, + GlobalMethodReference, + GlobalNamespace, + type GlobalNamespaceName, + formatLocation, + getArray, + getBoolean, + getNode, + getOptionalNode, + getString, + IntrinsicReference, + InterpreterRuntimeError, + isRecord, + type MemberReference, + OptionalShortCircuit, + PromiseMethodReference, + type PromiseMethodName, + PromiseNamespace, + ProgramThrow, + type ProgramNode, + type StatementResult, + sourceLocation, + supportedSyntaxMessage, + unsupportedSyntax, + UriFunction, +} from "./model.js" +import { arrayMethods, mapMethods, setMethods, spreadItems } from "../stdlib/collections.js" +import { consoleMethods, MAX_CONSOLE_DEPTH } from "../stdlib/console.js" +import { dateMethods, dateStatics, invokeDateMethod, invokeDateStatic } from "../stdlib/date.js" +import { invokeJsonMethod } from "../stdlib/json.js" +import { invokeMathMethod, mathConstants } from "../stdlib/math.js" +import { + invokeNumberMethod, + invokeNumberStatic, + numberConstants, + numberMethods, + numberStatics, +} from "../stdlib/number.js" +import { invokeObjectMethod } from "../stdlib/object.js" +import { promiseStatics, TOOL_CALL_CONCURRENCY } from "../stdlib/promise.js" +import { + escapeRegexHint, + invokeRegExpMethod, + matchToValue, + regexpMethods, + regexpProperties, + regexFailureReason, + toHostRegex, +} from "../stdlib/regexp.js" +import { invokeStringStatic, stringMethods, stringStatics } from "../stdlib/string.js" +import { + urlMethods, + urlProperties, + urlSearchParamsMethods, + urlStatics, + urlWritableProperties, + invokeUriFunction, + invokeURLMethod, + invokeURLStatic, + uriArgument, + urlArgument, +} from "../stdlib/url.js" +import { + boundedData, + coerceToNumber, + coerceToString, + compoundOperators, + createErrorValue, + errorBrandName, + errorConstructors, + invokeCoercion, + valueConstructors, +} from "../stdlib/value.js" +import { + isSandboxValue, + SandboxDate, + SandboxMap, + SandboxPromise, + SandboxRegExp, + SandboxSet, + SandboxURL, + SandboxURLSearchParams, +} from "../values.js" + +const parseProgram = (code: string): ProgramNode => { + const transpiled = transpileModule(`async function __codemode__() {\n${code}\n}`, { + reportDiagnostics: true, + compilerOptions: { + target: ScriptTarget.ESNext, + module: ModuleKind.ESNext, + }, + }) + const diagnostic = transpiled.diagnostics?.find((item) => item.category === DiagnosticCategory.Error) + + if (diagnostic) { + throw new InterpreterRuntimeError( + `Failed to parse TypeScript: ${flattenDiagnosticMessageText(diagnostic.messageText, "\n")}`, + undefined, + "ParseError", + ) + } + + const bodyStart = transpiled.outputText.indexOf("{") + 1 + const bodyEnd = transpiled.outputText.lastIndexOf("}") + const executableCode = transpiled.outputText.slice(bodyStart, bodyEnd) + const parsed = parse(executableCode, { + ecmaVersion: "latest", + sourceType: "script", + allowReturnOutsideFunction: true, + allowAwaitOutsideFunction: true, + locations: true, + }) as unknown + + if (!isRecord(parsed) || parsed.type !== "Program" || !Array.isArray(parsed.body)) { + throw new InterpreterRuntimeError("Failed to parse script as a Program node.") + } + + return parsed as ProgramNode +} + +const publicErrorMessage = (message: string): string => + message.replace(/\/(?:Users|home|private|tmp|var\/folders)\/[^\s"'`]+/g, "") + +const normalizeError = (error: unknown): Diagnostic => { + if (error instanceof InterpreterRuntimeError) { + return { + kind: error.kind, + message: `${error.message}${formatLocation(error.node)}`, + ...(error.node?.loc ? { location: sourceLocation(error.node) } : {}), + ...(error.suggestions ? { suggestions: error.suggestions } : {}), + } + } + + if (error instanceof ToolRuntimeError) { + return { + kind: error.kind, + message: error.message, + ...(error.suggestions.length > 0 ? { suggestions: error.suggestions } : {}), + } + } + + if (error instanceof ToolError) { + return { kind: "ToolFailure", message: publicErrorMessage(error.message) } + } + + if (error instanceof ProgramThrow) { + const value = error.value + let message: string + if (containsRuntimeReference(value)) { + // A thrown tool/function reference must not leak its internal structure. + message = "a non-data value" + } else if (typeof value === "string") { + message = value + } else if ( + value !== null && + typeof value === "object" && + typeof (value as { message?: unknown }).message === "string" + ) { + message = (value as { message: string }).message + } else { + try { + message = JSON.stringify(copyOut(value)) ?? String(value) + } catch { + message = String(value) + } + } + return { kind: "ExecutionFailure", message: `Uncaught: ${message}` } + } + + if (error instanceof RangeError && /call stack|recursion/i.test(error.message)) { + return { + kind: "ExecutionFailure", + message: "Execution exceeded the maximum nesting depth.", + } + } + + if (error instanceof Error) { + return { + kind: error.name === "SyntaxError" ? "ParseError" : "ExecutionFailure", + message: publicErrorMessage(error.message), + } + } + + // A non-Error thrown by a host tool (raw string / number / Symbol) still routes through + // path redaction so filesystem paths can never leak through the catch-all branch. + return { + kind: "ExecutionFailure", + message: publicErrorMessage(String(error)), + } +} + +// Shared by catch bindings, Promise.allSettled rejection reasons, and Promise.race losers. +const caughtErrorValue = (thrown: unknown): unknown => { + if (thrown instanceof ProgramThrow) return thrown.value + if (thrown instanceof InterpreterRuntimeError) return createErrorValue(thrown.errorName, thrown.message) + const name = thrown instanceof Error && errorConstructors.has(thrown.name) ? thrown.name : "Error" + return createErrorValue(name, normalizeError(thrown).message) +} + +const isRuntimeReference = (value: unknown): boolean => + value instanceof CodeModeFunction || + value instanceof ToolReference || + value instanceof IntrinsicReference || + value instanceof GlobalNamespace || + value instanceof GlobalMethodReference || + value instanceof PromiseNamespace || + value instanceof PromiseMethodReference || + value instanceof SandboxPromise || + value instanceof CoercionFunction || + value instanceof UriFunction || + value instanceof ErrorConstructorReference || + isSandboxValue(value) + +const containsRuntimeReference = (value: unknown, seen = new Set()): boolean => { + if (isRuntimeReference(value)) return true + if (value === null || typeof value !== "object") return false + if (seen.has(value)) return false + seen.add(value) + const contains = Array.isArray(value) + ? value.some((item) => containsRuntimeReference(item, seen)) + : Object.values(value).some((item) => containsRuntimeReference(item, seen)) + seen.delete(value) + return contains +} + +// Like containsRuntimeReference, but sandbox standard-library values count as data: +// operators and switch treat them as ordinary object operands (identity equality, ToPrimitive +// coercion) rather than rejecting them as opaque interpreter machinery. +const containsOpaqueReference = (value: unknown, seen = new Set()): boolean => { + if (isSandboxValue(value)) return false + if (isRuntimeReference(value)) return true + if (value === null || typeof value !== "object") return false + if (seen.has(value)) return false + seen.add(value) + const contains = Array.isArray(value) + ? value.some((item) => containsOpaqueReference(item, seen)) + : Object.values(value).some((item) => containsOpaqueReference(item, seen)) + seen.delete(value) + return contains +} + +// `typeof` never throws in JS; map every interpreter value to its JS-visible category. +// A SandboxPromise falls through to the final `typeof value` and reports "object", exactly +// like a real JS promise. +const typeofValue = (value: unknown): string => { + if ( + value instanceof CodeModeFunction || + value instanceof CoercionFunction || + value instanceof IntrinsicReference || + value instanceof GlobalMethodReference || + value instanceof PromiseMethodReference || + value instanceof PromiseNamespace || + value instanceof ErrorConstructorReference + ) + return "function" + if (value instanceof UriFunction) return "function" + if (value instanceof ToolReference) return value.path.length > 0 ? "function" : "object" + if (value instanceof GlobalNamespace) { + return value.name === "Math" || value.name === "JSON" || value.name === "console" ? "object" : "function" + } + return typeof value +} + +// `x instanceof C` against the constructors CodeMode knows. Like `typeof`, it observes any +// left-hand value (opaque references included) without coercing it. Error checks use the +// error brand: `instanceof Error` accepts every branded error; a specific error type matches +// its own brand only (as in JS, where TypeError instances are also Error instances). +const instanceofValue = (lhs: unknown, rhs: unknown, node: AstNode): boolean => { + if (rhs instanceof ErrorConstructorReference) { + const brand = errorBrandName(lhs) + return brand !== undefined && (rhs.name === "Error" || brand === rhs.name) + } + if (rhs instanceof GlobalNamespace) { + switch (rhs.name) { + case "Date": + return lhs instanceof SandboxDate + case "RegExp": + return lhs instanceof SandboxRegExp + case "Map": + return lhs instanceof SandboxMap + case "Set": + return lhs instanceof SandboxSet + case "URL": + return lhs instanceof SandboxURL + case "URLSearchParams": + return lhs instanceof SandboxURLSearchParams + case "Array": + return Array.isArray(lhs) + case "Object": + return lhs !== null && (typeof lhs === "object" || typeofValue(lhs) === "function") + } + } + if (rhs instanceof PromiseNamespace) return lhs instanceof SandboxPromise + // Number/String/Boolean wrap primitives in JS; no boxed values exist in CodeMode, so + // `x instanceof Number` is always false - exactly what it is for primitives in JS. + if (rhs instanceof CoercionFunction && (rhs.name === "Number" || rhs.name === "String" || rhs.name === "Boolean")) { + return false + } + throw new InterpreterRuntimeError( + "The right-hand side of 'instanceof' must be a constructor CodeMode knows: Error (or a specific error type like TypeError), Date, RegExp, Map, Set, URL, URLSearchParams, Array, Object, or Promise.", + node, + ) +} + +const invokeStringMethod = (value: string, name: string, args: Array, node: AstNode): unknown => { + const str = (index: number): string => { + const arg = args[index] + if (typeof arg !== "string") + throw new InterpreterRuntimeError(`String.${name} expects argument ${index + 1} to be a string.`, node) + return arg + } + const num = (index: number): number => { + const arg = args[index] + if (typeof arg !== "number") + throw new InterpreterRuntimeError(`String.${name} expects argument ${index + 1} to be a number.`, node) + return arg + } + const optNum = (index: number): number | undefined => (args[index] === undefined ? undefined : num(index)) + const optStr = (index: number): string | undefined => (args[index] === undefined ? undefined : str(index)) + + let result: unknown + switch (name) { + case "toLowerCase": + result = value.toLowerCase() + break + case "toUpperCase": + result = value.toUpperCase() + break + case "trim": + result = value.trim() + break + // trimLeft/trimRight are the legacy aliases of trimStart/trimEnd, kept because models write them. + case "trimStart": + case "trimLeft": + result = value.trimStart() + break + case "trimEnd": + case "trimRight": + result = value.trimEnd() + break + // Locale/options arguments are ignored: comparison runs with the host default locale, and + // the common use is a sort comparator where any consistent order works. + case "localeCompare": + result = value.localeCompare(str(0)) + break + case "normalize": { + const form = optStr(0) + try { + result = value.normalize(form) + } catch { + throw new InterpreterRuntimeError( + `String.normalize expects the form "NFC", "NFD", "NFKC", or "NFKD" (got ${JSON.stringify(form)}).`, + node, + ).as("RangeError") + } + break + } + case "split": { + if (args.length === 0) { + result = [value] + break + } + if (args[0] instanceof SandboxRegExp) { + result = value.split((args[0] as SandboxRegExp).regex, optNum(1)) + break + } + const requestedLimit = optNum(1) + result = value.split(str(0), requestedLimit === undefined ? undefined : requestedLimit >>> 0) + break + } + case "slice": + result = value.slice(optNum(0), optNum(1)) + break + case "includes": + result = value.includes(str(0), optNum(1)) + break + case "startsWith": + result = value.startsWith(str(0), optNum(1)) + break + case "endsWith": + result = value.endsWith(str(0), optNum(1)) + break + case "indexOf": + result = value.indexOf(str(0), optNum(1)) + break + case "lastIndexOf": + result = value.lastIndexOf(str(0), optNum(1)) + break + case "replace": + case "replaceAll": { + if (args[0] instanceof SandboxRegExp) { + const pattern = (args[0] as SandboxRegExp).regex + const replacement = str(1) + if (name === "replaceAll" && !pattern.global) { + throw new InterpreterRuntimeError( + `String.replaceAll requires a regular expression with the global (g) flag: write /${pattern.source}/${pattern.flags}g, or use String.replace to replace only the first match.`, + node, + ) + } + result = name === "replace" ? value.replace(pattern, replacement) : value.replaceAll(pattern, replacement) + break + } + if (name === "replace") { + result = value.replace(str(0), str(1)) + break + } + result = value.replaceAll(str(0), str(1)) + break + } + case "match": { + const pattern = toHostRegex(args[0], name, node) + const matched = value.match(pattern) + if (matched === null) return null + // A global match is a plain array of matched strings; a non-global match carries + // index/groups own properties, so bypass the copying data checkpoint to keep them. + if (pattern.global) return boundedData(matched, "String.match result") + return matchToValue(matched) + } + case "matchAll": { + const pattern = toHostRegex(args[0], name, node, "g") + if (!pattern.global) { + throw new InterpreterRuntimeError( + `String.matchAll requires a regular expression with the global (g) flag: write /${pattern.source}/${pattern.flags}g, or use String.match for a single match.`, + node, + ) + } + // Materialized as an array (not an iterator); each entry is a match array with + // index/groups own properties. Match count is bounded by the subject length. + return Array.from(value.matchAll(pattern), matchToValue) + } + case "search": { + result = value.search(toHostRegex(args[0], name, node)) + break + } + case "repeat": { + const count = num(0) + if (!Number.isFinite(count) || count < 0) + throw new InterpreterRuntimeError("String.repeat expects a finite non-negative count.", node) + result = value.repeat(count) + break + } + case "padStart": + result = value.padStart(num(0), optStr(1)) + break + case "padEnd": + result = value.padEnd(num(0), optStr(1)) + break + case "charAt": + result = value.charAt(optNum(0) ?? 0) + break + case "at": + result = value.at(optNum(0) ?? 0) + break + case "substring": + result = value.substring(optNum(0) ?? 0, optNum(1)) + break + case "substr": + result = value.substr(optNum(0) ?? 0, optNum(1)) + break + // JS charCodeAt returns NaN out of range; NaN flows as an ordinary in-sandbox value + // (normalized to null only at the data boundary - see copyOut), so return it as-is. + case "charCodeAt": + result = value.charCodeAt(optNum(0) ?? 0) + break + case "codePointAt": + result = value.codePointAt(optNum(0) ?? 0) + break + case "toString": + result = value + break + case "concat": { + result = value.concat(...args.map((_, index) => str(index))) + break + } + default: + throw new InterpreterRuntimeError(`String method '${name}' is not available in CodeMode.`, node) + } + return boundedData(result, `String.${name} result`) +} + +const invokeArrayStatic = (name: string, args: Array, node: AstNode): unknown => { + switch (name) { + case "isArray": + return Array.isArray(args[0]) + case "of": + return [...args] + case "from": { + if (args.length > 1) { + throw new InterpreterRuntimeError( + "Array.from(...) does not support a map function in CodeMode; call .map() on the result instead.", + node, + "UnsupportedSyntax", + [supportedSyntaxMessage], + ) + } + // Map/Set materialize directly (the data checkpoint would serialize them to {}). + if (args[0] instanceof SandboxMap) + return Array.from((args[0] as SandboxMap).map.entries(), ([key, item]) => [key, item]) + if (args[0] instanceof SandboxSet) return Array.from((args[0] as SandboxSet).set.values()) + if (args[0] instanceof SandboxURLSearchParams) { + return Array.from(args[0].params.entries(), ([key, value]) => [key, value]) + } + const source = boundedData(args[0], "Array.from input") + if (typeof source === "string") return Array.from(source) + if (Array.isArray(source)) return [...source] + if ( + source !== null && + typeof source === "object" && + typeof (source as { length?: unknown }).length === "number" + ) { + return Array.from(source as ArrayLike) + } + throw new InterpreterRuntimeError("Array.from expects an array, string, Map, Set, or array-like value.", node) + } + default: + throw new InterpreterRuntimeError(`Array.${name} is not available in CodeMode.`, node) + } +} + +const invokeGlobalMethod = (ref: GlobalMethodReference, args: Array, node: AstNode): unknown => { + if (ref.namespace === "console") + throw new InterpreterRuntimeError(`console.${ref.name} is not available in CodeMode.`, node) + if (ref.namespace === "Object") return invokeObjectMethod(ref.name, args, node) + if (ref.namespace === "Math") return invokeMathMethod(ref.name, args, node) + if (ref.namespace === "Array") return invokeArrayStatic(ref.name, args, node) + if (ref.namespace === "Number") return invokeNumberStatic(ref.name, args, node) + if (ref.namespace === "String") return invokeStringStatic(ref.name, args, node) + if (ref.namespace === "URL") return invokeURLStatic(ref.name, args, node) + if (ref.namespace === "Date") { + if (!dateStatics.has(ref.name)) + throw new InterpreterRuntimeError(`Date.${ref.name} is not available in CodeMode.`, node) + return invokeDateStatic(ref.name, args, node) + } + if ( + ref.namespace === "RegExp" || + ref.namespace === "Map" || + ref.namespace === "Set" || + ref.namespace === "URLSearchParams" + ) { + throw new InterpreterRuntimeError(`${ref.namespace}.${ref.name} is not available in CodeMode.`, node) + } + return invokeJsonMethod(ref.name, args, node) +} + +// Every identifier a parameter pattern binds, used to seed TDZ slots before defaults run. +const collectPatternNames = (pattern: AstNode, out: Array = []): Array => { + switch (pattern.type) { + case "Identifier": + out.push(getString(pattern, "name")) + break + case "AssignmentPattern": + collectPatternNames(getNode(pattern, "left"), out) + break + case "RestElement": + collectPatternNames(getNode(pattern, "argument"), out) + break + case "ArrayPattern": + for (const element of getArray(pattern, "elements")) { + if (element !== null) collectPatternNames(asNode(element, "elements"), out) + } + break + case "ObjectPattern": + for (const property of getArray(pattern, "properties")) { + const prop = asNode(property, "properties") + collectPatternNames(prop.type === "RestElement" ? getNode(prop, "argument") : getNode(prop, "value"), out) + } + break + } + return out +} + +class Interpreter { + private scopes: Array> + private readonly invokeTool: (path: ReadonlyArray, args: Array) => Effect.Effect + // Enumerable namespace/tool names at a node of the host tool tree, threaded from + // ToolRuntime.make like invokeTool: the interpreter never holds the tree itself. + private readonly toolKeys: (path: ReadonlyArray) => ReadonlyArray + private readonly logs: Array + private lastValue: unknown + // Caps how many eagerly forked tool calls run at once (the parallel-call concurrency cap). + private readonly callPermits: Semaphore.Semaphore + // Fiber-backed promises whose settlement no program construct has observed yet. Successful + // program completion drains these (like a runtime waiting on in-flight work at exit) and + // surfaces a never-awaited failure as an unhandled-rejection diagnostic. + private readonly pendingSettlements = new Set() + + constructor( + invokeTool: (path: ReadonlyArray, args: Array) => Effect.Effect, + toolKeys: (path: ReadonlyArray) => ReadonlyArray, + logs: Array = [], + ) { + const globalScope = new Map() + this.scopes = [globalScope] + this.invokeTool = invokeTool + this.toolKeys = toolKeys + this.logs = logs + this.lastValue = undefined + this.callPermits = Semaphore.makeUnsafe(TOOL_CALL_CONCURRENCY) + globalScope.set("tools", { mutable: false, value: new ToolReference([]) }) + globalScope.set("Promise", { mutable: false, value: new PromiseNamespace() }) + globalScope.set("undefined", { mutable: false, value: undefined }) + globalScope.set("Object", { mutable: false, value: new GlobalNamespace("Object") }) + globalScope.set("Math", { mutable: false, value: new GlobalNamespace("Math") }) + globalScope.set("JSON", { mutable: false, value: new GlobalNamespace("JSON") }) + globalScope.set("Number", { mutable: false, value: new CoercionFunction("Number") }) + globalScope.set("String", { mutable: false, value: new CoercionFunction("String") }) + globalScope.set("Boolean", { mutable: false, value: new CoercionFunction("Boolean") }) + globalScope.set("Array", { mutable: false, value: new GlobalNamespace("Array") }) + globalScope.set("console", { mutable: false, value: new GlobalNamespace("console") }) + globalScope.set("parseInt", { mutable: false, value: new CoercionFunction("parseInt") }) + globalScope.set("parseFloat", { mutable: false, value: new CoercionFunction("parseFloat") }) + globalScope.set("Date", { mutable: false, value: new GlobalNamespace("Date") }) + globalScope.set("RegExp", { mutable: false, value: new GlobalNamespace("RegExp") }) + globalScope.set("Map", { mutable: false, value: new GlobalNamespace("Map") }) + globalScope.set("Set", { mutable: false, value: new GlobalNamespace("Set") }) + globalScope.set("URL", { mutable: false, value: new GlobalNamespace("URL") }) + globalScope.set("URLSearchParams", { mutable: false, value: new GlobalNamespace("URLSearchParams") }) + globalScope.set("encodeURI", { mutable: false, value: new UriFunction("encodeURI") }) + globalScope.set("encodeURIComponent", { mutable: false, value: new UriFunction("encodeURIComponent") }) + globalScope.set("decodeURI", { mutable: false, value: new UriFunction("decodeURI") }) + globalScope.set("decodeURIComponent", { mutable: false, value: new UriFunction("decodeURIComponent") }) + // Error constructors are real values, so `x instanceof Error` works and `Error("msg")` + // (with or without `new`) constructs a branded { name, message } error object. + for (const name of errorConstructors) { + globalScope.set(name, { mutable: false, value: new ErrorConstructorReference(name) }) + } + // NaN/Infinity flow as ordinary in-sandbox values (normalized to null only at the data + // boundary - see copyOut), so their global bindings must exist too, e.g. `reduce(max, -Infinity)`. + globalScope.set("NaN", { mutable: false, value: NaN }) + globalScope.set("Infinity", { mutable: false, value: Infinity }) + } + + run(program: ProgramNode): Effect.Effect { + const self = this + // Run the program body in its own module scope on top of the builtin global scope, so + // top-level declarations (`let undefined = 5`, `const Object = ...`) shadow builtins like + // JS module scope, instead of colliding with the seeded globals. + this.pushScope() + return Effect.gen(function* () { + self.hoistFunctions(program.body) + let value: unknown = undefined + let returned = false + for (const statement of program.body) { + const result = yield* self.evaluateStatement(statement) + + if (result.kind === "return") { + value = result.value + returned = true + break + } + + if (result.kind === "break" || result.kind === "continue") { + throw new InterpreterRuntimeError(`Unexpected '${result.kind}' outside of a loop.`, statement) + } + + if (result.kind === "value") { + self.lastValue = result.value + } + } + if (!returned) value = self.lastValue + + // The program body runs inside an implicit async function, so a returned promise + // resolves before crossing the data boundary - `return tools.ns.tool(...)` works + // without an explicit await, exactly as in JS. + if (value instanceof SandboxPromise) value = yield* self.settlePromise(value) + yield* self.drainPendingSettlements() + return value + }).pipe(Effect.ensuring(Effect.sync(() => self.popScope()))) + } + + // Awaits every fiber-backed promise the program abandoned (fire-and-forget tool calls), so + // their work completes before the execution ends - mirroring a JS runtime waiting on + // in-flight I/O at exit. A failure nobody could have handled becomes an unhandled-rejection + // diagnostic (interrupted calls, e.g. Promise.race losers, are ignored). + private drainPendingSettlements(): Effect.Effect { + const self = this + return Effect.gen(function* () { + for (const promise of [...self.pendingSettlements]) { + const exit = yield* self.observePromise(promise) + if (Exit.isSuccess(exit) || Cause.hasInterruptsOnly(exit.cause)) continue + const failure = normalizeError(Cause.squash(exit.cause)) + throw new InterpreterRuntimeError( + `Unhandled rejection from an un-awaited tool call: ${failure.message}`, + undefined, + failure.kind, + ["Await tool calls - `const result = await tools.ns.tool(...)` - so failures can be caught and handled."], + ) + } + }) + } + + // Eagerly starts a tool call on a supervised child fiber (so the execution timeout and + // scope teardown interrupt it) gated by the concurrency semaphore, and wraps the fiber in a + // first-class promise value. `startImmediately` makes the runtime admit the call - charging + // the tool-call budget and firing onToolCallStart - at the call site, before any await. + private createToolCallPromise( + path: ReadonlyArray, + args: Array, + ): Effect.Effect { + const self = this + return Effect.map( + Effect.forkChild(this.callPermits.withPermit(Effect.suspend(() => self.invokeTool(path, args))), { + startImmediately: true, + }), + (fiber) => { + const promise = new SandboxPromise(fiber) + self.pendingSettlements.add(promise) + return promise + }, + ) + } + + // The promise's settlement as an Exit, marking it observed for unhandled-rejection tracking. + // Fiber settlement is idempotent, so observing the same promise repeatedly (await twice, + // Promise.all([p, p])) never re-runs the underlying call. + private observePromise(promise: SandboxPromise): Effect.Effect> { + this.pendingSettlements.delete(promise) + return promise.fiber !== undefined ? Fiber.await(promise.fiber) : Effect.exit(promise.immediate ?? Effect.void) + } + + // `await promise`: succeed with the fulfilled value or re-raise the failure so try/catch + // observes it exactly like a synchronous throw at the await site. + private settlePromise(promise: SandboxPromise, node?: AstNode): Effect.Effect { + const self = this + return Effect.flatMap(this.observePromise(promise), (exit) => self.unwrapPromiseExit(promise, exit, node)) + } + + private unwrapPromiseExit( + promise: SandboxPromise | undefined, + exit: Exit.Exit, + node?: AstNode, + ): Effect.Effect { + if (Exit.isSuccess(exit)) return Effect.succeed(exit.value) + // A call Promise.race interrupted after losing settles as a catchable program failure; + // any other interruption is execution teardown (timeout/host) and must keep propagating + // as interruption rather than becoming program-visible data. + if (promise?.interrupted === true && Cause.hasInterruptsOnly(exit.cause)) { + return Effect.fail( + new InterpreterRuntimeError( + "This tool call was interrupted because another value settled a Promise.race first.", + node, + ), + ) + } + return Effect.failCause(exit.cause) + } + + private evaluateStatement(node: AstNode): Effect.Effect { + switch (node.type) { + case "ExpressionStatement": + return Effect.map(this.evaluateExpression(getNode(node, "expression")), (value) => ({ kind: "value", value })) + case "VariableDeclaration": + return Effect.map(this.evaluateVariableDeclaration(node), () => ({ kind: "none" })) + case "ReturnStatement": { + const argumentNode = getOptionalNode(node, "argument") + return argumentNode + ? Effect.map(this.evaluateExpression(argumentNode), (value) => ({ kind: "return", value })) + : Effect.succeed({ kind: "return", value: undefined }) + } + case "BlockStatement": + return this.evaluateBlock(node) + case "IfStatement": + return this.evaluateIfStatement(node) + case "SwitchStatement": + return this.evaluateSwitchStatement(node) + case "WhileStatement": + return this.evaluateWhileStatement(node) + case "DoWhileStatement": + return this.evaluateDoWhileStatement(node) + case "ForStatement": + return this.evaluateForStatement(node) + case "ForOfStatement": + return this.evaluateForOfStatement(node) + case "ForInStatement": + return this.evaluateForInStatement(node) + case "BreakStatement": + return Effect.succeed(this.evaluateBreakStatement(node)) + case "ContinueStatement": + return Effect.succeed(this.evaluateContinueStatement(node)) + case "ThrowStatement": + return this.evaluateThrowStatement(node) + case "TryStatement": + return this.evaluateTryStatement(node) + case "EmptyStatement": + return Effect.succeed({ kind: "none" }) + case "FunctionDeclaration": + return Effect.succeed({ kind: "none" }) // bound ahead of time by hoistFunctions + default: + throw unsupportedSyntax(node.type, node) + } + } + + private evaluateBlock(node: AstNode): Effect.Effect { + this.pushScope() + const self = this + return Effect.gen(function* () { + const body = getArray(node, "body") + self.hoistFunctions(body) + + for (const statementValue of body) { + const statement = asNode(statementValue, "body") + const result = yield* self.evaluateStatement(statement) + + if (result.kind === "value") { + self.lastValue = result.value + continue + } + + if (result.kind !== "none") { + return result + } + } + + return { kind: "none" } satisfies StatementResult + }).pipe(Effect.ensuring(Effect.sync(() => self.popScope()))) + } + + private createFunction(node: AstNode): CodeModeFunction { + if (node.generator === true) { + throw new InterpreterRuntimeError( + "Generator functions are not supported in CodeMode.", + node, + "UnsupportedSyntax", + [supportedSyntaxMessage], + ) + } + return new CodeModeFunction( + getArray(node, "params").map((parameter, index) => asNode(parameter, `params[${index}]`)), + getNode(node, "body"), + this.scopes.slice(), + ) + } + + // Function declarations are hoisted: bound in their scope before the body runs, so a + // program can call a helper defined further down (matching JavaScript). + private hoistFunctions(statements: Array): void { + for (const statementValue of statements) { + if (!isRecord(statementValue) || statementValue.type !== "FunctionDeclaration") continue + const node = statementValue as AstNode + this.declare(getString(getNode(node, "id"), "name"), this.createFunction(node), true, node) + } + } + + private evaluateIfStatement(node: AstNode): Effect.Effect { + const testNode = getNode(node, "test") + const consequentNode = getNode(node, "consequent") + const alternateNode = getOptionalNode(node, "alternate") + + return Effect.flatMap(this.evaluateExpression(testNode), (test) => + test + ? this.evaluateStatement(consequentNode) + : alternateNode + ? this.evaluateStatement(alternateNode) + : Effect.succeed({ kind: "none" }), + ) + } + + private evaluateSwitchStatement(node: AstNode): Effect.Effect { + const self = this + this.pushScope() + return Effect.gen(function* () { + const discriminant = yield* self.evaluateExpression(getNode(node, "discriminant")) + if (containsOpaqueReference(discriminant)) { + throw new InterpreterRuntimeError( + "Switch discriminants must be data values in CodeMode.", + node, + "InvalidDataValue", + ) + } + const cases = getArray(node, "cases").map((value, index) => asNode(value, `cases[${index}]`)) + let defaultIndex: number | undefined + let selected: number | undefined + for (const [index, branch] of cases.entries()) { + const test = getOptionalNode(branch, "test") + if (!test) { + defaultIndex = index + continue + } + const candidate = yield* self.evaluateExpression(test) + if (containsOpaqueReference(candidate)) { + throw new InterpreterRuntimeError( + "Switch case values must be data values in CodeMode.", + test, + "InvalidDataValue", + ) + } + if (candidate === discriminant) { + selected = index + break + } + } + const start = selected ?? defaultIndex + if (start === undefined) return { kind: "none" } satisfies StatementResult + for (let index = start; index < cases.length; index += 1) { + for (const statementValue of getArray(cases[index]!, "consequent")) { + const result = yield* self.evaluateStatement(asNode(statementValue, "consequent")) + if (result.kind === "break") return { kind: "none" } satisfies StatementResult + if (result.kind === "return" || result.kind === "continue") return result + if (result.kind === "value") self.lastValue = result.value + } + } + return { kind: "none" } satisfies StatementResult + }).pipe(Effect.ensuring(Effect.sync(() => self.popScope()))) + } + + private evaluateWhileStatement(node: AstNode): Effect.Effect { + const testNode = getNode(node, "test") + const bodyNode = getNode(node, "body") + + const self = this + return Effect.gen(function* () { + while (yield* self.evaluateExpression(testNode)) { + const result = yield* self.evaluateStatement(bodyNode) + + if (result.kind === "continue") { + continue + } + + if (result.kind === "break") { + return { kind: "none" } satisfies StatementResult + } + + if (result.kind === "return") { + return result + } + + if (result.kind === "value") { + self.lastValue = result.value + } + } + + return { kind: "none" } satisfies StatementResult + }) + } + + private evaluateDoWhileStatement(node: AstNode): Effect.Effect { + const bodyNode = getNode(node, "body") + const testNode = getNode(node, "test") + + const self = this + return Effect.gen(function* () { + do { + const result = yield* self.evaluateStatement(bodyNode) + + if (result.kind === "continue") { + continue + } + + if (result.kind === "break") { + return { kind: "none" } satisfies StatementResult + } + + if (result.kind === "return") { + return result + } + + if (result.kind === "value") { + self.lastValue = result.value + } + } while (yield* self.evaluateExpression(testNode)) + + return { kind: "none" } satisfies StatementResult + }) + } + + private evaluateForStatement(node: AstNode): Effect.Effect { + this.pushScope() + const self = this + return Effect.gen(function* () { + const initNode = getOptionalNode(node, "init") + const testNode = getOptionalNode(node, "test") + const updateNode = getOptionalNode(node, "update") + const bodyNode = getNode(node, "body") + + if (initNode) { + if (initNode.type === "VariableDeclaration") { + yield* self.evaluateVariableDeclaration(initNode) + } else { + yield* self.evaluateExpression(initNode) + } + } + + const perIterationBindings = + initNode?.type === "VariableDeclaration" && getString(initNode, "kind") !== "var" + ? Array.from(self.currentScope().keys()) + : [] + + while (testNode ? yield* self.evaluateExpression(testNode) : true) { + let iterationScope: Map | undefined + if (perIterationBindings.length > 0) { + iterationScope = new Map( + perIterationBindings.map((name) => { + const binding = self.currentScope().get(name)! + return [name, { ...binding }] + }), + ) + self.scopes.push(iterationScope) + } + const result = yield* self.evaluateStatement(bodyNode).pipe( + Effect.ensuring( + Effect.sync(() => { + if (iterationScope) self.popScope() + }), + ), + ) + + if (result.kind === "return") { + return result + } + + if (result.kind === "break") { + return { kind: "none" } satisfies StatementResult + } + + if (result.kind === "value") { + self.lastValue = result.value + } + + if (iterationScope) { + const loopScope = self.currentScope() + for (const name of perIterationBindings) { + loopScope.set(name, { ...iterationScope.get(name)! }) + } + } + + if (updateNode) { + yield* self.evaluateExpression(updateNode) + } + + if (result.kind === "continue") { + continue + } + } + + return { kind: "none" } satisfies StatementResult + }).pipe(Effect.ensuring(Effect.sync(() => self.popScope()))) + } + + private evaluateForOfStatement(node: AstNode): Effect.Effect { + if (getBoolean(node, "await")) { + throw new InterpreterRuntimeError("for await...of is not supported.", node) + } + + const self = this + return Effect.gen(function* () { + const left = getNode(node, "left") + const right = yield* self.evaluateExpression(getNode(node, "right")) + const body = getNode(node, "body") + + // Arrays iterate in place; strings iterate code points; Maps iterate [key, value] + // pairs and Sets iterate values over a snapshot (mutation during iteration is safe). + const iterable = Array.isArray(right) ? right : spreadItems(right) + if (iterable === undefined) { + throw new InterpreterRuntimeError("for...of requires an array, string, Map, or Set value in CodeMode.", node) + } + + let declaration: { readonly pattern: AstNode; readonly mutable: boolean } | undefined + let assignmentName: string | undefined + + if (left.type === "VariableDeclaration") { + const declarations = getArray(left, "declarations") + if (declarations.length !== 1) { + throw new InterpreterRuntimeError("for...of supports one declared binding.", left) + } + + const declarator = asNode(declarations[0], "declarations[0]") + declaration = { pattern: getNode(declarator, "id"), mutable: getString(left, "kind") !== "const" } + } else if (left.type === "Identifier") { + assignmentName = getString(left, "name") + } else { + throw new InterpreterRuntimeError("Unsupported for...of binding.", left) + } + + for (const value of iterable) { + if (declaration) { + self.pushScope() + yield* self.declarePattern(declaration.pattern, value, declaration.mutable, left) + } else if (assignmentName) { + self.setIdentifierValue(assignmentName, value, left) + } + + const result = yield* self.evaluateStatement(body).pipe( + Effect.ensuring( + Effect.sync(() => { + if (declaration) self.popScope() + }), + ), + ) + + if (result.kind === "return") { + return result + } + + if (result.kind === "break") { + return { kind: "none" } + } + + if (result.kind === "value") { + self.lastValue = result.value + } + + if (result.kind === "continue") { + continue + } + } + + return { kind: "none" } + }) + } + + // Own enumerable string keys of a value, shared by `for...in` and `Object.keys` over tool + // references: plain data objects enumerate their own keys, arrays their index strings (plus + // any own non-index properties, e.g. match results' index/groups - exactly Object.keys in + // JS), and a tool reference the namespace/tool names at its path in the host tool tree. + // Returns undefined for everything else so callers can raise a contextual error. + private enumerableKeys(value: unknown): Array | undefined { + if (value instanceof ToolReference) { + return [...this.toolKeys(value.path)] + } + if (Array.isArray(value)) { + return Object.keys(value) + } + if (value !== null && typeof value === "object" && !isRuntimeReference(value)) { + return Object.keys(value) + } + return undefined + } + + private evaluateForInStatement(node: AstNode): Effect.Effect { + const self = this + return Effect.gen(function* () { + const left = getNode(node, "left") + const right = yield* self.evaluateExpression(getNode(node, "right")) + const body = getNode(node, "body") + + // Keys are snapshotted up front (mutation during iteration is safe): plain objects + // enumerate their own keys, arrays their index strings, and tool references the + // namespace/tool names at that node - the same enumeration Object.keys performs. + // Anything else (strings, Maps, Sets, numbers, null, ...) is a deliberate error rather + // than real JS's surprising behavior (indices for strings, zero iterations for + // Maps/Sets/null): the hint points at the constructs that do what the program means. + const keys = self.enumerableKeys(right) + if (keys === undefined) { + throw new InterpreterRuntimeError( + "for...in requires a plain object, array, or tools reference in CodeMode. Use for...of for arrays/strings/Maps/Sets, or Object.keys(value) for a key list.", + node, + ) + } + + let declaration: { readonly pattern: AstNode; readonly mutable: boolean } | undefined + let assignmentName: string | undefined + + if (left.type === "VariableDeclaration") { + const declarations = getArray(left, "declarations") + if (declarations.length !== 1) { + throw new InterpreterRuntimeError("for...in supports one declared binding.", left) + } + + const declarator = asNode(declarations[0], "declarations[0]") + declaration = { pattern: getNode(declarator, "id"), mutable: getString(left, "kind") !== "const" } + } else if (left.type === "Identifier") { + assignmentName = getString(left, "name") + } else { + throw new InterpreterRuntimeError("Unsupported for...in binding.", left) + } + + for (const key of keys) { + if (declaration) { + self.pushScope() + yield* self.declarePattern(declaration.pattern, key, declaration.mutable, left) + } else if (assignmentName) { + self.setIdentifierValue(assignmentName, key, left) + } + + const result = yield* self.evaluateStatement(body).pipe( + Effect.ensuring( + Effect.sync(() => { + if (declaration) self.popScope() + }), + ), + ) + + if (result.kind === "return") { + return result + } + + if (result.kind === "break") { + return { kind: "none" } + } + + if (result.kind === "value") { + self.lastValue = result.value + } + + if (result.kind === "continue") { + continue + } + } + + return { kind: "none" } + }) + } + + private evaluateBreakStatement(node: AstNode): StatementResult { + const labelNode = getOptionalNode(node, "label") + + if (labelNode) { + throw new InterpreterRuntimeError("Labeled break is not supported in v1.", node) + } + + return { kind: "break" } + } + + private evaluateContinueStatement(node: AstNode): StatementResult { + const labelNode = getOptionalNode(node, "label") + + if (labelNode) { + throw new InterpreterRuntimeError("Labeled continue is not supported in v1.", node) + } + + return { kind: "continue" } + } + + private evaluateThrowStatement(node: AstNode): Effect.Effect { + const argument = getNode(node, "argument") + return Effect.flatMap(this.evaluateExpression(argument), (value) => Effect.fail(new ProgramThrow(value))) + } + + private evaluateTryStatement(node: AstNode): Effect.Effect { + const body = getNode(node, "block") + const handler = getOptionalNode(node, "handler") + const finalizer = getOptionalNode(node, "finalizer") + const self = this + + const attempted = Effect.matchCauseEffect(this.evaluateStatement(body), { + onFailure: (cause) => { + if (cause.reasons.some(Cause.isInterruptReason) || !handler) { + return Effect.failCause(cause) + } + + // The program sees a plain { message } error (or the thrown value itself) - see + // caughtErrorValue, shared with Promise.allSettled rejection reasons. + const caught = caughtErrorValue(Cause.squash(cause)) + const parameter = getOptionalNode(handler, "param") + self.pushScope() + return Effect.gen(function* () { + if (parameter) yield* self.declarePattern(parameter, caught, true, handler) + return yield* self.evaluateStatement(getNode(handler, "body")) + }).pipe(Effect.ensuring(Effect.sync(() => self.popScope()))) + }, + onSuccess: Effect.succeed, + }) + + if (!finalizer) return attempted + + const isAbrupt = (result: StatementResult): boolean => + result.kind === "return" || result.kind === "break" || result.kind === "continue" + + return Effect.matchCauseEffect(attempted, { + onFailure: (cause) => + cause.reasons.some(Cause.isInterruptReason) + ? Effect.failCause(cause) + : Effect.flatMap(this.evaluateStatement(finalizer), (final) => + isAbrupt(final) ? Effect.succeed(final) : Effect.failCause(cause), + ), + onSuccess: (result) => + Effect.flatMap(this.evaluateStatement(finalizer), (final) => + isAbrupt(final) ? Effect.succeed(final) : Effect.succeed(result), + ), + }) + } + + private evaluateVariableDeclaration(node: AstNode): Effect.Effect { + const kind = getString(node, "kind") + const declarations = getArray(node, "declarations") + const self = this + return Effect.gen(function* () { + for (const declarationValue of declarations) { + const declaration = asNode(declarationValue, "declarations") + + if (declaration.type !== "VariableDeclarator") { + throw new InterpreterRuntimeError("Unsupported variable declaration shape.", declaration) + } + + const init = getOptionalNode(declaration, "init") + const value = init ? yield* self.evaluateExpression(init) : undefined + yield* self.declarePattern(getNode(declaration, "id"), value, kind !== "const", declaration) + } + }) + } + + private declarePattern( + pattern: AstNode, + value: unknown, + mutable: boolean, + node: AstNode, + ): Effect.Effect { + const self = this + return Effect.gen(function* () { + if (pattern.type === "Identifier") { + self.declare(getString(pattern, "name"), value, mutable, node) + return + } + + // Default values: `x = expr` / `{ a = 1 }` - the default is evaluated only when the value is undefined. + if (pattern.type === "AssignmentPattern") { + const resolved = value === undefined ? yield* self.evaluateExpression(getNode(pattern, "right")) : value + yield* self.declarePattern(getNode(pattern, "left"), resolved, mutable, node) + return + } + + if (pattern.type === "ObjectPattern") { + if (value === null || typeof value !== "object" || Array.isArray(value) || isRuntimeReference(value)) { + throw new InterpreterRuntimeError( + "Object destructuring requires a data object value.", + pattern, + "InvalidDataValue", + ) + } + + const consumed = new Set() + for (const propertyValue of getArray(pattern, "properties")) { + const property = asNode(propertyValue, "properties") + + // Object rest: `{ a, ...others }` - gather the not-yet-consumed own keys. + if (property.type === "RestElement") { + const rest: SafeObject = Object.create(null) as SafeObject + for (const [key, item] of Object.entries(value as SafeObject)) { + if (!consumed.has(key) && !isBlockedMember(key)) rest[key] = item + } + yield* self.declarePattern(getNode(property, "argument"), rest, mutable, property) + continue + } + + if ( + property.type !== "Property" || + getBoolean(property, "computed") || + getString(property, "kind") !== "init" + ) { + throw new InterpreterRuntimeError("Only named object destructuring properties are supported.", property) + } + + const keyNode = getNode(property, "key") + const key = keyNode.type === "Identifier" ? getString(keyNode, "name") : String(keyNode.value) + if (isBlockedMember(key)) { + throw new InterpreterRuntimeError(`Property '${key}' is not available in CodeMode.`, keyNode) + } + consumed.add(key) + yield* self.declarePattern(getNode(property, "value"), (value as SafeObject)[key], mutable, property) + } + return + } + + if (pattern.type === "ArrayPattern") { + if (!Array.isArray(value)) { + throw new InterpreterRuntimeError("Array destructuring requires an array value.", pattern) + } + + for (const [index, item] of getArray(pattern, "elements").entries()) { + if (item === null) continue + const element = asNode(item, `elements[${index}]`) + // Array rest: `[head, ...tail]` - binds the remaining elements (must be last). + if (element.type === "RestElement") { + yield* self.declarePattern(getNode(element, "argument"), value.slice(index), mutable, element) + break + } + yield* self.declarePattern(element, value[index], mutable, pattern) + } + return + } + + throw new InterpreterRuntimeError(`Unsupported binding pattern '${pattern.type}'.`, pattern) + }) + } + + private evaluateExpression(node: AstNode): Effect.Effect { + switch (node.type) { + case "Literal": { + // A regex literal parses as a Literal node carrying { pattern, flags }; construct the + // sandbox regex from those (the host `value` instance is never exposed). + const regex = node.regex + if (isRecord(regex) && typeof regex.pattern === "string") { + return Effect.sync(() => + this.constructRegExp([regex.pattern, typeof regex.flags === "string" ? regex.flags : ""], node), + ) + } + return Effect.sync(() => boundedData(node.value, "Literal")) + } + case "Identifier": + return Effect.sync(() => this.getIdentifierValue(getString(node, "name"), node)) + case "BinaryExpression": + return this.evaluateBinaryExpression(node) + case "LogicalExpression": + return this.evaluateLogicalExpression(node) + case "UnaryExpression": + return this.evaluateUnaryExpression(node) + case "AssignmentExpression": + return this.evaluateAssignmentExpression(node) + case "CallExpression": + return this.evaluateCallExpression(node) + case "ArrowFunctionExpression": + case "FunctionExpression": + return Effect.sync(() => this.createFunction(node)) + case "MemberExpression": + return this.readMember(node) + case "ChainExpression": + return Effect.map(this.evaluateExpression(getNode(node, "expression")), (value) => + value === OptionalShortCircuit ? undefined : value, + ) + case "ObjectExpression": + return this.evaluateObjectExpression(node) + case "ArrayExpression": + return this.evaluateArrayExpression(node) + case "TemplateLiteral": + return this.evaluateTemplateLiteral(node) + case "ConditionalExpression": + return this.evaluateConditionalExpression(node) + case "UpdateExpression": + return this.evaluateUpdateExpression(node) + case "AwaitExpression": { + // `await` resolves a promise value; awaiting anything else is a passthrough no-op, + // matching real JS semantics for non-thenables. + const self = this + return Effect.flatMap(this.evaluateExpression(getNode(node, "argument")), (value) => + value instanceof SandboxPromise ? self.settlePromise(value, node) : Effect.succeed(value), + ) + } + case "NewExpression": + return this.evaluateNewExpression(node) + default: + throw unsupportedSyntax(node.type, node) + } + } + + private evaluateNewExpression(node: AstNode): Effect.Effect { + const callee = getNode(node, "callee") + if (callee.type !== "Identifier") { + throw unsupportedSyntax("NewExpression", node) + } + const name = getString(callee, "name") + const argNodes = getArray(node, "arguments") + const self = this + if (name === "Promise") { + throw new InterpreterRuntimeError( + "new Promise(...) is not supported in CodeMode; tool calls already return promises - call the tool and await the result.", + node, + "UnsupportedSyntax", + [supportedSyntaxMessage], + ) + } + if (errorConstructors.has(name)) { + return Effect.gen(function* () { + const arg = + argNodes.length > 0 ? yield* self.evaluateExpression(asNode(argNodes[0], "arguments[0]")) : undefined + return createErrorValue(name, arg === undefined ? "" : coerceToString(arg)) + }) + } + if (valueConstructors.has(name)) { + return Effect.gen(function* () { + const args = yield* self.evaluateCallArguments(argNodes) + switch (name) { + case "Date": + return self.constructDate(args) + case "RegExp": + return self.constructRegExp(args, node) + case "Map": + return self.constructMap(args[0], node) + case "Set": + return self.constructSet(args[0], node) + case "URL": + return self.constructURL(args, node) + default: + return self.constructURLSearchParams(args[0], node) + } + }) + } + throw unsupportedSyntax("NewExpression", node) + } + + private constructDate(args: Array): SandboxDate { + if (args.length === 0) return new SandboxDate(Date.now()) + if (args.length === 1) { + const arg = args[0] + if (arg instanceof SandboxDate) return new SandboxDate(arg.time) + if (typeof arg === "number") return new SandboxDate(new Date(arg).getTime()) + if (typeof arg === "string") return new SandboxDate(Date.parse(arg)) + return new SandboxDate(Number.NaN) + } + // new Date(year, month, day?, hours?, ...) - local-time component form. + const parts = args.map((arg) => coerceToNumber(arg)) + return new SandboxDate(new Date(...(parts as [number, number])).getTime()) + } + + private constructRegExp(args: Array, node: AstNode): SandboxRegExp { + const first = args[0] + const pattern = + first instanceof SandboxRegExp ? first.regex.source : first === undefined ? "" : coerceToString(first) + const flagsArg = args[1] + if (flagsArg !== undefined && typeof flagsArg !== "string") { + throw new InterpreterRuntimeError( + `RegExp flags must be a string of flag characters (e.g. "g", "gi"), not ${flagsArg === null ? "null" : typeof flagsArg}.`, + node, + ) + } + const flags = flagsArg ?? (first instanceof SandboxRegExp ? first.regex.flags : "") + try { + return new SandboxRegExp(pattern, flags) + } catch (error) { + // Say which part was rejected and how to fix it, instead of passing the engine + // message through bare. A flags failure names the flags; a pattern failure gets the + // escaping hint (the usual cause is an unescaped metacharacter in a built-up string). + const reason = regexFailureReason(error) + throw new InterpreterRuntimeError( + /flag/i.test(reason) + ? `new RegExp(...) received invalid flags ${JSON.stringify(flags)} (${reason}). Valid flags are d, g, i, m, s, u, v, and y.` + : `new RegExp(...) received ${JSON.stringify(pattern)}, which is not a valid regular expression pattern (${reason}). ${escapeRegexHint}`, + node, + ).as("SyntaxError") + } + } + + private constructMap(init: unknown, node: AstNode): SandboxMap { + const target = new SandboxMap() + if (init === undefined || init === null) return target + const entries = Array.isArray(init) + ? init + : init instanceof SandboxMap + ? Array.from(init.map.entries(), ([key, item]): Array => [key, item]) + : undefined + if (entries === undefined) { + throw new InterpreterRuntimeError( + "new Map(...) expects an array of [key, value] pairs, a Map, or no argument.", + node, + ) + } + for (const pair of entries) { + if (!Array.isArray(pair)) { + throw new InterpreterRuntimeError("new Map(...) expects [key, value] pairs.", node) + } + target.map.set(pair[0], pair[1]) + } + return target + } + + private constructSet(init: unknown, node: AstNode): SandboxSet { + const target = new SandboxSet() + if (init === undefined || init === null) return target + const items = Array.isArray(init) + ? init + : init instanceof SandboxSet + ? Array.from(init.set.values()) + : typeof init === "string" + ? Array.from(init) + : undefined + if (items === undefined) { + throw new InterpreterRuntimeError("new Set(...) expects an array, Set, string, or no argument.", node) + } + for (const item of items) target.set.add(item) + return target + } + + private constructURL(args: Array, node: AstNode): SandboxURL { + if (args.length === 0) { + throw new InterpreterRuntimeError("new URL(...) requires a URL string and an optional base URL.", node).as( + "TypeError", + ) + } + const input = urlArgument(args[0], "new URL input") + const base = args[1] === undefined ? undefined : urlArgument(args[1], "new URL base") + try { + return new SandboxURL(new URL(input, base)) + } catch { + throw new InterpreterRuntimeError( + `new URL(...) received an invalid URL${base === undefined ? "" : " or base URL"}.`, + node, + ).as("TypeError") + } + } + + private constructURLSearchParams(init: unknown, node: AstNode): SandboxURLSearchParams { + if (init === undefined) return new SandboxURLSearchParams(new URLSearchParams()) + if (init instanceof SandboxURLSearchParams) { + return new SandboxURLSearchParams(new URLSearchParams(init.params)) + } + if (typeof init === "string") return new SandboxURLSearchParams(new URLSearchParams(init)) + if (init === null || typeof init === "number" || typeof init === "boolean") { + return new SandboxURLSearchParams(new URLSearchParams(coerceToString(init))) + } + if (init instanceof SandboxMap) { + return this.constructURLSearchParams( + Array.from(init.map.entries(), ([key, value]) => [key, value]), + node, + ) + } + if (Array.isArray(init)) { + const entries = init.map((pair) => { + if (!Array.isArray(pair) || pair.length !== 2) { + throw new InterpreterRuntimeError( + "new URLSearchParams(...) expects an array of [name, value] pairs.", + node, + ).as("TypeError") + } + return [uriArgument(pair[0], "URLSearchParams name"), uriArgument(pair[1], "URLSearchParams value")] as [ + string, + string, + ] + }) + return new SandboxURLSearchParams(new URLSearchParams(entries)) + } + if (isSandboxValue(init)) return new SandboxURLSearchParams(new URLSearchParams()) + const data = boundedData(init, "new URLSearchParams input") + if (data === null || typeof data !== "object") { + throw new InterpreterRuntimeError( + "new URLSearchParams(...) expects a query string, data object, array of pairs, or URLSearchParams.", + node, + ).as("TypeError") + } + return new SandboxURLSearchParams( + new URLSearchParams(Object.fromEntries(Object.entries(data).map(([key, value]) => [key, coerceToString(value)]))), + ) + } + + private evaluateBinaryExpression(node: AstNode): Effect.Effect { + const operator = getString(node, "operator") + const self = this + return Effect.gen(function* () { + const lhs = yield* self.evaluateExpression(getNode(node, "left")) + const rhs = yield* self.evaluateExpression(getNode(node, "right")) + // Like `typeof`, `instanceof` observes any value without coercing it (a promise or + // function operand is a legitimate question, not an error), so it is handled before + // the data-only operand check. + if (operator === "instanceof") return instanceofValue(lhs, rhs, node) + return boundedData(self.applyBinaryOperator(operator, lhs, rhs, node), "Binary expression result") + }) + } + + /** + * Applies a binary operator to two already-evaluated operands with CodeMode's coercion + * semantics. Shared by binary expressions and compound assignment (`x op= y` must behave + * exactly like `x = x op y`, coercion included). + */ + private applyBinaryOperator(operator: string, lhs: unknown, rhs: unknown, node: AstNode): unknown { + if (containsOpaqueReference(lhs) || containsOpaqueReference(rhs)) { + throw new InterpreterRuntimeError("Binary operators require data values in CodeMode.", node, "InvalidDataValue") + } + // Data objects/arrays are null-prototype, so JS's ToPrimitive throws an opaque host + // "No default value" TypeError when an operator coerces them. Coerce to their JS string + // form first (as String(x) / template literals do) so operators behave like JavaScript. + // A Date follows its ToPrimitive hints: string for `+` (concatenation), its time value + // for arithmetic and ordering - so `end - start` and `a < b` work as in JS. + // Identity (=== / !==) and the right operand of `in` keep their raw object value. + const coerceOperand = (operand: unknown): unknown => { + if (operand instanceof SandboxDate) return operator === "+" ? coerceToString(operand) : operand.time + return operand !== null && typeof operand === "object" ? coerceToString(operand) : operand + } + const bothObjects = lhs !== null && typeof lhs === "object" && rhs !== null && typeof rhs === "object" + const l = coerceOperand(lhs) + const r = coerceOperand(rhs) + switch (operator) { + case "+": + return (l as string) + (r as string) + case "-": + return (l as number) - (r as number) + case "*": + return (l as number) * (r as number) + case "/": + return (l as number) / (r as number) + case "%": + return (l as number) % (r as number) + case "**": + return (l as number) ** (r as number) + // Two objects compare by identity in JS (no ToPrimitive); only object-vs-primitive coerces. + case "==": + return bothObjects ? lhs === rhs : l == r + case "===": + return lhs === rhs + case "!=": + return bothObjects ? lhs !== rhs : l != r + case "!==": + return lhs !== rhs + case "<": + return (l as string) < (r as string) + case "<=": + return (l as string) <= (r as string) + case ">": + return (l as string) > (r as string) + case ">=": + return (l as string) >= (r as string) + case "&": + return (l as number) & (r as number) + case "|": + return (l as number) | (r as number) + case "^": + return (l as number) ^ (r as number) + case "<<": + return (l as number) << (r as number) + case ">>": + return (l as number) >> (r as number) + case ">>>": + return (l as number) >>> (r as number) + case "in": + if (rhs === null || typeof rhs !== "object") { + throw new InterpreterRuntimeError("The 'in' operator requires a data object on the right-hand side.", node) + } + // Own properties only, so arrays don't leak the host Array.prototype (map/constructor/...). + return Object.hasOwn(rhs as object, coerceOperand(lhs) as PropertyKey) + default: + throw new InterpreterRuntimeError(`Unsupported binary operator '${operator}'.`, node) + } + } + + private evaluateLogicalExpression(node: AstNode): Effect.Effect { + const operator = getString(node, "operator") + return Effect.flatMap(this.evaluateExpression(getNode(node, "left")), (left) => { + if (operator === "&&") return left ? this.evaluateExpression(getNode(node, "right")) : Effect.succeed(left) + if (operator === "||") return left ? Effect.succeed(left) : this.evaluateExpression(getNode(node, "right")) + if (operator === "??") + return left !== null && left !== undefined + ? Effect.succeed(left) + : this.evaluateExpression(getNode(node, "right")) + throw new InterpreterRuntimeError(`Unsupported logical operator '${operator}'.`, node) + }) + } + + private evaluateUnaryExpression(node: AstNode): Effect.Effect { + const operator = getString(node, "operator") + const argument = getNode(node, "argument") + // `typeof undeclaredIdentifier` is `"undefined"` in JS (never a ReferenceError), so + // feature-detection guards like `typeof x !== "undefined"` don't crash. Short-circuit before + // evaluating the argument; a declared-but-TDZ binding still falls through to the normal throw. + if (operator === "typeof" && argument.type === "Identifier" && !this.resolveBinding(getString(argument, "name"))) { + return Effect.succeed("undefined") + } + return Effect.map(this.evaluateExpression(argument), (value) => { + // `typeof` and `!` never throw in JS - they observe any value (functions and runtime + // references included) without coercing it, so feature detection and negation work. + if (operator === "typeof") return typeofValue(value) + if (operator === "!") return !value + if (containsOpaqueReference(value)) { + throw new InterpreterRuntimeError("Unary operators require data values in CodeMode.", node, "InvalidDataValue") + } + // Numeric/bitwise unary operators ToPrimitive their operand; a Date yields its time value + // (`+date` is the epoch-ms idiom), other null-prototype data objects/arrays coerce to + // their JS string form first (see evaluateBinaryExpression). + const operand = + value instanceof SandboxDate + ? value.time + : value !== null && typeof value === "object" + ? coerceToString(value) + : value + let result: unknown + switch (operator) { + case "+": + result = +(operand as number) + break + case "-": + result = -(operand as number) + break + case "~": + result = ~(operand as number) + break + default: + throw new InterpreterRuntimeError(`Unsupported unary operator '${operator}'.`, node) + } + return boundedData(result, "Unary expression result") + }) + } + + private evaluateAssignmentExpression(node: AstNode): Effect.Effect { + const left = getNode(node, "left") + const operator = getString(node, "operator") + const self = this + return Effect.gen(function* () { + if (operator === "??=" || operator === "||=" || operator === "&&=") { + return yield* self.evaluateLogicalAssignment(node, left, operator) + } + const rightValue = yield* self.evaluateExpression(getNode(node, "right")) + if (left.type === "Identifier") { + const name = getString(left, "name") + if (operator === "=") return self.setIdentifierValue(name, rightValue, left) + const next = boundedData( + self.applyCompoundAssignment(operator, self.getIdentifierValue(name, left), rightValue, node), + "Assignment result", + ) + return self.setIdentifierValue(name, next, left) + } + if (left.type === "MemberExpression") { + if (operator === "=") return yield* self.writeMember(left, rightValue) + return yield* self.modifyMember(left, (current) => { + const next = boundedData( + self.applyCompoundAssignment(operator, current, rightValue, node), + "Assignment result", + ) + return Effect.succeed({ write: true, next, result: next }) + }) + } + throw new InterpreterRuntimeError("Assignment target must be an Identifier or MemberExpression.", left) + }) + } + + private evaluateLogicalAssignment( + node: AstNode, + left: AstNode, + operator: string, + ): Effect.Effect { + const self = this + const shouldAssign = (current: unknown): boolean => + operator === "??=" ? current === null || current === undefined : operator === "||=" ? !current : Boolean(current) + if (left.type === "Identifier") { + const name = getString(left, "name") + return Effect.gen(function* () { + const current = self.getIdentifierValue(name, left) + if (!shouldAssign(current)) return current + const rightValue = yield* self.evaluateExpression(getNode(node, "right")) + return self.setIdentifierValue(name, rightValue, left) + }) + } + if (left.type === "MemberExpression") { + // Resolve the member exactly once; evaluate the RHS only if we actually assign. + return self.modifyMember(left, (current) => + shouldAssign(current) + ? Effect.map(self.evaluateExpression(getNode(node, "right")), (rightValue) => ({ + write: true, + next: rightValue, + result: rightValue, + })) + : Effect.succeed({ write: false, next: current, result: current }), + ) + } + throw new InterpreterRuntimeError("Assignment target must be an Identifier or MemberExpression.", left) + } + + private evaluateUpdateExpression(node: AstNode): Effect.Effect { + const operator = getString(node, "operator") + const argument = getNode(node, "argument") + const prefix = getBoolean(node, "prefix") + + const increment = operator === "++" ? 1 : operator === "--" ? -1 : undefined + + if (increment === undefined) { + throw new InterpreterRuntimeError(`Unsupported update operator '${operator}'.`, node) + } + + if (argument.type === "Identifier") { + return Effect.sync(() => { + const name = getString(argument, "name") + const current = Number(this.getIdentifierValue(name, argument)) + const next = current + increment + this.setIdentifierValue(name, next, argument) + return prefix ? next : current + }) + } + + if (argument.type === "MemberExpression") { + return this.modifyMember(argument, (current) => { + const value = Number(current) + const next = value + increment + return Effect.succeed({ write: true, next, result: prefix ? next : value }) + }) + } + + throw new InterpreterRuntimeError("Update target must be an Identifier or MemberExpression.", argument) + } + + private evaluateCallExpression(node: AstNode): Effect.Effect { + const callee = getNode(node, "callee") + const argNodes = getArray(node, "arguments") + + const self = this + return Effect.gen(function* () { + const callable = yield* self.evaluateExpression(callee) + if (callable === OptionalShortCircuit) return OptionalShortCircuit + if ((callable === null || callable === undefined) && node.optional === true) return OptionalShortCircuit + + const args = yield* self.evaluateCallArguments(argNodes) + + if (callable instanceof ToolReference) { + if (callable.path.length === 0) throw new InterpreterRuntimeError("The tools root is not callable.", callee) + // An un-awaited tool call is a first-class promise value; the call itself starts now. + return yield* self.createToolCallPromise(callable.path, args) + } + if (callable instanceof PromiseMethodReference) { + return yield* self.invokePromiseMethod(callable, args, node) + } + if (callable instanceof CodeModeFunction) { + return yield* self.invokeFunction(callable, args) + } + if (callable instanceof IntrinsicReference) { + return yield* self.invokeIntrinsic(callable, args, node) + } + if (callable instanceof GlobalMethodReference) { + if (callable.namespace === "console") return self.invokeConsole(callable.name, args, node) + if (callable.namespace === "Object" && args[0] instanceof ToolReference) { + return self.invokeObjectMethodOnTools(callable.name, args[0] as ToolReference, node) + } + return boundedData(invokeGlobalMethod(callable, args, node), `${callable.namespace}.${callable.name} result`) + } + if (callable instanceof CoercionFunction) { + return boundedData(invokeCoercion(callable, args, node), `${callable.name} result`) + } + if (callable instanceof UriFunction) { + return invokeUriFunction(callable, args, node) + } + // `Error("msg")` without `new` constructs an error exactly like `new Error("msg")`, as in JS. + if (callable instanceof ErrorConstructorReference) { + return createErrorValue(callable.name, args[0] === undefined ? "" : coerceToString(args[0])) + } + throw new InterpreterRuntimeError("Only tools are callable in CodeMode.", callee) + }) + } + + // Object.* over a tool reference: `Object.keys(tools)` / `Object.keys(tools.ns)` enumerate + // namespace/tool names from the host tool tree - the discovery idiom a model reaches for + // first. Every other Object helper cannot produce data from a tool reference, so it fails + // with a pointer at the working idioms instead of the generic plain-objects-only message. + private invokeObjectMethodOnTools(name: string, ref: ToolReference, node: AstNode): unknown { + if (name === "keys") { + return boundedData(this.enumerableKeys(ref)!, "Object.keys result") + } + throw new InterpreterRuntimeError( + `Object.${name}(...) cannot read tool references: they are not plain data. Use Object.keys(tools) for names, or tools.$codemode.search({ query }) for signatures.`, + node, + "InvalidDataValue", + ) + } + + private invokeConsole(name: string, args: Array, node: AstNode): undefined { + if (!consoleMethods.has(name)) + throw new InterpreterRuntimeError(`console.${name} is not available in CodeMode.`, node) + this.logs.push(publicErrorMessage(this.formatConsoleMessage(name, args, node))) + return undefined + } + + private formatConsoleMessage(name: string, args: Array, node: AstNode): string { + if (name === "dir") return args.length === 0 ? "undefined" : this.formatConsoleArgument(args[0]) + if (name === "table") return this.formatConsoleTable(args[0], args[1], node) + const prefix = name === "warn" ? "[warn] " : name === "error" ? "[error] " : name === "debug" ? "[debug] " : "" + return `${prefix}${args.map((arg) => this.formatConsoleArgument(arg)).join(" ")}` + } + + // Console arguments format deeply and totally: values render as a debugger would show them + // rather than as boundary JSON - numbers keep NaN/Infinity (JSON would say null), sandbox + // values keep their friendly forms at ANY depth (ISO date, /regex/flags, Map(n) [...], + // Set(n) [...]), opaque runtime references become "[CodeMode reference]" markers in place, + // and plain objects/arrays render JSON-style. Formatting never fails the program: cycles + // render "[Circular]" and extreme depth degrades to "...". + private formatConsoleArgument(value: unknown): string { + if (value === undefined) return "undefined" + // A top-level string prints bare; nested strings are JSON-quoted (see formatConsoleValue). + if (typeof value === "string") return value + return this.formatConsoleValue(value, new Set(), 0) + } + + private formatConsoleValue(value: unknown, seen: Set, depth: number): string { + // Nested undefined renders as null, matching what JSON boundary output would show. + if (value === null || value === undefined) return "null" + if (typeof value === "string") return JSON.stringify(value) + // String(value) keeps NaN/Infinity/-Infinity readable; finite numbers match their JSON form. + if (typeof value === "number" || typeof value === "boolean") return String(value) + if (typeof value !== "object") return String(value) + if (value instanceof SandboxPromise) return "[Promise (await it to get its value)]" + if (value instanceof SandboxDate) return coerceToString(value) + if (value instanceof SandboxRegExp) return coerceToString(value) + if (value instanceof SandboxURL) return coerceToString(value) + if (value instanceof SandboxURLSearchParams) return coerceToString(value) + if (depth > MAX_CONSOLE_DEPTH) return "..." + if (seen.has(value)) return "[Circular]" + if (value instanceof SandboxMap) { + seen.add(value) + try { + const entries = Array.from(value.map.entries(), ([key, item]): Array => [key, item]) + return `Map(${value.map.size}) ${this.formatConsoleValue(entries, seen, depth + 1)}` + } finally { + seen.delete(value) + } + } + if (value instanceof SandboxSet) { + seen.add(value) + try { + return `Set(${value.set.size}) ${this.formatConsoleValue(Array.from(value.set.values()), seen, depth + 1)}` + } finally { + seen.delete(value) + } + } + if (isRuntimeReference(value)) return "[CodeMode reference]" + seen.add(value) + try { + if (Array.isArray(value)) { + return `[${value.map((item) => this.formatConsoleValue(item, seen, depth + 1)).join(",")}]` + } + return `{${Object.entries(value) + .map(([key, item]) => `${JSON.stringify(key)}:${this.formatConsoleValue(item, seen, depth + 1)}`) + .join(",")}}` + } finally { + seen.delete(value) + } + } + + private formatConsoleTable(value: unknown, columnsArgument: unknown, node: AstNode): string { + if (value === undefined) return "undefined" + // Sandbox values are legitimate table data (cells render their friendly forms); only + // truly opaque references (functions, tools, promises) collapse to the marker. + if (containsOpaqueReference(value)) return "[CodeMode reference]" + const data = boundedData(value, "console.table argument") + const columns = this.consoleTableColumns(columnsArgument, node) + const rows = this.consoleTableRows(data, columns) + const keys = columns ?? Array.from(new Set(rows.flatMap((row) => Object.keys(row.values)))) + const header = ["(index)", ...keys].join("\t") + return [ + header, + ...rows.map((row) => [row.index, ...keys.map((key) => this.formatConsoleTableCell(row.values[key]))].join("\t")), + ].join("\n") + } + + private consoleTableColumns(value: unknown, node: AstNode): ReadonlyArray | undefined { + if (value === undefined) return undefined + if (containsRuntimeReference(value)) return undefined + const columns = copyOut(copyIn(value, "console.table columns"), true) + return Array.isArray(columns) ? columns.map((column) => String(column)) : undefined + } + + private consoleTableRows( + data: unknown, + columns: ReadonlyArray | undefined, + ): Array<{ readonly index: string; readonly values: Record }> { + if (Array.isArray(data)) { + return data.map((item, index) => ({ index: String(index), values: this.consoleTableValues(item, columns) })) + } + if (data !== null && typeof data === "object" && !isSandboxValue(data)) { + return Object.entries(data).map(([index, item]) => ({ index, values: this.consoleTableValues(item, columns) })) + } + return [{ index: "0", values: { Value: data } }] + } + + private consoleTableValues(value: unknown, columns: ReadonlyArray | undefined): Record { + if (value !== null && typeof value === "object" && !Array.isArray(value) && !isSandboxValue(value)) { + const source = value as Record + if (columns !== undefined) return Object.fromEntries(columns.map((column) => [column, source[column]])) + return Object.fromEntries(Object.entries(source)) + } + return { Value: value } + } + + private formatConsoleTableCell(value: unknown): string { + if (value === undefined) return "" + if (typeof value === "string") return value + return this.formatConsoleValue(value, new Set(), 0) + } + + private evaluateCallArguments(argNodes: Array): Effect.Effect, unknown, R> { + const self = this + return Effect.gen(function* () { + const args: Array = [] + for (const [index, arg] of argNodes.entries()) { + const argNode = asNode(arg, `arguments[${index}]`) + if (argNode.type === "SpreadElement") { + const spread = yield* self.evaluateExpression(getNode(argNode, "argument")) + const items = spreadItems(spread) + if (items === undefined) + throw new InterpreterRuntimeError( + "Spread arguments require an array, string, Map, or Set in CodeMode.", + argNode, + ) + args.push(...items) + } else { + args.push(yield* self.evaluateExpression(argNode)) + } + } + return args + }) + } + + // Promise.* over ordinary runtime values. Combinators accept ANY array (or spreadable + // collection) mixing promise values and plain data - built inline, beforehand, via spread, + // whatever - because tool calls already run eagerly on their own fibers; the combinators + // only observe settlements. Joining is therefore sequential (no extra fibers) without + // costing parallelism, and the concurrency cap stays where the work is: the fork semaphore. + private invokePromiseMethod( + ref: PromiseMethodReference, + args: Array, + node: AstNode, + ): Effect.Effect { + const self = this + if (ref.name === "resolve") { + // Promise.resolve of a promise is that promise (JS flattens); anything else is a + // promise already fulfilled with the value. + const value = args[0] + return Effect.succeed( + value instanceof SandboxPromise ? value : new SandboxPromise(undefined, Effect.succeed(value)), + ) + } + if (ref.name === "reject") { + return Effect.sync(() => new SandboxPromise(undefined, Effect.fail(new ProgramThrow(args[0])))) + } + + const items = Array.isArray(args[0]) ? args[0] : spreadItems(args[0]) + if (items === undefined) { + throw new InterpreterRuntimeError( + `Promise.${ref.name} expects an array of promises or plain values (e.g. Promise.${ref.name}(items.map((item) => tools.ns.tool(item)))).`, + node, + ) + } + + switch (ref.name) { + case "all": { + // Mark every promise element observed up-front (Promise.all handles all of its + // members' failures, as in JS), then join in index order; the first failure rejects + // the whole call while unrelated in-flight members keep running. + const settles = items.map((item) => + item instanceof SandboxPromise ? this.settlePromise(item, node) : Effect.succeed(item), + ) + return Effect.gen(function* () { + const values: Array = [] + for (const settle of settles) values.push(yield* settle) + return values + }) + } + case "allSettled": { + const observations = items.map((item) => + item instanceof SandboxPromise + ? Effect.map(this.observePromise(item), (exit) => ({ promise: item as SandboxPromise | undefined, exit })) + : Effect.succeed({ promise: undefined as SandboxPromise | undefined, exit: Exit.succeed(item as unknown) }), + ) + return Effect.gen(function* () { + const outcomes: Array = [] + for (const observation of observations) { + const { exit, promise } = yield* observation + if (Exit.isSuccess(exit)) { + outcomes.push( + Object.assign(Object.create(null) as SafeObject, { status: "fulfilled", value: exit.value }), + ) + continue + } + const raceInterrupted = promise?.interrupted === true && Cause.hasInterruptsOnly(exit.cause) + if (Cause.hasInterruptsOnly(exit.cause) && !raceInterrupted) { + // Execution teardown (timeout/host interruption), not a program-level rejection. + return yield* Effect.failCause(exit.cause) + } + const thrown = raceInterrupted + ? new InterpreterRuntimeError( + "This tool call was interrupted because another value settled a Promise.race first.", + node, + ) + : Cause.squash(exit.cause) + outcomes.push( + Object.assign(Object.create(null) as SafeObject, { + status: "rejected", + reason: caughtErrorValue(thrown), + }), + ) + } + return outcomes + }) + } + case "race": { + if (items.length === 0) { + throw new InterpreterRuntimeError( + "Promise.race([]) would never settle; provide at least one promise or value.", + node, + ) + } + const observations = items.map((item, index) => + item instanceof SandboxPromise + ? Effect.map(this.observePromise(item), (exit) => ({ index, exit })) + : Effect.succeed({ index, exit: Exit.succeed(item as unknown) }), + ) + return Effect.gen(function* () { + // First settlement (fulfilled OR rejected) wins; the observations never fail, so + // racing them yields exactly that. Losing in-flight calls are then interrupted. + const winner = yield* Effect.raceAll(observations) + for (const [index, item] of items.entries()) { + if (index === winner.index || !(item instanceof SandboxPromise) || item.fiber === undefined) continue + item.interrupted = true + yield* Fiber.interrupt(item.fiber) + } + const winningItem = items[winner.index] + return yield* self.unwrapPromiseExit( + winningItem instanceof SandboxPromise ? winningItem : undefined, + winner.exit, + node, + ) + }) + } + } + } + + private invokeFunction(fn: CodeModeFunction, args: Array): Effect.Effect { + const self = this + return Effect.suspend(() => { + const savedScopes = self.scopes + self.scopes = [...fn.capturedScopes, new Map()] + const run = Effect.gen(function* () { + // Seed every parameter name into the scope as a TDZ slot first, so a default that + // references another parameter resolves to that (uninitialized) param rather than + // silently falling through to an outer binding of the same name - matching JS. + const paramScope = self.currentScope() + for (const parameter of fn.parameters) { + for (const name of collectPatternNames(parameter)) { + paramScope.set(name, { mutable: true, value: undefined, initialized: false }) + } + } + for (const [index, parameter] of fn.parameters.entries()) { + if (parameter.type === "RestElement") { + yield* self.declarePattern(getNode(parameter, "argument"), args.slice(index), true, parameter) + break + } + yield* self.declarePattern(parameter, args[index], true, parameter) + } + + if (fn.body.type === "BlockStatement") { + const result = yield* self.evaluateStatement(fn.body) + return result.kind === "return" || result.kind === "value" ? result.value : undefined + } + + return yield* self.evaluateExpression(fn.body) + }) + return run.pipe( + Effect.ensuring( + Effect.sync(() => { + self.scopes = savedScopes + }), + ), + ) + }) + } + + private invokeIntrinsic( + ref: IntrinsicReference, + args: Array, + node: AstNode, + ): Effect.Effect { + if (typeof ref.receiver === "string") { + if ( + (ref.name === "replace" || ref.name === "replaceAll") && + (args[1] instanceof CodeModeFunction || args[1] instanceof CoercionFunction || args[1] instanceof UriFunction) + ) { + return this.invokeStringReplacer(ref.receiver, ref.name, args, node) + } + return Effect.succeed(invokeStringMethod(ref.receiver, ref.name, args, node)) + } + if (typeof ref.receiver === "number") { + return Effect.succeed(invokeNumberMethod(ref.receiver, ref.name, args, node)) + } + if (Array.isArray(ref.receiver)) { + return this.invokeArrayMethod(ref.receiver, ref.name, args, node) + } + if (ref.receiver instanceof SandboxDate) { + return Effect.succeed(invokeDateMethod(ref.receiver, ref.name, node)) + } + if (ref.receiver instanceof SandboxRegExp) { + return Effect.succeed(invokeRegExpMethod(ref.receiver, ref.name, args, node)) + } + if (ref.receiver instanceof SandboxMap) { + return this.invokeMapMethod(ref.receiver, ref.name, args, node) + } + if (ref.receiver instanceof SandboxSet) { + return this.invokeSetMethod(ref.receiver, ref.name, args, node) + } + if (ref.receiver instanceof SandboxURL) { + return Effect.succeed(invokeURLMethod(ref.receiver, ref.name, node)) + } + if (ref.receiver instanceof SandboxURLSearchParams) { + return this.invokeURLSearchParamsMethod(ref.receiver, ref.name, args, node) + } + throw new InterpreterRuntimeError(`Method '${ref.name}' is not available in CodeMode.`, node) + } + + private invokeStringReplacer( + value: string, + name: "replace" | "replaceAll", + args: Array, + node: AstNode, + ): Effect.Effect { + const apply = this.applyCollectionCallback(args[1], `String.${name}`, node) + const matches: Array<{ readonly match: string; readonly offset: number; readonly args: Array }> = [] + const collect = (...callbackArgs: Array): string => { + const match = callbackArgs[0] + const groups = callbackArgs[callbackArgs.length - 1] + const hasGroups = groups !== null && typeof groups === "object" + const offset = callbackArgs[callbackArgs.length - (hasGroups ? 3 : 2)] + if (typeof match !== "string" || typeof offset !== "number") { + throw new InterpreterRuntimeError(`String.${name} produced an invalid replacement match.`, node) + } + if (hasGroups) { + const safeGroups: SafeObject = Object.create(null) as SafeObject + for (const [key, group] of Object.entries(groups)) { + if (!isBlockedMember(key)) safeGroups[key] = group + } + callbackArgs[callbackArgs.length - 1] = safeGroups + } + matches.push({ match, offset, args: callbackArgs }) + return match + } + + const pattern = args[0] + if (pattern instanceof SandboxRegExp) { + if (name === "replaceAll" && !pattern.regex.global) { + throw new InterpreterRuntimeError( + `String.replaceAll requires a regular expression with the global (g) flag: write /${pattern.regex.source}/${pattern.regex.flags}g, or use String.replace to replace only the first match.`, + node, + ) + } + if (name === "replace") value.replace(pattern.regex, collect) + else value.replaceAll(pattern.regex, collect) + } else { + if (typeof pattern !== "string") { + throw new InterpreterRuntimeError(`String.${name} expects argument 1 to be a string.`, node) + } + if (name === "replace") value.replace(pattern, collect) + else value.replaceAll(pattern, collect) + } + + return Effect.gen(function* () { + const output: Array = [] + let end = 0 + for (const match of matches) { + output.push( + value.slice(end, match.offset), + coerceToString(boundedData(yield* apply(match.args), `String.${name} replacer result`)), + ) + end = match.offset + match.match.length + } + output.push(value.slice(end)) + return boundedData(output.join(""), `String.${name} result`) + }) + } + + // Runs a collection callback accepting a user function or supported builtin callable, + // mirroring the array-method callback contract. + private applyCollectionCallback( + callback: unknown, + name: string, + node: AstNode, + ): (args: Array) => Effect.Effect { + if ( + !(callback instanceof CodeModeFunction) && + !(callback instanceof CoercionFunction) && + !(callback instanceof UriFunction) + ) { + throw new InterpreterRuntimeError(`${name} expects a function callback.`, node) + } + return (callbackArgs) => + callback instanceof CoercionFunction + ? Effect.succeed(invokeCoercion(callback, callbackArgs, node)) + : callback instanceof UriFunction + ? Effect.succeed(invokeUriFunction(callback, callbackArgs, node)) + : this.invokeFunction(callback, callbackArgs) + } + + private invokeMapMethod( + target: SandboxMap, + name: string, + args: Array, + node: AstNode, + ): Effect.Effect { + switch (name) { + case "get": + return Effect.succeed(target.map.get(args[0])) + case "has": + return Effect.succeed(target.map.has(args[0])) + case "set": + return Effect.sync(() => { + target.map.set(args[0], args[1]) + return target + }) + case "delete": + return Effect.sync(() => target.map.delete(args[0])) + case "clear": + return Effect.sync(() => { + target.map.clear() + return undefined + }) + case "keys": + return Effect.sync(() => Array.from(target.map.keys())) + case "values": + return Effect.sync(() => Array.from(target.map.values())) + case "entries": + return Effect.sync(() => Array.from(target.map.entries(), ([key, item]): Array => [key, item])) + case "forEach": { + const apply = this.applyCollectionCallback(args[0], "Map.forEach", node) + return Effect.gen(function* () { + // Snapshot iteration, matching the array-method callback contract. + for (const [key, item] of Array.from(target.map.entries())) yield* apply([item, key, target]) + return undefined + }) + } + default: + throw new InterpreterRuntimeError(`Map method '${name}' is not available in CodeMode.`, node) + } + } + + private invokeSetMethod( + target: SandboxSet, + name: string, + args: Array, + node: AstNode, + ): Effect.Effect { + switch (name) { + case "has": + return Effect.succeed(target.set.has(args[0])) + case "add": + return Effect.sync(() => { + target.set.add(args[0]) + return target + }) + case "delete": + return Effect.sync(() => target.set.delete(args[0])) + case "clear": + return Effect.sync(() => { + target.set.clear() + return undefined + }) + case "keys": + case "values": + return Effect.sync(() => Array.from(target.set.values())) + case "entries": + return Effect.sync(() => Array.from(target.set.values(), (item): Array => [item, item])) + case "forEach": { + const apply = this.applyCollectionCallback(args[0], "Set.forEach", node) + return Effect.gen(function* () { + for (const item of Array.from(target.set.values())) yield* apply([item, item, target]) + return undefined + }) + } + default: + throw new InterpreterRuntimeError(`Set method '${name}' is not available in CodeMode.`, node) + } + } + + private invokeURLSearchParamsMethod( + target: SandboxURLSearchParams, + name: string, + args: Array, + node: AstNode, + ): Effect.Effect { + const arg = (index: number): string => uriArgument(args[index], `URLSearchParams.${name} argument ${index + 1}`) + const requireArgs = (count: number): void => { + if (args.length < count) { + throw new InterpreterRuntimeError( + `URLSearchParams.${name} requires ${count} argument${count === 1 ? "" : "s"}.`, + node, + ).as("TypeError") + } + } + switch (name) { + case "append": { + requireArgs(2) + return Effect.sync(() => { + target.params.append(arg(0), arg(1)) + return undefined + }) + } + case "delete": { + requireArgs(1) + return Effect.sync(() => { + if (args[1] !== undefined) target.params.delete(arg(0), arg(1)) + else target.params.delete(arg(0)) + return undefined + }) + } + case "get": + requireArgs(1) + return Effect.sync(() => target.params.get(arg(0))) + case "getAll": + requireArgs(1) + return Effect.sync(() => target.params.getAll(arg(0))) + case "has": + requireArgs(1) + return Effect.sync(() => + args[1] !== undefined ? target.params.has(arg(0), arg(1)) : target.params.has(arg(0)), + ) + case "set": { + requireArgs(2) + return Effect.sync(() => { + target.params.set(arg(0), arg(1)) + return undefined + }) + } + case "sort": + return Effect.sync(() => { + target.params.sort() + return undefined + }) + case "keys": + return Effect.sync(() => Array.from(target.params.keys())) + case "values": + return Effect.sync(() => Array.from(target.params.values())) + case "entries": + return Effect.sync(() => Array.from(target.params.entries(), ([key, value]): Array => [key, value])) + case "toString": + return Effect.sync(() => target.params.toString()) + case "forEach": { + requireArgs(1) + const apply = this.applyCollectionCallback(args[0], "URLSearchParams.forEach", node) + return Effect.gen(function* () { + for (const [key, value] of Array.from(target.params.entries())) yield* apply([value, key, target]) + return undefined + }) + } + default: + throw new InterpreterRuntimeError(`URLSearchParams method '${name}' is not available in CodeMode.`, node) + } + } + + private invokeArrayMethod( + target: Array, + name: string, + args: Array, + node: AstNode, + ): Effect.Effect { + const optNumber = (value: unknown, label: string): number | undefined => { + if (value === undefined) return undefined + if (typeof value !== "number") + throw new InterpreterRuntimeError(`Array.${name} expects ${label} to be a number.`, node) + return value + } + switch (name) { + case "join": { + if (args.length > 1 || (args.length === 1 && typeof args[0] !== "string")) { + throw new InterpreterRuntimeError("Array.join expects zero arguments or one string separator.", node) + } + const input = boundedData(target, "Array.join input") as Array + return Effect.succeed( + input.map((item) => coerceToString(item ?? "")).join(args.length === 0 ? "," : (args[0] as string)), + ) + } + case "includes": + if (args.length === 0 || args.length > 2) + throw new InterpreterRuntimeError("Array.includes expects a value and optional start index.", node) + return Effect.succeed(target.includes(args[0], optNumber(args[1], "start index"))) + case "indexOf": + return Effect.succeed(target.indexOf(args[0], optNumber(args[1], "start index"))) + case "lastIndexOf": + return Effect.succeed( + args[1] === undefined + ? target.lastIndexOf(args[0]) + : target.lastIndexOf(args[0], optNumber(args[1], "start index")), + ) + case "at": + return Effect.succeed(target.at(optNumber(args[0], "index") ?? 0)) + case "slice": + return Effect.succeed(target.slice(optNumber(args[0], "start"), optNumber(args[1], "end"))) + case "concat": + return Effect.succeed(target.concat(...args)) + case "flat": + return Effect.succeed(target.flat(optNumber(args[0], "depth") ?? 1)) + case "reverse": + return Effect.succeed([...target].reverse()) + case "sort": + case "toSorted": + return this.sortArray(target, args[0], node) + case "toReversed": + return Effect.succeed([...target].reverse()) + case "with": { + const index = optNumber(args[0], "index") ?? 0 + const resolved = index < 0 ? target.length + index : index + if (resolved < 0 || resolved >= target.length) { + throw new InterpreterRuntimeError("Array.with index is out of range.", node) + } + const copied = [...target] + copied[resolved] = args[1] + return Effect.succeed(copied) + } + case "push": { + // Validate before mutating (so no rollback is needed): inserting a container into + // itself would create a cycle no later walk could survive. + for (const item of args) this.rejectCircularInsertion(target, item, "Array.push result", node) + target.push(...args) + return Effect.succeed(target.length) + } + case "unshift": { + for (const item of args) this.rejectCircularInsertion(target, item, "Array.unshift result", node) + target.unshift(...args) + return Effect.succeed(target.length) + } + case "pop": + return Effect.succeed(target.pop()) + case "shift": + return Effect.succeed(target.shift()) + case "splice": { + // Mutates in place and returns the removed elements, exactly like JS: one argument + // removes to the end, an undefined delete count removes nothing. + if (args.length === 0) return Effect.succeed(target.splice(0, 0)) + const start = optNumber(args[0], "start") ?? 0 + if (args.length === 1) return Effect.succeed(target.splice(start)) + const deleteCount = optNumber(args[1], "delete count") ?? 0 + const inserted = args.slice(2) + for (const item of inserted) this.rejectCircularInsertion(target, item, "Array.splice result", node) + return Effect.succeed(target.splice(start, deleteCount, ...inserted)) + } + case "fill": { + this.rejectCircularInsertion(target, args[0], "Array.fill result", node) + return Effect.succeed(target.fill(args[0], optNumber(args[1], "start"), optNumber(args[2], "end"))) + } + case "copyWithin": + return Effect.succeed( + target.copyWithin( + optNumber(args[0], "target index") ?? 0, + optNumber(args[1], "start") ?? 0, + optNumber(args[2], "end"), + ), + ) + // keys/values/entries return arrays (not iterators), matching the Map/Set convention; + // they work with for...of and spread either way. + case "keys": + return Effect.succeed(Array.from(target.keys())) + case "values": + return Effect.succeed([...target]) + case "entries": + return Effect.succeed(Array.from(target.entries(), ([index, item]): Array => [index, item])) + } + + const callback = args[0] + if ( + !(callback instanceof CodeModeFunction) && + !(callback instanceof CoercionFunction) && + !(callback instanceof UriFunction) + ) { + throw new InterpreterRuntimeError(`Array.${name} expects a function callback.`, node) + } + const self = this + // Accept a user function or supported builtin callable, so idioms such as + // `filter(Boolean)`, `map(String)`, and `map(encodeURIComponent)` work as in JS. Builtins + // are synchronous; only CodeModeFunctions can await tool calls. + const apply = (callbackArgs: Array): Effect.Effect => + callback instanceof CoercionFunction + ? Effect.succeed(invokeCoercion(callback, callbackArgs, node)) + : callback instanceof UriFunction + ? Effect.succeed(invokeUriFunction(callback, callbackArgs, node)) + : self.invokeFunction(callback, callbackArgs) + return Effect.gen(function* () { + // Iterate a snapshot taken at call time so a callback that mutates the array can't + // self-extend the loop - matching JS, where elements appended during iteration are not visited. + const items = target.slice() + switch (name) { + case "map": { + const values: Array = [] + for (const [index, item] of items.entries()) values.push(yield* apply([item, index, items])) + return values + } + case "flatMap": { + const values: Array = [] + for (const [index, item] of items.entries()) { + const mapped = yield* apply([item, index, items]) + if (Array.isArray(mapped)) values.push(...mapped) + else values.push(mapped) + } + return values + } + case "filter": { + const values: Array = [] + for (const [index, item] of items.entries()) { + if (yield* apply([item, index, items])) values.push(item) + } + return values + } + case "find": + for (const [index, item] of items.entries()) { + if (yield* apply([item, index, items])) return item + } + return undefined + case "findIndex": + for (const [index, item] of items.entries()) { + if (yield* apply([item, index, items])) return index + } + return -1 + case "some": + for (const [index, item] of items.entries()) { + if (yield* apply([item, index, items])) return true + } + return false + case "every": + for (const [index, item] of items.entries()) { + if (!(yield* apply([item, index, items]))) return false + } + return true + case "forEach": + for (const [index, item] of items.entries()) yield* apply([item, index, items]) + return undefined + case "reduce": { + let accumulator: unknown + let start: number + if (args.length >= 2) { + accumulator = args[1] + start = 0 + } else { + if (items.length === 0) + throw new InterpreterRuntimeError("Array.reduce of an empty array with no initial value.", node) + accumulator = items[0] + start = 1 + } + for (let index = start; index < items.length; index += 1) { + accumulator = yield* apply([accumulator, items[index], index, items]) + } + return accumulator + } + case "reduceRight": { + let accumulator: unknown + let start: number + if (args.length >= 2) { + accumulator = args[1] + start = items.length - 1 + } else { + if (items.length === 0) + throw new InterpreterRuntimeError("Array.reduceRight of an empty array with no initial value.", node) + accumulator = items[items.length - 1] + start = items.length - 2 + } + for (let index = start; index >= 0; index -= 1) { + accumulator = yield* apply([accumulator, items[index], index, items]) + } + return accumulator + } + case "findLast": + for (let index = items.length - 1; index >= 0; index -= 1) { + if (yield* apply([items[index], index, items])) return items[index] + } + return undefined + case "findLastIndex": + for (let index = items.length - 1; index >= 0; index -= 1) { + if (yield* apply([items[index], index, items])) return index + } + return -1 + } + throw new InterpreterRuntimeError(`Array method '${name}' is not available in CodeMode.`, node) + }) + } + + private sortArray( + target: Array, + comparator: unknown, + node: AstNode, + ): Effect.Effect, unknown, R> { + if (comparator !== undefined && !(comparator instanceof CodeModeFunction)) { + throw new InterpreterRuntimeError("Array.sort expects an arrow function comparator.", node) + } + if (!(comparator instanceof CodeModeFunction)) { + return Effect.sync(() => + [...target].sort((a, b) => { + const left = coerceToString(a) + const right = coerceToString(b) + return left < right ? -1 : left > right ? 1 : 0 + }), + ) + } + const self = this + const mergeSort = (items: Array): Effect.Effect, unknown, R> => { + if (items.length <= 1) return Effect.succeed(items) + const midpoint = Math.floor(items.length / 2) + return Effect.gen(function* () { + const left = yield* mergeSort(items.slice(0, midpoint)) + const right = yield* mergeSort(items.slice(midpoint)) + const merged: Array = [] + let leftIndex = 0 + let rightIndex = 0 + while (leftIndex < left.length && rightIndex < right.length) { + // Coerce the comparator's result like JS ToNumber (data objects -> NaN, never a host + // crash) and treat NaN as 0 - the spec's "no consistent order" -> keep the left element. + const order = coerceToNumber(yield* self.invokeFunction(comparator, [left[leftIndex], right[rightIndex]])) + if (Number.isNaN(order) || order <= 0) merged.push(left[leftIndex++]) + else merged.push(right[rightIndex++]) + } + return [...merged, ...left.slice(leftIndex), ...right.slice(rightIndex)] + }) + } + // Per spec, undefined elements sort to the end and the comparator is never called on them. + const defined = target.filter((item) => item !== undefined) + const undefinedCount = target.length - defined.length + return Effect.map(mergeSort(defined), (items) => [...items, ...Array(undefinedCount).fill(undefined)]) + } + + private evaluateObjectExpression(node: AstNode): Effect.Effect, unknown, R> { + const objectValue: Record = Object.create(null) as Record + const properties = getArray(node, "properties") + const self = this + return Effect.gen(function* () { + for (const propertyValue of properties) { + const property = asNode(propertyValue, "properties") + + if (property.type === "SpreadElement") { + const spread = yield* self.evaluateExpression(getNode(property, "argument")) + // JS treats `{ ...null }` / `{ ...undefined }` as a no-op, so the common + // `{ ...maybeOpts, override }` merge works when the operand is absent. Sandbox values + // have no own enumerable properties in JS, so they are no-ops too. + if (spread === null || spread === undefined || isSandboxValue(spread)) continue + if (typeof spread !== "object" || Array.isArray(spread) || isRuntimeReference(spread)) { + throw new InterpreterRuntimeError( + "Object spread requires a data object in CodeMode.", + property, + "InvalidDataValue", + ) + } + for (const [key, value] of Object.entries(spread)) { + if (isBlockedMember(key)) + throw new InterpreterRuntimeError(`Property '${key}' is not available in CodeMode.`, property) + objectValue[key] = value + } + continue + } + + if (property.type !== "Property") { + throw new InterpreterRuntimeError("Only standard object properties are supported.", property) + } + + if (getString(property, "kind") !== "init") { + throw new InterpreterRuntimeError("Only init object properties are supported.", property) + } + + const keyNode = getNode(property, "key") + const valueNode = getNode(property, "value") + const computed = getBoolean(property, "computed") + + let key: PropertyKey + + if (computed) { + key = self.toPropertyKey(yield* self.evaluateExpression(keyNode), keyNode) + } else if (keyNode.type === "Identifier") { + key = getString(keyNode, "name") + } else if (keyNode.type === "Literal") { + key = self.toPropertyKey(keyNode.value, keyNode) + } else { + throw new InterpreterRuntimeError("Unsupported object property key shape.", keyNode) + } + + if (isBlockedMember(String(key))) { + throw new InterpreterRuntimeError(`Property '${String(key)}' is not available in CodeMode.`, keyNode) + } + objectValue[String(key)] = yield* self.evaluateExpression(valueNode) + } + + return objectValue + }) + } + + private evaluateArrayExpression(node: AstNode): Effect.Effect, unknown, R> { + const elements = getArray(node, "elements") + const values: Array = [] + + const self = this + return Effect.gen(function* () { + for (const elementValue of elements) { + if (elementValue === null) { + values.push(undefined) + continue + } + const element = asNode(elementValue, "elements") + if (element.type === "SpreadElement") { + const spread = yield* self.evaluateExpression(getNode(element, "argument")) + const items = spreadItems(spread) + if (items === undefined) + throw new InterpreterRuntimeError( + "Array spread requires an array, string, Map, or Set in CodeMode.", + element, + ) + values.push(...items) + } else { + values.push(yield* self.evaluateExpression(element)) + } + } + return values + }) + } + + private evaluateTemplateLiteral(node: AstNode): Effect.Effect { + const quasis = getArray(node, "quasis") + const expressions = getArray(node, "expressions") + + let output = "" + + const self = this + return Effect.gen(function* () { + for (let index = 0; index < quasis.length; index += 1) { + const quasi = asNode(quasis[index], "quasis") + const rawValue = quasi.value + + if (!isRecord(rawValue) || typeof rawValue.cooked !== "string") { + throw new InterpreterRuntimeError("Invalid template literal quasi.", quasi) + } + + output += rawValue.cooked + + if (index < expressions.length) { + const raw = yield* self.evaluateExpression(asNode(expressions[index], "expressions")) + // The preserving checkpoint keeps sandbox values intact, so coerceToString renders + // them directly (ISO date, /regex/ literal form) instead of a JSON-serialized husk. + output += coerceToString(boundedData(raw, "Template interpolation")) + } + } + + return output + }) + } + + private evaluateConditionalExpression(node: AstNode): Effect.Effect { + return Effect.flatMap(this.evaluateExpression(getNode(node, "test")), (test) => + this.evaluateExpression(getNode(node, test ? "consequent" : "alternate")), + ) + } + + private applyCompoundAssignment(operator: string, current: unknown, incoming: unknown, node: AstNode): unknown { + // `x op= y` is `x = x op y`: dispatch through the shared binary operator implementation + // so compound assignment inherits the same coercion semantics (Dates, data objects, ...). + // Only the arithmetic/bitwise operators are compoundable; logical assignments (&&=/||=/??=) + // short-circuit and are handled by evaluateLogicalAssignment before reaching here. + if (!compoundOperators.has(operator)) { + throw new InterpreterRuntimeError(`Unsupported assignment operator '${operator}'.`, node) + } + return this.applyBinaryOperator(operator.slice(0, -1), current, incoming, node) + } + + private getMemberReference( + node: AstNode, + ): Effect.Effect< + | MemberReference + | ToolReference + | PromiseMethodReference + | IntrinsicReference + | GlobalMethodReference + | ComputedValue + | typeof OptionalShortCircuit + | undefined, + unknown, + R + > { + const objectNode = getNode(node, "object") + const propertyNode = getNode(node, "property") + const computed = getBoolean(node, "computed") + const optional = node.optional === true + const self = this + return Effect.gen(function* () { + const objectValue = yield* self.evaluateExpression(objectNode) + if (objectValue === OptionalShortCircuit) return OptionalShortCircuit + if ((objectValue === null || objectValue === undefined) && optional) return OptionalShortCircuit + + const key = computed + ? self.toPropertyKey(yield* self.evaluateExpression(propertyNode), propertyNode) + : propertyNode.type === "Identifier" + ? getString(propertyNode, "name") + : self.toPropertyKey(yield* self.evaluateExpression(propertyNode), propertyNode) + + if (objectValue instanceof ToolReference) { + if (typeof key !== "string" || isBlockedMember(key)) { + throw new InterpreterRuntimeError("Tool paths must use safe string property names.", propertyNode) + } + return new ToolReference([...objectValue.path, key]) + } + + if (objectValue instanceof PromiseNamespace) { + if (typeof key === "string" && promiseStatics.has(key as PromiseMethodName)) { + return new PromiseMethodReference(key as PromiseMethodName) + } + throw new InterpreterRuntimeError( + `Promise.${String(key)} is not available in CodeMode. Available: Promise.all, Promise.allSettled, Promise.race, Promise.resolve, and Promise.reject; consume promises with await.`, + propertyNode, + ) + } + + if (objectValue instanceof GlobalNamespace) { + if (typeof key !== "string" || isBlockedMember(key)) { + throw new InterpreterRuntimeError( + `${objectValue.name}.${String(key)} is not available in CodeMode.`, + propertyNode, + ) + } + if (objectValue.name === "Math" && mathConstants.has(key)) { + return new ComputedValue((Math as unknown as Record)[key]) + } + return new GlobalMethodReference(objectValue.name, key) + } + + if (typeof objectValue === "string") { + if (key === "length") return new ComputedValue(objectValue.length) + if (typeof key === "number") return new ComputedValue(objectValue[key]) + if (typeof key === "string" && /^\d+$/.test(key)) return new ComputedValue(objectValue[Number(key)]) + if (typeof key === "string" && stringMethods.has(key)) return new IntrinsicReference(objectValue, key) + // Unknown property on a string reads as `undefined`, matching JS (`"x".foo === undefined`), + // instead of throwing - so defensive access like `result?.login ?? result` on a JSON-string + // tool result doesn't crash. (Optional chaining only guards null/undefined receivers, so a + // real string still reaches here.) Only the method allowlist above yields callables. + return new ComputedValue(undefined) + } + + if (typeof objectValue === "number") { + if (typeof key === "string" && numberMethods.has(key)) return new IntrinsicReference(objectValue, key) + // Unknown property on a number reads as `undefined`, matching JS, rather than throwing. + return new ComputedValue(undefined) + } + + // Number / String expose a small allowlist of statics; everything else stays opaque. + if (objectValue instanceof CoercionFunction && typeof key === "string" && !isBlockedMember(key)) { + if (objectValue.name === "Number" && numberConstants.has(key)) { + return new ComputedValue((Number as unknown as Record)[key]) + } + if (objectValue.name === "Number" && numberStatics.has(key)) return new GlobalMethodReference("Number", key) + if (objectValue.name === "String" && stringStatics.has(key)) return new GlobalMethodReference("String", key) + } + + // Sandbox value types expose their method/property allowlists; any other key reads as + // `undefined`, consistent with unknown-property reads on strings/numbers/arrays. + if (objectValue instanceof SandboxDate) { + if (typeof key === "string" && dateMethods.has(key)) return new IntrinsicReference(objectValue, key) + return new ComputedValue(undefined) + } + if (objectValue instanceof SandboxRegExp) { + if (typeof key === "string" && regexpProperties.has(key)) { + return new ComputedValue((objectValue.regex as unknown as Record)[key]) + } + if (typeof key === "string" && regexpMethods.has(key)) return new IntrinsicReference(objectValue, key) + return new ComputedValue(undefined) + } + if (objectValue instanceof SandboxMap) { + if (key === "size") return new ComputedValue(objectValue.map.size) + if (typeof key === "string" && mapMethods.has(key)) return new IntrinsicReference(objectValue, key) + return new ComputedValue(undefined) + } + if (objectValue instanceof SandboxSet) { + if (key === "size") return new ComputedValue(objectValue.set.size) + if (typeof key === "string" && setMethods.has(key)) return new IntrinsicReference(objectValue, key) + return new ComputedValue(undefined) + } + if (objectValue instanceof SandboxURL) { + if (key === "searchParams") { + return new ComputedValue(objectValue.searchParams) + } + if (typeof key === "string" && urlMethods.has(key)) return new IntrinsicReference(objectValue, key) + if (typeof key === "string" && urlProperties.has(key)) return { target: objectValue, key } + return new ComputedValue(undefined) + } + if (objectValue instanceof SandboxURLSearchParams) { + if (key === "size") return new ComputedValue(objectValue.params.size) + if (typeof key === "string" && urlSearchParamsMethods.has(key)) { + return new IntrinsicReference(objectValue, key) + } + return new ComputedValue(undefined) + } + + // Any property access on a promise is a confused program (`p.then(...)`, `p.value`); + // reading `undefined` here would hide the missing await, so both paths get an explicit, + // await-hinting error instead of the forgiving unknown-property fallthrough. + if (objectValue instanceof SandboxPromise) { + if (key === "then" || key === "catch" || key === "finally") { + throw new InterpreterRuntimeError( + `Promise.prototype.${String(key)} is not supported in CodeMode; use await instead (with try/catch to handle failures) - e.g. \`const result = await tools.ns.tool(...)\`.`, + propertyNode, + "UnsupportedSyntax", + [supportedSyntaxMessage], + ) + } + throw new InterpreterRuntimeError( + "This value is an un-awaited Promise and has no readable properties; await it first - e.g. `const result = await tools.ns.tool(...)`.", + objectNode, + "InvalidDataValue", + ) + } + + if (isRuntimeReference(objectValue)) { + throw new InterpreterRuntimeError( + "CodeMode runtime references are opaque and do not expose properties.", + objectNode, + "InvalidDataValue", + ) + } + + if (typeof objectValue !== "object" || objectValue === null) { + throw new InterpreterRuntimeError("Cannot access a property on a non-object value.", objectNode) + } + + if (typeof key === "string" && isBlockedMember(key)) { + throw new InterpreterRuntimeError(`Property '${key}' is not available in CodeMode.`, propertyNode) + } + + if (Array.isArray(objectValue)) { + if ( + key !== "length" && + !(typeof key === "string" && arrayMethods.has(key)) && + typeof key !== "number" && + !/^\d+$/.test(key) + ) { + // Own non-index properties read through (match results carry index/groups); like JS, + // they are readable in place and dropped by JSON at data boundaries. + if (typeof key === "string" && Object.hasOwn(objectValue, key)) { + return new ComputedValue((objectValue as Record & Array)[key]) + } + // Unknown property on an array reads as `undefined`, matching JS (`[1,2].foo === undefined`), + // instead of throwing - so defensive access under optional chaining behaves as expected. + return new ComputedValue(undefined) + } + return { target: objectValue, key } + } + + return { target: objectValue as SafeObject, key } + }) + } + + private readMember(node: AstNode): Effect.Effect { + return Effect.map(this.getMemberReference(node), (reference) => { + if (reference === OptionalShortCircuit) return OptionalShortCircuit + if (reference instanceof ComputedValue) return reference.value + if ( + reference === undefined || + reference instanceof ToolReference || + reference instanceof PromiseMethodReference || + reference instanceof IntrinsicReference || + reference instanceof GlobalMethodReference + ) + return reference + if (Array.isArray(reference.target)) { + if (typeof reference.key === "string" && arrayMethods.has(reference.key)) { + return new IntrinsicReference(reference.target, reference.key) + } + return reference.key === "length" ? reference.target.length : reference.target[Number(reference.key)] + } + if (reference.target instanceof SandboxURL) { + return (reference.target.url as unknown as Record)[String(reference.key)] + } + return reference.target[String(reference.key)] + }) + } + + private writeMember(node: AstNode, value: unknown): Effect.Effect { + return this.modifyMember(node, () => Effect.succeed({ write: true, next: value, result: value })) + } + + // Resolves the member reference EXACTLY ONCE (so a side-effecting object/key expression + // runs once), then lets `compute` decide whether to write - enabling compound assignment, + // updates, plain writes, and short-circuiting logical assignment to share one safe path. + private modifyMember( + node: AstNode, + compute: (current: unknown) => Effect.Effect<{ write: boolean; next: unknown; result: unknown }, unknown, R>, + ): Effect.Effect { + const self = this + return Effect.gen(function* () { + const reference = yield* self.getMemberReference(node) + if ( + reference === OptionalShortCircuit || + reference instanceof ComputedValue || + reference === undefined || + reference instanceof ToolReference || + reference instanceof PromiseMethodReference || + reference instanceof IntrinsicReference || + reference instanceof GlobalMethodReference + ) { + throw new InterpreterRuntimeError("Only data fields may be assigned in CodeMode.", node) + } + if (Array.isArray(reference.target)) { + if (reference.key === "length") + throw new InterpreterRuntimeError("Array length cannot be assigned in CodeMode.", node) + if (typeof reference.key === "string" && arrayMethods.has(reference.key)) { + throw new InterpreterRuntimeError("Array methods cannot be assigned in CodeMode.", node) + } + } + const key = Array.isArray(reference.target) ? Number(reference.key) : String(reference.key) + const current = + reference.target instanceof SandboxURL + ? (reference.target.url as unknown as Record)[key] + : (reference.target as Record)[key] + const { write, next, result } = yield* compute(current) + if (write) self.assignToReference(reference, key, next, node) + return result + }) + } + + // Rejects inserting a value that (transitively) contains the container it is being inserted + // into - the mutation that would create a circular structure no later walk could survive. + private rejectCircularInsertion( + container: object, + value: unknown, + label: string, + node: AstNode, + seen = new Set(), + ): void { + if (value === container) + throw new InterpreterRuntimeError(`${label} contains a circular value.`, node, "InvalidDataValue") + if (value === null || typeof value !== "object" || isRuntimeReference(value) || seen.has(value)) return + seen.add(value) + const items = Array.isArray(value) ? value : Object.values(value) + for (const item of items) this.rejectCircularInsertion(container, item, label, node, seen) + seen.delete(value) + } + + private assignToReference(reference: MemberReference, key: number | string, next: unknown, node: AstNode): void { + if (Array.isArray(reference.target)) { + const target = reference.target + const index = key as number + if (!Number.isInteger(index) || index < 0) { + throw new InterpreterRuntimeError( + "Array assignment index must be a non-negative integer.", + node, + "InvalidDataValue", + ) + } + this.rejectCircularInsertion(target, next, "Array assignment result", node) + target[index] = next + return + } + if (reference.target instanceof SandboxURL) { + const property = key as string + if (!urlWritableProperties.has(property)) { + throw new InterpreterRuntimeError(`URL.${property} is read-only.`, node).as("TypeError") + } + try { + const url = reference.target.url as unknown as Record + url[property] = uriArgument(next, `URL.${property} value`) + return + } catch (error) { + if (error instanceof InterpreterRuntimeError || error instanceof ToolRuntimeError) throw error + throw new InterpreterRuntimeError(`URL.${property} received an invalid value.`, node).as("TypeError") + } + } + const target = reference.target as SafeObject + const objectKey = key as string + this.rejectCircularInsertion(target, next, "Object assignment result", node) + target[objectKey] = next + } + + private toPropertyKey(value: unknown, node: AstNode): string | number { + if (typeof value === "string" || typeof value === "number") { + return value + } + + throw new InterpreterRuntimeError("Property key must be a string or number.", node) + } + + private declare(name: string, value: unknown, mutable: boolean, node: AstNode): void { + const scope = this.currentScope() + + // A pre-seeded parameter slot (initialized === false) is being bound for the first time; + // anything else already present is a genuine duplicate declaration. + const existing = scope.get(name) + if (existing && existing.initialized !== false) { + throw new InterpreterRuntimeError(`Identifier '${name}' has already been declared.`, node) + } + + scope.set(name, { mutable, value, initialized: true }) + } + + private getIdentifierValue(name: string, node: AstNode): unknown { + const binding = this.resolveBinding(name) + + if (!binding) { + throw new InterpreterRuntimeError(`Unknown identifier '${name}'.`, node).as("ReferenceError") + } + + // A parameter default that forward-references a later (not-yet-bound) parameter - JS TDZ. + if (binding.initialized === false) { + throw new InterpreterRuntimeError(`Cannot access '${name}' before initialization.`, node).as("ReferenceError") + } + + return binding.value + } + + private setIdentifierValue(name: string, value: unknown, node: AstNode): unknown { + const binding = this.resolveBinding(name) + + if (!binding) { + throw new InterpreterRuntimeError(`Unknown identifier '${name}'.`, node).as("ReferenceError") + } + + if (!binding.mutable) { + throw new InterpreterRuntimeError(`Cannot assign to constant '${name}'.`, node).as("TypeError") + } + + binding.value = value + return value + } + + private resolveBinding(name: string): Binding | undefined { + for (let index = this.scopes.length - 1; index >= 0; index -= 1) { + const scope = this.scopes[index] + const binding = scope?.get(name) + + if (binding) { + return binding + } + } + + return undefined + } + + private currentScope(): Map { + const scope = this.scopes[this.scopes.length - 1] + + if (!scope) { + throw new InterpreterRuntimeError("Interpreter scope stack is empty.") + } + + return scope + } + + private pushScope(): void { + this.scopes.push(new Map()) + } + + private popScope(): void { + this.scopes.pop() + } +} + +/** + * Executes one Effect-native CodeMode program without constructing a reusable runtime. + * + * @example + * ```ts + * const result = yield* CodeMode.execute({ + * tools: { lookup }, + * code: `return await tools.lookup({ id: "order_42" })`, + * }) + * ``` + */ +export const executeWithLimits = >( + options: ExecuteOptions, + limits: ResolvedExecutionLimits, + searchIndex: ToolRuntime.DiscoveryPlan["searchIndex"], +): Effect.Effect> => { + const hooks = { + ...(options.onToolCallStart === undefined ? {} : { onToolCallStart: options.onToolCallStart }), + ...(options.onToolCallEnd === undefined ? {} : { onToolCallEnd: options.onToolCallEnd }), + } + const tools = ToolRuntime.make( + (options.tools ?? {}) as HostTools>, + limits.maxToolCalls, + searchIndex, + hooks, + ) + const logs: Array = [] + const logged = () => (logs.length > 0 ? { logs: [...logs] } : {}) + + if (options.code.trim().length === 0) { + return Effect.succeed({ + ok: false, + error: { kind: "ParseError", message: "Code cannot be empty." }, + toolCalls: tools.calls, + }) + } + + const operation = Effect.gen(function* () { + const program = parseProgram(options.code) + const interpreter = new Interpreter>(tools.invoke, tools.keys, logs) + const value = yield* interpreter.run(program) + const result = copyOut(copyIn(value, "Execution result"), true) as DataValue + return { + ok: true, + value: result, + ...logged(), + toolCalls: tools.calls, + } satisfies Result + }).pipe((program) => { + const timeoutMs = limits.timeoutMs + if (timeoutMs === undefined) return program + return program.pipe( + Effect.timeoutOrElse({ + duration: timeoutMs, + orElse: () => + Effect.succeed({ + ok: false, + error: { kind: "TimeoutExceeded", message: `Execution timed out after ${timeoutMs}ms.` }, + ...logged(), + toolCalls: tools.calls, + } satisfies Result), + }), + ) + }) + + return operation.pipe( + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.interrupt + : Effect.succeed({ + ok: false, + error: normalizeError(Cause.squash(cause)), + ...logged(), + toolCalls: tools.calls, + } satisfies Result), + ), + Effect.map((result) => (limits.maxOutputBytes === undefined ? result : boundOutput(result, limits.maxOutputBytes))), + ) +} + +const utf8ByteLength = (value: string): number => new TextEncoder().encode(value).byteLength + +// Truncates to a UTF-8 byte budget without splitting a code point (a split multi-byte +// sequence decodes to a replacement character, which is dropped). +const utf8Truncate = (value: string, maxBytes: number): string => { + const bytes = new TextEncoder().encode(value) + if (bytes.byteLength <= maxBytes) return value + const text = new TextDecoder("utf-8").decode(bytes.slice(0, Math.max(0, maxBytes))) + return text.endsWith("\uFFFD") ? text.slice(0, -1) : text +} + +/** + * Bounds the model-facing output (serialized result value plus logs) to `maxOutputBytes`. + * Oversized values are replaced by their truncated serialized text with an explanatory marker, + * and logs are kept from the start until the remaining budget is exhausted. Truncation never + * fails the execution; `truncated: true` marks affected results. Only runs when the host set + * `maxOutputBytes` - with the limit absent, output passes through unbounded. + */ +const boundOutput = (result: Result, maxOutputBytes: number): Result => { + let truncated = false + + let value: DataValue = null + let valueBytes = 0 + if (result.ok) { + const serialized = JSON.stringify(result.value) ?? "null" + const bytes = utf8ByteLength(serialized) + if (bytes > maxOutputBytes) { + truncated = true + value = `${utf8Truncate(serialized, maxOutputBytes)} [result truncated: ${bytes} bytes exceeds the ${maxOutputBytes}-byte output limit; return a smaller value]` + valueBytes = maxOutputBytes + } else { + value = result.value + valueBytes = bytes + } + } + + const logs = result.logs ?? [] + const kept: Array = [] + const logBudget = Math.max(0, maxOutputBytes - valueBytes) + let logBytes = 0 + for (const line of logs) { + const lineBytes = utf8ByteLength(line) + 1 + if (logBytes + lineBytes > logBudget) break + logBytes += lineBytes + kept.push(line) + } + if (kept.length < logs.length) { + truncated = true + kept.push(`[logs truncated: showing ${kept.length} of ${logs.length} lines]`) + } + + if (!truncated) return result + const logsPart = kept.length > 0 ? { logs: kept } : {} + return result.ok + ? { ok: true, value, ...logsPart, truncated: true, toolCalls: result.toolCalls } + : { ok: false, error: result.error, ...logsPart, truncated: true, toolCalls: result.toolCalls } +} diff --git a/packages/codemode/src/openapi/index.ts b/packages/codemode/src/openapi/index.ts index 4ceda5eef005..7f1770ef3627 100644 --- a/packages/codemode/src/openapi/index.ts +++ b/packages/codemode/src/openapi/index.ts @@ -1,5 +1,5 @@ import { HttpClient } from "effect/unstable/http" -import { Tool, type Definition } from "../tool.js" +import { make, type Definition } from "../tool.js" import { invoke } from "./runtime.js" import { componentDefinitions, @@ -102,7 +102,7 @@ export const fromSpec = (options: Options): Result => { setTool( tools, segments, - Tool.make({ + make({ description: operation.description ?? operation.summary ?? `${operation.method} ${path}`, input: inputSchema(input.fields, definitions), output: output.value, diff --git a/packages/codemode/src/stdlib/collections.ts b/packages/codemode/src/stdlib/collections.ts new file mode 100644 index 000000000000..f4760ff7061a --- /dev/null +++ b/packages/codemode/src/stdlib/collections.ts @@ -0,0 +1,51 @@ +export const arrayMethods = new Set([ + "map", + "filter", + "find", + "findIndex", + "findLast", + "findLastIndex", + "some", + "every", + "includes", + "join", + "reduce", + "reduceRight", + "flatMap", + "forEach", + "sort", + "toSorted", + "slice", + "concat", + "indexOf", + "lastIndexOf", + "at", + "flat", + "reverse", + "toReversed", + "with", + "push", + "pop", + "shift", + "unshift", + "splice", + "fill", + "copyWithin", + "keys", + "values", + "entries", +]) + +export const mapMethods = new Set(["get", "set", "has", "delete", "clear", "forEach", "keys", "values", "entries"]) + +export const setMethods = new Set(["add", "has", "delete", "clear", "forEach", "keys", "values", "entries"]) + +export const spreadItems = (value: unknown): Array | undefined => { + if (Array.isArray(value)) return value + if (typeof value === "string") return Array.from(value) + if (value instanceof SandboxMap) return Array.from(value.map.entries(), ([key, item]) => [key, item]) + if (value instanceof SandboxSet) return Array.from(value.set.values()) + if (value instanceof SandboxURLSearchParams) return Array.from(value.params.entries(), ([key, item]) => [key, item]) + return undefined +} +import { SandboxMap, SandboxSet, SandboxURLSearchParams } from "../values.js" diff --git a/packages/codemode/src/stdlib/console.ts b/packages/codemode/src/stdlib/console.ts new file mode 100644 index 000000000000..798563128e56 --- /dev/null +++ b/packages/codemode/src/stdlib/console.ts @@ -0,0 +1,4 @@ +export const consoleMethods = new Set(["log", "info", "debug", "warn", "error", "dir", "table"]) + +/** Console formatting recursion ceiling; deeper values render as "...". */ +export const MAX_CONSOLE_DEPTH = 32 diff --git a/packages/codemode/src/stdlib/date.ts b/packages/codemode/src/stdlib/date.ts new file mode 100644 index 000000000000..c492f58f94b1 --- /dev/null +++ b/packages/codemode/src/stdlib/date.ts @@ -0,0 +1,94 @@ +export const dateMethods = new Set([ + "getTime", + "valueOf", + "toISOString", + "toJSON", + "toString", + "getFullYear", + "getMonth", + "getDate", + "getDay", + "getHours", + "getMinutes", + "getSeconds", + "getMilliseconds", + "getUTCFullYear", + "getUTCMonth", + "getUTCDate", + "getUTCDay", + "getUTCHours", + "getUTCMinutes", + "getUTCSeconds", + "getUTCMilliseconds", + "getTimezoneOffset", +]) + +export const dateStatics = new Set(["now", "parse", "UTC"]) + +export const invokeDateStatic = (name: string, args: Array, node: AstNode): number => { + switch (name) { + case "now": + return Date.now() + case "parse": + return Date.parse(coerceToString(args[0])) + case "UTC": + return Date.UTC(...(args.map((arg) => coerceToNumber(arg)) as Parameters)) + default: + throw new InterpreterRuntimeError(`Date.${name} is not available in CodeMode.`, node) + } +} + +export const invokeDateMethod = (value: SandboxDate, name: string, node: AstNode): unknown => { + const hosted = new Date(value.time) + switch (name) { + case "getTime": + case "valueOf": + return value.time + case "toISOString": + if (!Number.isFinite(value.time)) throw new InterpreterRuntimeError("Invalid time value.", node) + return hosted.toISOString() + case "toJSON": + return Number.isFinite(value.time) ? hosted.toISOString() : null + case "toString": + return coerceToString(value) + case "getFullYear": + return hosted.getFullYear() + case "getMonth": + return hosted.getMonth() + case "getDate": + return hosted.getDate() + case "getDay": + return hosted.getDay() + case "getHours": + return hosted.getHours() + case "getMinutes": + return hosted.getMinutes() + case "getSeconds": + return hosted.getSeconds() + case "getMilliseconds": + return hosted.getMilliseconds() + case "getUTCFullYear": + return hosted.getUTCFullYear() + case "getUTCMonth": + return hosted.getUTCMonth() + case "getUTCDate": + return hosted.getUTCDate() + case "getUTCDay": + return hosted.getUTCDay() + case "getUTCHours": + return hosted.getUTCHours() + case "getUTCMinutes": + return hosted.getUTCMinutes() + case "getUTCSeconds": + return hosted.getUTCSeconds() + case "getUTCMilliseconds": + return hosted.getUTCMilliseconds() + case "getTimezoneOffset": + return hosted.getTimezoneOffset() + default: + throw new InterpreterRuntimeError(`Date method '${name}' is not available in CodeMode.`, node) + } +} +import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js" +import { SandboxDate } from "../values.js" +import { coerceToNumber, coerceToString } from "./value.js" diff --git a/packages/codemode/src/stdlib/json.ts b/packages/codemode/src/stdlib/json.ts new file mode 100644 index 000000000000..8a479d2c8c72 --- /dev/null +++ b/packages/codemode/src/stdlib/json.ts @@ -0,0 +1,42 @@ +import { + type AstNode, + CodeModeFunction, + InterpreterRuntimeError, + supportedSyntaxMessage, +} from "../interpreter/model.js" +import { copyIn, copyOut } from "../tool-runtime.js" + +export const jsonStatics = new Set(["stringify", "parse"]) + +export const invokeJsonMethod = (name: string, args: Array, node: AstNode): unknown => { + if (!jsonStatics.has(name)) throw new InterpreterRuntimeError(`JSON.${name} is not available in CodeMode.`, node) + switch (name) { + case "stringify": { + const replacer = args[1] + if (Array.isArray(replacer) || replacer instanceof CodeModeFunction) { + throw new InterpreterRuntimeError( + "JSON.stringify replacers are not supported in CodeMode.", + node, + "UnsupportedSyntax", + [supportedSyntaxMessage], + ) + } + const space = args[2] + const indent = typeof space === "number" || typeof space === "string" ? space : undefined + return JSON.stringify(copyOut(copyIn(args[0], "JSON.stringify value")), null, indent) + } + case "parse": { + const text = args[0] + if (typeof text !== "string") throw new InterpreterRuntimeError("JSON.parse expects a string.", node) + try { + return copyIn(JSON.parse(text), "JSON.parse result") + } catch (error) { + throw new InterpreterRuntimeError( + `JSON.parse received invalid JSON: ${error instanceof Error ? error.message : String(error)}`, + node, + ).as("SyntaxError") + } + } + } + throw new InterpreterRuntimeError(`JSON.${name} is not available in CodeMode.`, node) +} diff --git a/packages/codemode/src/stdlib/math.ts b/packages/codemode/src/stdlib/math.ts new file mode 100644 index 000000000000..cc8dd0670e96 --- /dev/null +++ b/packages/codemode/src/stdlib/math.ts @@ -0,0 +1,65 @@ +export const mathConstants = new Set(["PI", "E", "LN2", "LN10", "LOG2E", "LOG10E", "SQRT2", "SQRT1_2"]) + +export const mathMethods = new Set([ + "max", + "min", + "abs", + "floor", + "ceil", + "round", + "trunc", + "sign", + "sqrt", + "cbrt", + "pow", + "hypot", + "log", + "log2", + "log10", + "exp", +]) + +export const invokeMathMethod = (name: string, args: Array, node: AstNode): number => { + if (!mathMethods.has(name)) throw new InterpreterRuntimeError(`Math.${name} is not available in CodeMode.`, node) + const nums = args.map((arg) => { + if (typeof arg !== "number") throw new InterpreterRuntimeError(`Math.${name} expects number arguments.`, node) + return arg + }) + const [a = Number.NaN, b = Number.NaN] = nums + switch (name) { + case "max": + return Math.max(...nums) + case "min": + return Math.min(...nums) + case "abs": + return Math.abs(a) + case "floor": + return Math.floor(a) + case "ceil": + return Math.ceil(a) + case "round": + return Math.round(a) + case "trunc": + return Math.trunc(a) + case "sign": + return Math.sign(a) + case "sqrt": + return Math.sqrt(a) + case "cbrt": + return Math.cbrt(a) + case "pow": + return Math.pow(a, b) + case "hypot": + return Math.hypot(...nums) + case "log": + return Math.log(a) + case "log2": + return Math.log2(a) + case "log10": + return Math.log10(a) + case "exp": + return Math.exp(a) + } + throw new InterpreterRuntimeError(`Math.${name} is not available in CodeMode.`, node) +} +import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js" diff --git a/packages/codemode/src/stdlib/number.ts b/packages/codemode/src/stdlib/number.ts new file mode 100644 index 000000000000..79710e1ad226 --- /dev/null +++ b/packages/codemode/src/stdlib/number.ts @@ -0,0 +1,66 @@ +export const numberMethods = new Set(["toFixed", "toPrecision", "toExponential", "toString"]) + +export const numberConstants = new Set(["MAX_SAFE_INTEGER", "MIN_SAFE_INTEGER", "MAX_VALUE", "MIN_VALUE", "EPSILON"]) + +export const numberStatics = new Set(["isInteger", "isFinite", "isNaN", "isSafeInteger", "parseInt", "parseFloat"]) + +export const invokeNumberMethod = (value: number, name: string, args: Array, node: AstNode): unknown => { + const optNum = (index: number): number | undefined => { + const arg = args[index] + if (arg === undefined) return undefined + if (typeof arg !== "number") throw new InterpreterRuntimeError(`Number.${name} expects a number argument.`, node) + return arg + } + let result: unknown + switch (name) { + case "toFixed": + result = value.toFixed(optNum(0)) + break + case "toExponential": + result = value.toExponential(optNum(0)) + break + case "toPrecision": { + const digits = optNum(0) + result = digits === undefined ? value.toString() : value.toPrecision(digits) + break + } + case "toString": { + const radix = optNum(0) + if (radix !== undefined && (radix < 2 || radix > 36)) { + throw new InterpreterRuntimeError("Number.toString radix must be between 2 and 36.", node) + } + result = value.toString(radix) + break + } + default: + throw new InterpreterRuntimeError(`Number method '${name}' is not available in CodeMode.`, node) + } + return boundedData(result, `Number.${name} result`) +} + +export const invokeNumberStatic = (name: string, args: Array, node: AstNode): unknown => { + const value = args[0] + switch (name) { + case "isInteger": + return Number.isInteger(value) + case "isFinite": + return Number.isFinite(value) + case "isNaN": + return Number.isNaN(value) + case "isSafeInteger": + return Number.isSafeInteger(value) + case "parseInt": { + const radix = args[1] + if (radix !== undefined && typeof radix !== "number") { + throw new InterpreterRuntimeError("Number.parseInt expects a numeric radix.", node) + } + return parseInt(coerceToString(value), radix) + } + case "parseFloat": + return parseFloat(coerceToString(value)) + default: + throw new InterpreterRuntimeError(`Number.${name} is not available in CodeMode.`, node) + } +} +import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js" +import { boundedData, coerceToString } from "./value.js" diff --git a/packages/codemode/src/stdlib/object.ts b/packages/codemode/src/stdlib/object.ts new file mode 100644 index 000000000000..2beda0bf5cae --- /dev/null +++ b/packages/codemode/src/stdlib/object.ts @@ -0,0 +1,77 @@ +import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js" +import { isBlockedMember } from "../tool-runtime.js" +import { isSandboxValue, SandboxMap, SandboxURLSearchParams } from "../values.js" +import { boundedData, coerceToString } from "./value.js" + +export const objectStatics = new Set(["keys", "values", "entries", "hasOwn", "assign", "fromEntries"]) + +export const invokeObjectMethod = (name: string, args: Array, node: AstNode): unknown => { + if (!objectStatics.has(name)) throw new InterpreterRuntimeError(`Object.${name} is not available in CodeMode.`, node) + const requireObject = (): Record => { + const value = boundedData(args[0], `Object.${name} input`) + if (isSandboxValue(value)) return {} + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw new InterpreterRuntimeError(`Object.${name} expects a data object.`, node) + } + return value as Record + } + const guardedSet = (out: Record, key: string, item: unknown): void => { + if (isBlockedMember(key)) throw new InterpreterRuntimeError(`Property '${key}' is not available in CodeMode.`, node) + out[key] = item + } + switch (name) { + case "keys": { + const value = boundedData(args[0], "Object.keys input") + if (isSandboxValue(value)) return [] + if (Array.isArray(value)) return Object.keys(value) + if (value === null || typeof value !== "object") { + throw new InterpreterRuntimeError("Object.keys expects a data object or array.", node) + } + return Object.keys(value) + } + case "values": + return Object.values(requireObject()) + case "entries": + return Object.entries(requireObject()).map(([key, item]) => [key, item]) + case "hasOwn": + return Object.hasOwn(requireObject(), String(args[1])) + case "assign": { + const out: Record = Object.create(null) + for (const source of args) { + if (source === null || source === undefined) continue + const value = boundedData(source, "Object.assign input") + if (isSandboxValue(value)) continue + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw new InterpreterRuntimeError("Object.assign expects data objects.", node) + } + for (const [key, item] of Object.entries(value)) guardedSet(out, key, item) + } + return out + } + case "fromEntries": { + if (args[0] instanceof SandboxMap) { + const out: Record = Object.create(null) + for (const [key, item] of args[0].map.entries()) guardedSet(out, coerceToString(key), item) + return out + } + if (args[0] instanceof SandboxURLSearchParams) { + const out: Record = Object.create(null) + for (const [key, value] of args[0].params.entries()) guardedSet(out, key, value) + return out + } + const pairs = boundedData(args[0], "Object.fromEntries input") + if (!Array.isArray(pairs)) { + throw new InterpreterRuntimeError("Object.fromEntries expects an array of [key, value] pairs.", node) + } + const out: Record = Object.create(null) + for (const pair of pairs) { + if (!Array.isArray(pair)) { + throw new InterpreterRuntimeError("Object.fromEntries expects [key, value] pairs.", node) + } + guardedSet(out, String(pair[0]), pair[1]) + } + return out + } + } + throw new InterpreterRuntimeError(`Object.${name} is not available in CodeMode.`, node) +} diff --git a/packages/codemode/src/stdlib/promise.ts b/packages/codemode/src/stdlib/promise.ts new file mode 100644 index 000000000000..d0b442ccf74f --- /dev/null +++ b/packages/codemode/src/stdlib/promise.ts @@ -0,0 +1,6 @@ +import type { PromiseMethodName } from "../interpreter/model.js" + +export const promiseStatics = new Set(["all", "allSettled", "race", "resolve", "reject"]) + +/** Maximum number of eagerly forked tool calls that may run concurrently. */ +export const TOOL_CALL_CONCURRENCY = 8 diff --git a/packages/codemode/src/stdlib/regexp.ts b/packages/codemode/src/stdlib/regexp.ts new file mode 100644 index 000000000000..eac123bc16c9 --- /dev/null +++ b/packages/codemode/src/stdlib/regexp.ts @@ -0,0 +1,74 @@ +export const regexpMethods = new Set(["test", "exec", "toString"]) + +export const regexpProperties = new Set([ + "source", + "flags", + "lastIndex", + "global", + "ignoreCase", + "multiline", + "sticky", + "unicode", + "dotAll", +]) + +export const regexFailureReason = (error: unknown): string => + (error instanceof Error ? error.message : String(error)).replace(/^Invalid regular expression:\s*/i, "") + +export const escapeRegexHint = + 'To match special characters like ( ) [ ] { } + * ? . literally, escape them with a backslash (e.g. "\\\\(") or test for them with String.includes instead.' + +export const toHostRegex = (arg: unknown, method: string, node: AstNode, extraFlags = ""): RegExp => { + if (arg instanceof SandboxRegExp) return arg.regex + if (typeof arg === "string") { + try { + return new RegExp(arg, extraFlags) + } catch (error) { + throw new InterpreterRuntimeError( + `String.${method} received the string ${JSON.stringify(arg)}, which is not a valid regular expression pattern (${regexFailureReason(error)}). ${escapeRegexHint}`, + node, + ).as("SyntaxError") + } + } + throw new InterpreterRuntimeError( + `String.${method} expects a regular expression (a /pattern/flags literal or new RegExp(...)) or a string pattern, not ${arg === null ? "null" : typeof arg}.`, + node, + ) +} + +export const matchToValue = (match: RegExpMatchArray): Array => { + const result: Array = Array.from(match, (group) => group) + if (match.index !== undefined) (result as Record & Array).index = match.index + if (match.groups) { + const groups: SafeObject = Object.create(null) as SafeObject + for (const [key, group] of Object.entries(match.groups)) { + if (!isBlockedMember(key)) groups[key] = group + } + ;(result as Record & Array).groups = groups + } + return result +} + +export const invokeRegExpMethod = ( + value: SandboxRegExp, + name: string, + args: Array, + node: AstNode, +): unknown => { + switch (name) { + case "test": + return value.regex.test(coerceToString(args[0])) + case "exec": { + const matched = value.regex.exec(coerceToString(args[0])) + return matched === null ? null : matchToValue(matched) + } + case "toString": + return coerceToString(value) + default: + throw new InterpreterRuntimeError(`RegExp method '${name}' is not available in CodeMode.`, node) + } +} +import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js" +import { isBlockedMember, type SafeObject } from "../tool-runtime.js" +import { SandboxRegExp } from "../values.js" +import { coerceToString } from "./value.js" diff --git a/packages/codemode/src/stdlib/string.ts b/packages/codemode/src/stdlib/string.ts new file mode 100644 index 000000000000..3ac4372e363f --- /dev/null +++ b/packages/codemode/src/stdlib/string.ts @@ -0,0 +1,52 @@ +export const stringMethods = new Set([ + "toLowerCase", + "toUpperCase", + "trim", + "trimStart", + "trimEnd", + "trimLeft", + "trimRight", + "split", + "slice", + "substring", + "substr", + "includes", + "startsWith", + "endsWith", + "indexOf", + "lastIndexOf", + "replace", + "replaceAll", + "repeat", + "padStart", + "padEnd", + "charAt", + "charCodeAt", + "codePointAt", + "at", + "concat", + "toString", + "match", + "matchAll", + "search", + "localeCompare", + "normalize", +]) + +export const stringStatics = new Set(["fromCharCode", "fromCodePoint"]) + +export const invokeStringStatic = (name: string, args: Array, node: AstNode): unknown => { + const codes = args.map((arg) => { + if (typeof arg !== "number") throw new InterpreterRuntimeError(`String.${name} expects number arguments.`, node) + return arg + }) + switch (name) { + case "fromCharCode": + return String.fromCharCode(...codes) + case "fromCodePoint": + return String.fromCodePoint(...codes) + default: + throw new InterpreterRuntimeError(`String.${name} is not available in CodeMode.`, node) + } +} +import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js" diff --git a/packages/codemode/src/stdlib/url.ts b/packages/codemode/src/stdlib/url.ts new file mode 100644 index 000000000000..583d776d22ad --- /dev/null +++ b/packages/codemode/src/stdlib/url.ts @@ -0,0 +1,90 @@ +export const urlProperties = new Set([ + "href", + "origin", + "protocol", + "username", + "password", + "host", + "hostname", + "port", + "pathname", + "search", + "hash", +]) + +export const urlWritableProperties = new Set([ + "href", + "protocol", + "username", + "password", + "host", + "hostname", + "port", + "pathname", + "search", + "hash", +]) + +export const urlMethods = new Set(["toString", "toJSON"]) +export const urlStatics = new Set(["canParse", "parse"]) +export const urlSearchParamsMethods = new Set([ + "append", + "delete", + "get", + "getAll", + "has", + "set", + "sort", + "forEach", + "keys", + "values", + "entries", + "toString", +]) + +export const uriArgument = (value: unknown, label: string): string => coerceToString(boundedData(value, label)) + +export const invokeUriFunction = (ref: UriFunction, args: Array, node: AstNode): string => { + const value = uriArgument(args[0], `${ref.name} input`) + try { + switch (ref.name) { + case "encodeURI": + return encodeURI(value) + case "encodeURIComponent": + return encodeURIComponent(value) + case "decodeURI": + return decodeURI(value) + case "decodeURIComponent": + return decodeURIComponent(value) + } + } catch (error) { + throw new InterpreterRuntimeError( + `${ref.name} received malformed URI data: ${error instanceof Error ? error.message : String(error)}`, + node, + ).as("URIError") + } +} + +export const urlArgument = (value: unknown, label: string): string => + value instanceof SandboxURL ? value.url.href : uriArgument(value, label) + +export const invokeURLStatic = (name: string, args: Array, node: AstNode): unknown => { + if (!urlStatics.has(name)) throw new InterpreterRuntimeError(`URL.${name} is not available in CodeMode.`, node) + if (args.length === 0) throw new InterpreterRuntimeError(`URL.${name} requires a URL argument.`, node).as("TypeError") + const input = urlArgument(args[0], `URL.${name} input`) + const base = args[1] === undefined ? undefined : urlArgument(args[1], `URL.${name} base`) + try { + const url = new URL(input, base) + return name === "canParse" ? true : new SandboxURL(url) + } catch { + return name === "canParse" ? false : null + } +} + +export const invokeURLMethod = (value: SandboxURL, name: string, node: AstNode): string => { + if (name === "toString" || name === "toJSON") return value.url.href + throw new InterpreterRuntimeError(`URL method '${name}' is not available in CodeMode.`, node) +} +import { type AstNode, InterpreterRuntimeError, UriFunction } from "../interpreter/model.js" +import { SandboxURL } from "../values.js" +import { boundedData, coerceToString } from "./value.js" diff --git a/packages/codemode/src/stdlib/value.ts b/packages/codemode/src/stdlib/value.ts new file mode 100644 index 000000000000..ab40dc07ddb5 --- /dev/null +++ b/packages/codemode/src/stdlib/value.ts @@ -0,0 +1,86 @@ +export const errorConstructors = new Set([ + "Error", + "TypeError", + "RangeError", + "SyntaxError", + "ReferenceError", + "EvalError", + "URIError", +]) + +export const valueConstructors = new Set(["Date", "RegExp", "Map", "Set", "URL", "URLSearchParams"]) + +export const compoundOperators = new Set(["+=", "-=", "*=", "/=", "%=", "**=", "&=", "|=", "^=", "<<=", ">>=", ">>>="]) + +const ErrorBrand: unique symbol = Symbol("codemode.error") + +export const createErrorValue = (name: string, message: string): SafeObject => { + const value = Object.assign(Object.create(null) as SafeObject, { name, message }) + Object.defineProperty(value, ErrorBrand, { value: name }) + return value +} + +export const errorBrandName = (value: unknown): string | undefined => + value !== null && typeof value === "object" + ? ((value as Record)[ErrorBrand] as string | undefined) + : undefined + +export const boundedData = (value: unknown, label: string): unknown => copyIn(value, label, true) + +export const coerceToString = (value: unknown): string => { + if (value === null) return "null" + if (value === undefined) return "undefined" + if (value instanceof SandboxDate) + return Number.isFinite(value.time) ? new Date(value.time).toISOString() : "Invalid Date" + if (value instanceof SandboxRegExp) return `/${value.regex.source}/${value.regex.flags}` + if (value instanceof SandboxMap) return "[object Map]" + if (value instanceof SandboxSet) return "[object Set]" + if (value instanceof SandboxURL) return value.url.href + if (value instanceof SandboxURLSearchParams) return value.params.toString() + if (typeof value === "object") { + return Array.isArray(value) + ? value.map((item) => (item === null || item === undefined ? "" : coerceToString(item))).join(",") + : "[object Object]" + } + return String(value) +} + +export const coerceToNumber = (value: unknown): number => { + if (value instanceof SandboxDate) return value.time + if (isSandboxValue(value)) return Number.NaN + return value !== null && typeof value === "object" && !Array.isArray(value) ? Number.NaN : Number(value) +} + +export const invokeCoercion = (ref: CoercionFunction, args: Array, node: AstNode): unknown => { + const raw = args[0] + if (isSandboxValue(raw)) { + if (ref.name === "Boolean") return true + if (ref.name === "Number") return coerceToNumber(raw) + if (ref.name === "String") return coerceToString(raw) + if (ref.name === "parseInt") return parseInt(coerceToString(raw)) + return parseFloat(coerceToString(raw)) + } + const value = boundedData(args[0], `${ref.name} input`) + if (ref.name === "Number") return coerceToNumber(value) + if (ref.name === "Boolean") return Boolean(value) + if (ref.name === "parseInt") { + const radix = args[1] + if (radix !== undefined && typeof radix !== "number") { + throw new InterpreterRuntimeError("parseInt expects a numeric radix.", node) + } + return parseInt(coerceToString(value), radix) + } + if (ref.name === "parseFloat") return parseFloat(coerceToString(value)) + return coerceToString(value) +} +import { type AstNode, CoercionFunction, InterpreterRuntimeError } from "../interpreter/model.js" +import { copyIn, type SafeObject } from "../tool-runtime.js" +import { + isSandboxValue, + SandboxDate, + SandboxMap, + SandboxRegExp, + SandboxSet, + SandboxURL, + SandboxURLSearchParams, +} from "../values.js" diff --git a/packages/codemode/src/tool-api.ts b/packages/codemode/src/tool-api.ts deleted file mode 100644 index 96ce68d046b0..000000000000 --- a/packages/codemode/src/tool-api.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { isDefinition, make } from "./tool.js" -export type { Definition, JsonSchema, Options, ToolSchema as SchemaType } from "./tool.js" diff --git a/packages/codemode/src/tool-runtime.ts b/packages/codemode/src/tool-runtime.ts index 48d8209a48f4..f4ccc61d4c49 100644 --- a/packages/codemode/src/tool-runtime.ts +++ b/packages/codemode/src/tool-runtime.ts @@ -6,12 +6,18 @@ import { identifierSegment, inputProperties, inputTypeScript, - isDefinition as isToolDefinition, outputTypeScript, - Tool, - type Definition, -} from "./tool.js" -import { SandboxDate, SandboxMap, SandboxPromise, SandboxRegExp, SandboxSet } from "./values.js" +} from "./tool-schema.js" +import { isDefinition as isToolDefinition, type Definition } from "./tool.js" +import { + SandboxDate, + SandboxMap, + SandboxPromise, + SandboxRegExp, + SandboxSet, + SandboxURL, + SandboxURLSearchParams, +} from "./values.js" const estimateTokens = (input: string) => Math.max(0, Math.round(input.length / 4)) @@ -154,9 +160,9 @@ export const isBlockedMember = (name: string): boolean => blockedMemberNames.has * Two modes share the walk: * - **Boundary** (`preserveSandboxValues` false, the default): the host<->sandbox boundary - * final results, tool-call arguments, `JSON.stringify`. Sandbox value types serialize - * exactly as JSON.stringify would: Date -> ISO string (invalid -> null), RegExp/Map/Set -> {}. + * exactly as JSON.stringify would: Date/URL -> strings, the remaining value types -> {}. * - **Intra-sandbox checkpoint** (`preserveSandboxValues` true; see `boundedData` in - * codemode.ts): Date/RegExp/Map/Set instances pass through untouched (treated as leaves, + * codemode.ts): standard-library value instances pass through untouched (treated as leaves, * contents not walked), so values flowing through `Object.*` helpers, coercion inputs, and * other in-sandbox checkpoints stay fully usable (`.getTime()`, `.has()`, ...). * @@ -210,7 +216,9 @@ const copyBounded = ( value instanceof SandboxDate || value instanceof SandboxRegExp || value instanceof SandboxMap || - value instanceof SandboxSet + value instanceof SandboxSet || + value instanceof SandboxURL || + value instanceof SandboxURLSearchParams ) { return value } @@ -230,24 +238,30 @@ const copyBounded = ( for (const item of value.values()) wrapped.set.add(copyBounded(item, label, depth + 1, seen, true)) return wrapped } + if (value instanceof URL) return new SandboxURL(new URL(value.href)) + if (value instanceof URLSearchParams) return new SandboxURLSearchParams(new URLSearchParams(value)) } // Sandbox value types (and their host counterparts, which a host tool may legitimately - // return) serialize exactly as JSON.stringify would at the data boundary: a Date is its - // toJSON() ISO string (invalid -> null), and RegExp/Map/Set have no JSON form beyond {}. + // return) serialize exactly as JSON.stringify would at the data boundary: Date/URL use + // toJSON(), while RegExp/Map/Set/URLSearchParams have no JSON form beyond {}. if (value instanceof SandboxDate) { return Number.isFinite(value.time) ? new Date(value.time).toISOString() : null } if (value instanceof Date) { return Number.isFinite(value.getTime()) ? value.toISOString() : null } + if (value instanceof SandboxURL) return value.url.href + if (value instanceof URL) return value.href if ( value instanceof SandboxRegExp || value instanceof SandboxMap || value instanceof SandboxSet || + value instanceof SandboxURLSearchParams || value instanceof RegExp || value instanceof Map || - value instanceof Set + value instanceof Set || + value instanceof URLSearchParams ) { return Object.create(null) as SafeObject } @@ -369,69 +383,70 @@ const termForms = (term: string): Array => { return forms } -const makeSearchTool = (searchIndex: ReadonlyArray) => - Tool.make({ - description: "Search available Code Mode tools", - input: SearchInput, - output: SearchOutput, - run: (request) => - Effect.sync(() => { - const query = request.query ?? "" - const offset = request.offset ?? 0 - const scoped = - request.namespace === undefined - ? searchIndex - : searchIndex.filter((entry) => entry.namespace === request.namespace) - // A query that names one tool path exactly (canonical path or rendered JavaScript - // expression) is a lookup, not a search: return that tool alone. - const trimmed = query.trim() - const pathQuery = trimmed.startsWith("tools.") ? trimmed.slice("tools.".length) : trimmed - const exact = - pathQuery === "" - ? undefined - : scoped.find( - (entry) => entry.description.path === pathQuery || toolExpression(entry.description.path) === trimmed, - ) - const terms = tokenize(query).map(termForms) - // Additive field-weighted scoring, summed across terms: exact path or path segment - // (20) > path substring (8) > description substring (4) > any searchable text, - // including input parameter names and descriptions (2). - const ranked = - exact !== undefined - ? [exact] - : scoped - .map((entry) => { - const path = entry.description.path.toLowerCase() - const description = entry.description.description.toLowerCase() - const score = terms.reduce( - (total, forms) => - total + - (forms.some((form) => path === form || path.endsWith(`.${form}`)) ? 20 : 0) + - (forms.some((form) => path.includes(form)) ? 8 : 0) + - (forms.some((form) => description.includes(form)) ? 4 : 0) + - (forms.some((form) => entry.searchText.includes(form)) ? 2 : 0), - 0, - ) - return { entry, score } - }) - .filter(({ score }) => terms.length === 0 || score > 0) - .sort( - (left, right) => - right.score - left.score || left.entry.description.path.localeCompare(right.entry.description.path), +const makeSearchTool = (searchIndex: ReadonlyArray): Definition => ({ + _tag: "CodeModeTool", + description: "Search available Code Mode tools", + input: SearchInput, + output: SearchOutput, + run: (input) => + Effect.sync(() => { + const request = input as typeof SearchInput.Type + const query = request.query ?? "" + const offset = request.offset ?? 0 + const scoped = + request.namespace === undefined + ? searchIndex + : searchIndex.filter((entry) => entry.namespace === request.namespace) + // A query that names one tool path exactly (canonical path or rendered JavaScript + // expression) is a lookup, not a search: return that tool alone. + const trimmed = query.trim() + const pathQuery = trimmed.startsWith("tools.") ? trimmed.slice("tools.".length) : trimmed + const exact = + pathQuery === "" + ? undefined + : scoped.find( + (entry) => entry.description.path === pathQuery || toolExpression(entry.description.path) === trimmed, + ) + const terms = tokenize(query).map(termForms) + // Additive field-weighted scoring, summed across terms: exact path or path segment + // (20) > path substring (8) > description substring (4) > any searchable text, + // including input parameter names and descriptions (2). + const ranked = + exact !== undefined + ? [exact] + : scoped + .map((entry) => { + const path = entry.description.path.toLowerCase() + const description = entry.description.description.toLowerCase() + const score = terms.reduce( + (total, forms) => + total + + (forms.some((form) => path === form || path.endsWith(`.${form}`)) ? 20 : 0) + + (forms.some((form) => path.includes(form)) ? 8 : 0) + + (forms.some((form) => description.includes(form)) ? 4 : 0) + + (forms.some((form) => entry.searchText.includes(form)) ? 2 : 0), + 0, ) - .map(({ entry }) => entry) - const items = ranked.slice(offset, offset + (request.limit ?? defaultSearchLimit)).map(({ description }) => ({ - ...description, - path: toolExpression(description.path), - })) - const remaining = Math.max(0, ranked.length - offset - items.length) - return { - items, - remaining, - next: remaining > 0 ? { offset: offset + items.length } : null, - } - }), - }) + return { entry, score } + }) + .filter(({ score }) => terms.length === 0 || score > 0) + .sort( + (left, right) => + right.score - left.score || left.entry.description.path.localeCompare(right.entry.description.path), + ) + .map(({ entry }) => entry) + const items = ranked.slice(offset, offset + (request.limit ?? defaultSearchLimit)).map(({ description }) => ({ + ...description, + path: toolExpression(description.path), + })) + const remaining = Math.max(0, ranked.length - offset - items.length) + return { + items, + remaining, + next: remaining > 0 ? { offset: offset + items.length } : null, + } + }), +}) const searchDescription = describeDefinition(`${reservedNamespace}.search`, makeSearchTool([])) @@ -590,9 +605,9 @@ export const prepare = (tools: HostTools, catalogBudget = defaultCatalogBu "", "## Language", "", - "Use common JavaScript data operations, functions, control flow, selected standard-library methods, and awaited tool calls.", - "Modules/imports, classes, generators, timers, fetch, eval, prototype access, arbitrary methods, and promise chaining are unavailable. Use Code Mode tools for external operations. Use await with try/catch.", - "Dates serialize to ISO strings at data boundaries; Map/Set/RegExp serialize to `{}`.", + "Use common JavaScript data operations, functions, control flow, selected standard-library methods, and awaited tool calls. Built-ins include Date, RegExp, Map, Set, URL, URLSearchParams, and URI encoding helpers.", + "Modules/imports, classes, generators, timers, fetch, eval, prototype access, unlisted methods, and promise chaining are unavailable. Use Code Mode tools for external operations. Use await with try/catch.", + "Dates and URLs serialize to strings at data boundaries; Map/Set/RegExp/URLSearchParams serialize to `{}`.", ] const toolSection: Array = [""] diff --git a/packages/codemode/src/tool-schema.ts b/packages/codemode/src/tool-schema.ts new file mode 100644 index 000000000000..16213fa8eeb2 --- /dev/null +++ b/packages/codemode/src/tool-schema.ts @@ -0,0 +1,301 @@ +import { JsonPointer, Schema } from "effect" +import type { Definition, JsonSchema, SchemaType } from "./tool.js" + +const isEffectSchema = (schema: SchemaType): schema is Schema.Decoder & Schema.Top => Schema.isSchema(schema) + +const renderLiteral = (value: unknown): string => JSON.stringify(value) ?? "unknown" + +/** + * Bare TypeScript identifier - usable unquoted as an object key (and, in the tool runtime, + * with dot access as a tool-path segment). Anything else must be quoted/bracketed. + */ +export const identifierSegment = /^[A-Za-z_$][A-Za-z0-9_$]*$/ + +/** Renders a property name as a valid TS object key: bare when an identifier, quoted otherwise. */ +const renderKey = (name: string): string => (identifierSegment.test(name) ? name : JSON.stringify(name)) + +const effectNumberSentinel = (schema: JsonSchema) => + schema.type === "string" && + Array.isArray(schema.enum) && + schema.enum.length === 1 && + (schema.enum[0] === "NaN" || schema.enum[0] === "Infinity" || schema.enum[0] === "-Infinity") + +const intersection = (members: ReadonlyArray): string => { + const concrete = members.filter((member) => member !== "unknown") + if (concrete.length === 0) return "unknown" + if (concrete.length === 1) return concrete[0] ?? "unknown" + return concrete.map((member) => (member.includes(" | ") ? `(${member})` : member)).join(" & ") +} + +/** + * Recursion ceiling for schema rendering. Object, array, and union recursion all increment + * depth, so this bounds every recursion path - pathological or structurally cyclic schemas + * degrade to `unknown` instead of overflowing the stack (rendering must never throw). + */ +const MAX_RENDER_DEPTH = 8 + +type RenderContext = { + readonly definitions: Readonly> + /** Indented, JSDoc-annotated multiline rendering (search results); compact single line otherwise. */ + readonly pretty: boolean +} + +const hasUnresolvedRef = ( + schema: JsonSchema, + definitions: Readonly>, + seen: ReadonlySet = new Set(), + visited: ReadonlySet = new Set(), +): boolean => { + if (visited.has(schema)) return false + const nextVisited = new Set([...visited, schema]) + if (schema.$ref !== undefined) { + const segment = schema.$ref.match(/^#\/(?:\$defs|definitions)\/([^/]+)$/)?.[1] + const name = segment === undefined ? undefined : JsonPointer.unescapeToken(segment) + if (name === undefined || definitions[name] === undefined || seen.has(name)) return true + if (hasUnresolvedRef(definitions[name], definitions, new Set([...seen, name]), nextVisited)) return true + } + return [ + ...(schema.anyOf ?? []), + ...(schema.oneOf ?? []), + ...(schema.allOf ?? []), + ...Object.values(schema.properties ?? {}), + ...(schema.items === undefined ? [] : [schema.items]), + ...(typeof schema.additionalProperties === "object" ? [schema.additionalProperties] : []), + ].some((item) => hasUnresolvedRef(item, definitions, seen, nextVisited)) +} + +/** + * Schema constraints a TypeScript type cannot express natively but a model benefits from, + * surfaced as JSDoc tags (`@deprecated`, `@default`, `@format`, `@minItems`, `@maxItems`). + */ +const docTags = (schema: JsonSchema): Array => { + const tags: Array = [] + if (schema.deprecated === true) tags.push("@deprecated") + if (schema.default !== undefined) { + try { + const rendered = JSON.stringify(schema.default) + if (rendered !== undefined) tags.push(`@default ${rendered}`) + } catch { + // unserializable default: skip rather than emit a broken tag + } + } + if (typeof schema.format === "string") tags.push(`@format ${schema.format}`) + if (typeof schema.minItems === "number") tags.push(`@minItems ${schema.minItems}`) + if (typeof schema.maxItems === "number") tags.push(`@maxItems ${schema.maxItems}`) + return tags +} + +/** + * Format a schema `description` plus `tags` as a JSDoc comment at the given indent, + * preserving multi-line text (a single line stays `/** ... *\/`; multiple lines become a + * `*`-prefixed block). `*\/` is neutralized so nothing can close the comment early, and + * blank leading/trailing lines are trimmed. Returns "" (else a trailing newline) so + * callers can prepend it directly to the field line. + */ +const jsdoc = (description: string | undefined, tags: ReadonlyArray, pad: string): string => { + const lines = [...(description === undefined ? [] : description.split("\n")), ...tags].map((line) => + line.replaceAll("*/", "* /").replace(/\s+$/, ""), + ) + while (lines.length > 0 && lines[0]!.trim() === "") lines.shift() + while (lines.length > 0 && lines[lines.length - 1]!.trim() === "") lines.pop() + if (lines.length === 0) return "" + if (lines.length === 1) return `${pad}/** ${lines[0]} */\n` + const body = lines.map((line) => `${pad} *${line === "" ? "" : ` ${line}`}`).join("\n") + return `${pad}/**\n${body}\n${pad} */\n` +} + +const renderSchema = ( + schema: JsonSchema, + ctx: RenderContext, + depth = 0, + seen: ReadonlySet = new Set(), +): string => { + if (depth > MAX_RENDER_DEPTH) return "unknown" + const nested = + schema.definitions === undefined && schema.$defs === undefined + ? ctx + : { ...ctx, definitions: { ...ctx.definitions, ...(schema.definitions ?? {}), ...(schema.$defs ?? {}) } } + if (schema.$ref) { + const segment = schema.$ref.match(/^#\/(?:\$defs|definitions)\/([^/]+)$/)?.[1] + const name = segment === undefined ? undefined : JsonPointer.unescapeToken(segment) + if (!name || !nested.definitions[name] || seen.has(name)) return "unknown" + return intersection([ + renderSchema(nested.definitions[name], nested, depth, new Set([...seen, name])), + renderSchema({ ...schema, $ref: undefined }, nested, depth + 1, seen), + ]) + } + if (schema.const !== undefined) return renderLiteral(schema.const) + if (schema.enum) return schema.enum.map(renderLiteral).join(" | ") + const alternatives = schema.anyOf ?? schema.oneOf + if (alternatives) { + // Effect's number schema emits `anyOf: [{ type: "number" }, { const: "NaN" }, + // { const: "Infinity" }, { const: "-Infinity" }]`. Collapse only that artifact; + // real JSON Schema unions such as `string | number` or `number | null` must keep + // every branch. + if ( + alternatives.some((item) => item.type === "number") && + alternatives.every((item) => item.type === "number" || effectNumberSentinel(item)) + ) + return "number" + // An empty Schema.Struct({}) emits `anyOf: [{ type: "object" }, { type: "array" }]` + // (no properties/items); render the bare shape as {} instead of `{} | Array`. + if ( + alternatives.length === 2 && + alternatives[0]?.type === "object" && + alternatives[0].properties === undefined && + alternatives[1]?.type === "array" && + alternatives[1].items === undefined + ) { + return "{}" + } + const members = alternatives.map((item) => renderSchema(item, nested, depth + 1, seen)) + if (members.some((member) => member === "unknown")) return "unknown" + return intersection([ + members.join(" | "), + renderSchema({ ...schema, anyOf: undefined, oneOf: undefined }, nested, depth + 1, seen), + ]) + } + if (schema.allOf) { + const members = schema.allOf.map((item) => renderSchema(item, nested, depth + 1, seen)) + if (schema.allOf.some((item) => hasUnresolvedRef(item, nested.definitions))) return "unknown" + return intersection([renderSchema({ ...schema, allOf: undefined }, nested, depth + 1, seen), ...members]) + } + if (Array.isArray(schema.type)) { + return schema.type.map((item) => renderSchema({ ...schema, type: item }, nested, depth + 1, seen)).join(" | ") + } + if (schema.type === "string") return "string" + if (schema.type === "number" || schema.type === "integer") return "number" + if (schema.type === "boolean") return "boolean" + if (schema.type === "null") return "null" + if (schema.type === "array") return `Array<${renderSchema(schema.items ?? {}, nested, depth + 1, seen)}>` + if (schema.type === "object" || schema.properties) { + const required = new Set(schema.required ?? []) + const properties = Object.entries(schema.properties ?? {}) + const additional = schema.additionalProperties + const indexType = + additional && typeof additional === "object" ? renderSchema(additional, nested, depth + 1, seen) : undefined + const field = ([name, value]: readonly [string, JsonSchema]) => + `${renderKey(name)}${required.has(name) ? "" : "?"}: ${renderSchema(value, nested, depth + 1, seen)}` + + if (!ctx.pretty) { + const fields = properties.map(field) + if (indexType !== undefined) fields.push(`[key: string]: ${indexType}`) + return fields.length === 0 ? "{}" : `{ ${fields.join("; ")} }` + } + + // Pretty: an indented block, each described field preceded by its JSDoc comment. + if (properties.length === 0 && indexType === undefined) return "{}" + const pad = " ".repeat(depth + 1) + const lines = properties.map( + (entry) => `${jsdoc(entry[1].description, docTags(entry[1]), pad)}${pad}${field(entry)},`, + ) + if (indexType !== undefined) lines.push(`${pad}[key: string]: ${indexType},`) + return `{\n${lines.join("\n")}\n${" ".repeat(depth)}}` + } + return "unknown" +} + +export const toTypeScript = (schema: Schema.Top, decoded = false, pretty = false): string => { + try { + const visible = decoded ? Schema.toType(schema) : schema + const document = Schema.toJsonSchemaDocument(visible) as { + readonly schema: JsonSchema + readonly definitions?: Readonly> + } + return renderSchema(document.schema, { definitions: document.definitions ?? {}, pretty }) + } catch { + return "unknown" + } +} + +/** Renders a raw JSON Schema document as a TypeScript type string. */ +export const jsonSchemaToTypeScript = (schema: JsonSchema, pretty = false): string => { + try { + return renderSchema(schema, { definitions: { ...(schema.definitions ?? {}), ...(schema.$defs ?? {}) }, pretty }) + } catch { + return "unknown" + } +} + +/** One input property of a tool, extracted best-effort from its input schema. */ +export type InputProperty = { + readonly name: string + readonly description: string | undefined + readonly required: boolean +} + +/** + * The property names, descriptions, and required flags of a tool's input schema - the raw + * material for search text. Best-effort: Effect Schemas go through their + * JSON Schema document (the same emission signature rendering uses); JSON Schemas are read + * directly, resolving a trivial top-level `$ref` into `$defs`/`definitions` when present. + * Anything unresolvable yields `[]` (search falls back to path + description). + */ +export const inputProperties = (definition: Definition): Array => { + try { + const document = isEffectSchema(definition.input) + ? (Schema.toJsonSchemaDocument(definition.input) as { + readonly schema: JsonSchema + readonly definitions?: Readonly> + }) + : { + schema: definition.input, + definitions: { ...(definition.input.definitions ?? {}), ...(definition.input.$defs ?? {}) }, + } + const definitions = document.definitions ?? {} + let schema = document.schema + if (schema.$ref !== undefined) { + const segment = schema.$ref.match(/^#\/(?:\$defs|definitions)\/([^/]+)$/)?.[1] + const name = segment === undefined ? undefined : JsonPointer.unescapeToken(segment) + const resolved = name === undefined ? undefined : definitions[name] + if (resolved === undefined) return [] + schema = resolved + } + const required = new Set(schema.required ?? []) + return Object.entries(schema.properties ?? {}).map(([name, value]) => ({ + name, + description: typeof value.description === "string" ? value.description : undefined, + required: required.has(name), + })) + } catch { + return [] + } +} + +/** + * The model-visible TypeScript type of a tool's input. `pretty` renders an indented + * multiline block with schema descriptions and constraints as JSDoc comments on the + * fields; the default stays the compact single-line form. + */ +export const inputTypeScript = (definition: Definition, pretty = false): string => + isEffectSchema(definition.input) + ? toTypeScript(definition.input, false, pretty) + : jsonSchemaToTypeScript(definition.input, pretty) + +/** + * The model-visible TypeScript type of a tool's result; tools without an output schema + * return `unknown`. `pretty` renders the JSDoc-annotated multiline form, as for inputs. + */ +export const outputTypeScript = (definition: Definition, pretty = false): string => + definition.output === undefined + ? "unknown" + : isEffectSchema(definition.output) + ? toTypeScript(definition.output, true, pretty) + : jsonSchemaToTypeScript(definition.output, pretty) + +/** + * Decodes tool input before `run` is invoked. Effect Schemas validate (throwing on failure); + * JSON-Schema-described inputs pass through unvalidated (render-only). + */ +export const decodeInput = (definition: Definition, value: unknown): unknown => + isEffectSchema(definition.input) ? Schema.decodeUnknownSync(definition.input)(value) : value + +/** + * Decodes a tool result before it is exposed to the program. Effect Schemas validate and + * transform (throwing on failure); JSON Schema outputs and tools without an output schema pass + * the host value through unchanged. + */ +export const decodeOutput = (definition: Definition, value: unknown): unknown => + definition.output !== undefined && isEffectSchema(definition.output) + ? Schema.decodeUnknownSync(definition.output)(value) + : value diff --git a/packages/codemode/src/tool.ts b/packages/codemode/src/tool.ts index 4ed2ecbcf29e..6c6863f99dcf 100644 --- a/packages/codemode/src/tool.ts +++ b/packages/codemode/src/tool.ts @@ -1,4 +1,4 @@ -import { Effect, JsonPointer, Schema } from "effect" +import { Effect, Schema } from "effect" /** * JSON Schema subset accepted for render-only tool schemas. @@ -30,25 +30,25 @@ export type JsonSchema = { } /** Either a validating Effect Schema or a render-only JSON Schema document. */ -export type ToolSchema = Schema.Decoder | JsonSchema +export type SchemaType = Schema.Decoder | JsonSchema /** Schema-backed tool definition consumed by a CodeMode tool tree. */ export type Definition = { readonly _tag: "CodeModeTool" readonly description: string - readonly input: ToolSchema - readonly output: ToolSchema | undefined + readonly input: SchemaType + readonly output: SchemaType | undefined readonly run: (input: unknown) => Effect.Effect } /** The value `run` receives: the decoded type for Effect Schemas, `unknown` for JSON Schemas. */ -export type InputType = S extends Schema.Decoder ? S["Type"] : unknown +type InputType = S extends Schema.Decoder ? S["Type"] : unknown /** The value `run` returns: the encoded type for Effect Schemas, `unknown` otherwise. */ -export type ResultType = S extends Schema.Decoder ? S["Encoded"] : unknown +type ResultType = S extends Schema.Decoder ? S["Encoded"] : unknown /** Options for defining one CodeMode tool. */ -export type Options = { +export type Options = { readonly description: string readonly input: I readonly output?: O @@ -58,305 +58,6 @@ export type Options(value: unknown): value is Definition => typeof value === "object" && value !== null && "_tag" in value && value._tag === "CodeModeTool" -const isEffectSchema = (schema: ToolSchema): schema is Schema.Decoder & Schema.Top => Schema.isSchema(schema) - -const renderLiteral = (value: unknown): string => JSON.stringify(value) ?? "unknown" - -/** - * Bare TypeScript identifier - usable unquoted as an object key (and, in the tool runtime, - * with dot access as a tool-path segment). Anything else must be quoted/bracketed. - */ -export const identifierSegment = /^[A-Za-z_$][A-Za-z0-9_$]*$/ - -/** Renders a property name as a valid TS object key: bare when an identifier, quoted otherwise. */ -const renderKey = (name: string): string => (identifierSegment.test(name) ? name : JSON.stringify(name)) - -const effectNumberSentinel = (schema: JsonSchema) => - schema.type === "string" && - Array.isArray(schema.enum) && - schema.enum.length === 1 && - (schema.enum[0] === "NaN" || schema.enum[0] === "Infinity" || schema.enum[0] === "-Infinity") - -const intersection = (members: ReadonlyArray): string => { - const concrete = members.filter((member) => member !== "unknown") - if (concrete.length === 0) return "unknown" - if (concrete.length === 1) return concrete[0] ?? "unknown" - return concrete.map((member) => (member.includes(" | ") ? `(${member})` : member)).join(" & ") -} - -/** - * Recursion ceiling for schema rendering. Object, array, and union recursion all increment - * depth, so this bounds every recursion path - pathological or structurally cyclic schemas - * degrade to `unknown` instead of overflowing the stack (rendering must never throw). - */ -const MAX_RENDER_DEPTH = 8 - -type RenderContext = { - readonly definitions: Readonly> - /** Indented, JSDoc-annotated multiline rendering (search results); compact single line otherwise. */ - readonly pretty: boolean -} - -const hasUnresolvedRef = ( - schema: JsonSchema, - definitions: Readonly>, - seen: ReadonlySet = new Set(), - visited: ReadonlySet = new Set(), -): boolean => { - if (visited.has(schema)) return false - const nextVisited = new Set([...visited, schema]) - if (schema.$ref !== undefined) { - const segment = schema.$ref.match(/^#\/(?:\$defs|definitions)\/([^/]+)$/)?.[1] - const name = segment === undefined ? undefined : JsonPointer.unescapeToken(segment) - if (name === undefined || definitions[name] === undefined || seen.has(name)) return true - if (hasUnresolvedRef(definitions[name], definitions, new Set([...seen, name]), nextVisited)) return true - } - return [ - ...(schema.anyOf ?? []), - ...(schema.oneOf ?? []), - ...(schema.allOf ?? []), - ...Object.values(schema.properties ?? {}), - ...(schema.items === undefined ? [] : [schema.items]), - ...(typeof schema.additionalProperties === "object" ? [schema.additionalProperties] : []), - ].some((item) => hasUnresolvedRef(item, definitions, seen, nextVisited)) -} - -/** - * Schema constraints a TypeScript type cannot express natively but a model benefits from, - * surfaced as JSDoc tags (`@deprecated`, `@default`, `@format`, `@minItems`, `@maxItems`). - */ -const docTags = (schema: JsonSchema): Array => { - const tags: Array = [] - if (schema.deprecated === true) tags.push("@deprecated") - if (schema.default !== undefined) { - try { - const rendered = JSON.stringify(schema.default) - if (rendered !== undefined) tags.push(`@default ${rendered}`) - } catch { - // unserializable default: skip rather than emit a broken tag - } - } - if (typeof schema.format === "string") tags.push(`@format ${schema.format}`) - if (typeof schema.minItems === "number") tags.push(`@minItems ${schema.minItems}`) - if (typeof schema.maxItems === "number") tags.push(`@maxItems ${schema.maxItems}`) - return tags -} - -/** - * Format a schema `description` plus `tags` as a JSDoc comment at the given indent, - * preserving multi-line text (a single line stays `/** ... *\/`; multiple lines become a - * `*`-prefixed block). `*\/` is neutralized so nothing can close the comment early, and - * blank leading/trailing lines are trimmed. Returns "" (else a trailing newline) so - * callers can prepend it directly to the field line. - */ -const jsdoc = (description: string | undefined, tags: ReadonlyArray, pad: string): string => { - const lines = [...(description === undefined ? [] : description.split("\n")), ...tags].map((line) => - line.replaceAll("*/", "* /").replace(/\s+$/, ""), - ) - while (lines.length > 0 && lines[0]!.trim() === "") lines.shift() - while (lines.length > 0 && lines[lines.length - 1]!.trim() === "") lines.pop() - if (lines.length === 0) return "" - if (lines.length === 1) return `${pad}/** ${lines[0]} */\n` - const body = lines.map((line) => `${pad} *${line === "" ? "" : ` ${line}`}`).join("\n") - return `${pad}/**\n${body}\n${pad} */\n` -} - -const renderSchema = ( - schema: JsonSchema, - ctx: RenderContext, - depth = 0, - seen: ReadonlySet = new Set(), -): string => { - if (depth > MAX_RENDER_DEPTH) return "unknown" - const nested = - schema.definitions === undefined && schema.$defs === undefined - ? ctx - : { ...ctx, definitions: { ...ctx.definitions, ...(schema.definitions ?? {}), ...(schema.$defs ?? {}) } } - if (schema.$ref) { - const segment = schema.$ref.match(/^#\/(?:\$defs|definitions)\/([^/]+)$/)?.[1] - const name = segment === undefined ? undefined : JsonPointer.unescapeToken(segment) - if (!name || !nested.definitions[name] || seen.has(name)) return "unknown" - return intersection([ - renderSchema(nested.definitions[name], nested, depth, new Set([...seen, name])), - renderSchema({ ...schema, $ref: undefined }, nested, depth + 1, seen), - ]) - } - if (schema.const !== undefined) return renderLiteral(schema.const) - if (schema.enum) return schema.enum.map(renderLiteral).join(" | ") - const alternatives = schema.anyOf ?? schema.oneOf - if (alternatives) { - // Effect's number schema emits `anyOf: [{ type: "number" }, { const: "NaN" }, - // { const: "Infinity" }, { const: "-Infinity" }]`. Collapse only that artifact; - // real JSON Schema unions such as `string | number` or `number | null` must keep - // every branch. - if ( - alternatives.some((item) => item.type === "number") && - alternatives.every((item) => item.type === "number" || effectNumberSentinel(item)) - ) - return "number" - // An empty Schema.Struct({}) emits `anyOf: [{ type: "object" }, { type: "array" }]` - // (no properties/items); render the bare shape as {} instead of `{} | Array`. - if ( - alternatives.length === 2 && - alternatives[0]?.type === "object" && - alternatives[0].properties === undefined && - alternatives[1]?.type === "array" && - alternatives[1].items === undefined - ) { - return "{}" - } - const members = alternatives.map((item) => renderSchema(item, nested, depth + 1, seen)) - if (members.some((member) => member === "unknown")) return "unknown" - return intersection([ - members.join(" | "), - renderSchema({ ...schema, anyOf: undefined, oneOf: undefined }, nested, depth + 1, seen), - ]) - } - if (schema.allOf) { - const members = schema.allOf.map((item) => renderSchema(item, nested, depth + 1, seen)) - if (schema.allOf.some((item) => hasUnresolvedRef(item, nested.definitions))) return "unknown" - return intersection([renderSchema({ ...schema, allOf: undefined }, nested, depth + 1, seen), ...members]) - } - if (Array.isArray(schema.type)) { - return schema.type.map((item) => renderSchema({ ...schema, type: item }, nested, depth + 1, seen)).join(" | ") - } - if (schema.type === "string") return "string" - if (schema.type === "number" || schema.type === "integer") return "number" - if (schema.type === "boolean") return "boolean" - if (schema.type === "null") return "null" - if (schema.type === "array") return `Array<${renderSchema(schema.items ?? {}, nested, depth + 1, seen)}>` - if (schema.type === "object" || schema.properties) { - const required = new Set(schema.required ?? []) - const properties = Object.entries(schema.properties ?? {}) - const additional = schema.additionalProperties - const indexType = - additional && typeof additional === "object" ? renderSchema(additional, nested, depth + 1, seen) : undefined - const field = ([name, value]: readonly [string, JsonSchema]) => - `${renderKey(name)}${required.has(name) ? "" : "?"}: ${renderSchema(value, nested, depth + 1, seen)}` - - if (!ctx.pretty) { - const fields = properties.map(field) - if (indexType !== undefined) fields.push(`[key: string]: ${indexType}`) - return fields.length === 0 ? "{}" : `{ ${fields.join("; ")} }` - } - - // Pretty: an indented block, each described field preceded by its JSDoc comment. - if (properties.length === 0 && indexType === undefined) return "{}" - const pad = " ".repeat(depth + 1) - const lines = properties.map( - (entry) => `${jsdoc(entry[1].description, docTags(entry[1]), pad)}${pad}${field(entry)},`, - ) - if (indexType !== undefined) lines.push(`${pad}[key: string]: ${indexType},`) - return `{\n${lines.join("\n")}\n${" ".repeat(depth)}}` - } - return "unknown" -} - -export const toTypeScript = (schema: Schema.Top, decoded = false, pretty = false): string => { - try { - const visible = decoded ? Schema.toType(schema) : schema - const document = Schema.toJsonSchemaDocument(visible) as { - readonly schema: JsonSchema - readonly definitions?: Readonly> - } - return renderSchema(document.schema, { definitions: document.definitions ?? {}, pretty }) - } catch { - return "unknown" - } -} - -/** Renders a raw JSON Schema document as a TypeScript type string. */ -export const jsonSchemaToTypeScript = (schema: JsonSchema, pretty = false): string => { - try { - return renderSchema(schema, { definitions: { ...(schema.definitions ?? {}), ...(schema.$defs ?? {}) }, pretty }) - } catch { - return "unknown" - } -} - -/** One input property of a tool, extracted best-effort from its input schema. */ -export type InputProperty = { - readonly name: string - readonly description: string | undefined - readonly required: boolean -} - -/** - * The property names, descriptions, and required flags of a tool's input schema - the raw - * material for search text. Best-effort: Effect Schemas go through their - * JSON Schema document (the same emission signature rendering uses); JSON Schemas are read - * directly, resolving a trivial top-level `$ref` into `$defs`/`definitions` when present. - * Anything unresolvable yields `[]` (search falls back to path + description). - */ -export const inputProperties = (definition: Definition): Array => { - try { - const document = isEffectSchema(definition.input) - ? (Schema.toJsonSchemaDocument(definition.input) as { - readonly schema: JsonSchema - readonly definitions?: Readonly> - }) - : { - schema: definition.input, - definitions: { ...(definition.input.definitions ?? {}), ...(definition.input.$defs ?? {}) }, - } - const definitions = document.definitions ?? {} - let schema = document.schema - if (schema.$ref !== undefined) { - const segment = schema.$ref.match(/^#\/(?:\$defs|definitions)\/([^/]+)$/)?.[1] - const name = segment === undefined ? undefined : JsonPointer.unescapeToken(segment) - const resolved = name === undefined ? undefined : definitions[name] - if (resolved === undefined) return [] - schema = resolved - } - const required = new Set(schema.required ?? []) - return Object.entries(schema.properties ?? {}).map(([name, value]) => ({ - name, - description: typeof value.description === "string" ? value.description : undefined, - required: required.has(name), - })) - } catch { - return [] - } -} - -/** - * The model-visible TypeScript type of a tool's input. `pretty` renders an indented - * multiline block with schema descriptions and constraints as JSDoc comments on the - * fields; the default stays the compact single-line form. - */ -export const inputTypeScript = (definition: Definition, pretty = false): string => - isEffectSchema(definition.input) - ? toTypeScript(definition.input, false, pretty) - : jsonSchemaToTypeScript(definition.input, pretty) - -/** - * The model-visible TypeScript type of a tool's result; tools without an output schema - * return `unknown`. `pretty` renders the JSDoc-annotated multiline form, as for inputs. - */ -export const outputTypeScript = (definition: Definition, pretty = false): string => - definition.output === undefined - ? "unknown" - : isEffectSchema(definition.output) - ? toTypeScript(definition.output, true, pretty) - : jsonSchemaToTypeScript(definition.output, pretty) - -/** - * Decodes tool input before `run` is invoked. Effect Schemas validate (throwing on failure); - * JSON-Schema-described inputs pass through unvalidated (render-only). - */ -export const decodeInput = (definition: Definition, value: unknown): unknown => - isEffectSchema(definition.input) ? Schema.decodeUnknownSync(definition.input)(value) : value - -/** - * Decodes a tool result before it is exposed to the program. Effect Schemas validate and - * transform (throwing on failure); JSON Schema outputs and tools without an output schema pass - * the host value through unchanged. - */ -export const decodeOutput = (definition: Definition, value: unknown): unknown => - definition.output !== undefined && isEffectSchema(definition.output) - ? Schema.decodeUnknownSync(definition.output)(value) - : value - /** * Defines one schema-described tool available to a CodeMode program through `tools.*`. * @@ -384,7 +85,7 @@ export const decodeOutput = (definition: Definition, value: unknown): unkn * }) * ``` */ -export const make = ( +export const make = ( options: Options, ): Definition => ({ _tag: "CodeModeTool", @@ -393,6 +94,3 @@ export const make = options.run(input as InputType), }) - -/** Constructors for schema-backed tools exposed inside CodeMode programs. */ -export const Tool = { make, isDefinition } diff --git a/packages/codemode/src/values.ts b/packages/codemode/src/values.ts index 07f22adb8a23..4ca305d815eb 100644 --- a/packages/codemode/src/values.ts +++ b/packages/codemode/src/values.ts @@ -27,8 +27,23 @@ export class SandboxSet { readonly set = new Set() } -export const isSandboxValue = (value: unknown): value is SandboxDate | SandboxRegExp | SandboxMap | SandboxSet => +export class SandboxURLSearchParams { + constructor(readonly params: URLSearchParams) {} +} + +export class SandboxURL { + readonly searchParams: SandboxURLSearchParams + constructor(readonly url: URL) { + this.searchParams = new SandboxURLSearchParams(url.searchParams) + } +} + +export const isSandboxValue = ( + value: unknown, +): value is SandboxDate | SandboxRegExp | SandboxMap | SandboxSet | SandboxURL | SandboxURLSearchParams => value instanceof SandboxDate || value instanceof SandboxRegExp || value instanceof SandboxMap || - value instanceof SandboxSet + value instanceof SandboxSet || + value instanceof SandboxURL || + value instanceof SandboxURLSearchParams diff --git a/packages/codemode/test/codemode.test.ts b/packages/codemode/test/codemode.test.ts index f5a7169cf553..221b5e07dfe1 100644 --- a/packages/codemode/test/codemode.test.ts +++ b/packages/codemode/test/codemode.test.ts @@ -655,9 +655,11 @@ describe("CodeMode public contract", () => { for (const missing of ["Modules/imports", "classes", "generators", "fetch", "promise chaining"]) { expect(instructions).toContain(missing) } + expect(instructions).toContain("URL, URLSearchParams, and URI encoding helpers") + expect(instructions).not.toContain("host globals") expect(instructions).toContain("Use Code Mode tools for external operations") expect(instructions).toContain( - "Dates serialize to ISO strings at data boundaries; Map/Set/RegExp serialize to `{}`.", + "Dates and URLs serialize to strings at data boundaries; Map/Set/RegExp/URLSearchParams serialize to `{}`.", ) }) diff --git a/packages/codemode/test/openapi.test.ts b/packages/codemode/test/openapi.test.ts index 24291ee14d0e..12a094e033b6 100644 --- a/packages/codemode/test/openapi.test.ts +++ b/packages/codemode/test/openapi.test.ts @@ -1,8 +1,8 @@ import { describe, expect, test } from "bun:test" import { Effect, Layer, Option } from "effect" import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" -import { CodeMode, OpenAPI } from "../src/index.js" -import { inputTypeScript, outputTypeScript, Tool } from "../src/tool.js" +import { CodeMode, OpenAPI, Tool } from "../src/index.js" +import { inputTypeScript, outputTypeScript } from "../src/tool-schema.js" const baseUrl = "http://localhost:4096" type Document = OpenAPI.Document diff --git a/packages/codemode/test/signature.test.ts b/packages/codemode/test/signature.test.ts index 2d07d2f234e9..232c6dcb221d 100644 --- a/packages/codemode/test/signature.test.ts +++ b/packages/codemode/test/signature.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test" import { Effect, Schema } from "effect" -import { CodeMode } from "../src/index.js" -import { Tool, inputTypeScript, jsonSchemaToTypeScript, outputTypeScript } from "../src/tool.js" +import { CodeMode, Tool } from "../src/index.js" +import { inputTypeScript, jsonSchemaToTypeScript, outputTypeScript } from "../src/tool-schema.js" // A raw JSON Schema tool in the shape an MCP adapter produces: render-only input schema // whose property descriptions and constraints must surface as JSDoc in pretty signatures. diff --git a/packages/codemode/test/stdlib.test.ts b/packages/codemode/test/stdlib.test.ts index ac0e8e2e7943..f7831a060338 100644 --- a/packages/codemode/test/stdlib.test.ts +++ b/packages/codemode/test/stdlib.test.ts @@ -1,12 +1,12 @@ import { describe, expect, test } from "bun:test" -import { Effect } from "effect" +import { Effect, Schema } from "effect" import { CodeMode, Tool } from "../src/index.js" // Standard-library value types: Date, RegExp, Map, Set. Programs use them as ordinary JS; // intra-sandbox checkpoints (Object.* helpers, spread, coercion inputs) preserve the live // values, while at the host boundary (final result, tool arguments, JSON.stringify) they // serialize exactly as JSON.stringify would: Date -> ISO string (invalid -> null), -// RegExp/Map/Set -> {}. +// URL -> href, and RegExp/Map/Set/URLSearchParams -> {}. const run = (code: string) => Effect.runPromise(CodeMode.execute({ code, tools: {} })) const value = async (code: string) => { const result = await run(code) @@ -149,6 +149,67 @@ describe("RegExp", () => { expect(await value(`return "hi bob".replace(/b(o)b/, "[$1]")`)).toBe("hi [o]") }) + test("function replacers receive captures, offsets, input, and named groups", async () => { + expect( + await value(` + const seen = [] + const output = "a1b22".replace(/(\\d)(\\d)?/g, (match, first, second, offset, input) => { + seen.push([match, first, second === undefined, offset, input]) + return Number(match) * 2 + }) + return { output, seen } + `), + ).toEqual({ + output: "a2b44", + seen: [ + ["1", "1", true, 1, "a1b22"], + ["22", "2", false, 3, "a1b22"], + ], + }) + expect( + await value(` + return "red-blue".replace( + /(?[a-z]+)-(?[a-z]+)/, + (match, left, right, offset, input, groups) => groups.right + ":" + groups.left, + ) + `), + ).toBe("blue:red") + }) + + test("function replacers support string searches, zero-length matches, and result coercion", async () => { + expect(await value(`return "banana".replace("na", (match, offset, input) => "[" + offset + "]")`)).toBe("ba[2]na") + expect(await value(`return "ab".replaceAll("", (match, offset) => offset)`)).toBe("0a1b2") + expect(await value(`return "😀".replaceAll(/(?:)/gu, (match, offset) => "[" + offset + "]")`)).toBe("[0]😀[2]") + expect( + await value(`return "123".replace(/\\d/g, (match) => match === "1" ? 7 : match === "2" ? null : { n: 3 })`), + ).toBe("7null[object Object]") + }) + + test("function replacers can await effectful tool calls", async () => { + const decorate = Tool.make({ + description: "Decorate a string", + input: Schema.String, + output: Schema.String, + run: (input) => Effect.succeed(`[${input}]`), + }) + const result = await Effect.runPromise( + CodeMode.execute({ + tools: { host: { decorate } }, + code: `return "a1b22".replace(/\\d+/g, async (match) => await tools.host.decorate(match))`, + }), + ) + expect(result.ok && result.value).toBe("a[1]b[22]") + + const missingAwait = await Effect.runPromise( + CodeMode.execute({ + tools: { host: { decorate } }, + code: `return "a1".replace(/\\d/, (match) => tools.host.decorate(match))`, + }), + ) + expect(!missingAwait.ok && missingAwait.error.kind).toBe("InvalidDataValue") + expect(!missingAwait.ok && missingAwait.error.message).toContain("un-awaited Promise") + }) + test("replaceAll without the g flag is a catchable error", async () => { expect(await value(`try { "a".replaceAll(/a/, "b"); return "no" } catch { return "caught" }`)).toBe("caught") }) @@ -208,6 +269,165 @@ describe("RegExp", () => { }) }) +describe("URL and URI helpers", () => { + test("encodes and decodes complete URIs and URI components", async () => { + expect( + await value(` + return [ + encodeURI("https://example.test/a b?q=a/b"), + encodeURIComponent("a b/c?"), + decodeURI("https://example.test/a%20b?q=a/b"), + decodeURIComponent("a%20b%2Fc%3F"), + ["a b", "c/d"].map(encodeURIComponent), + ] + `), + ).toEqual([ + "https://example.test/a%20b?q=a/b", + "a%20b%2Fc%3F", + "https://example.test/a b?q=a/b", + "a b/c?", + ["a%20b", "c%2Fd"], + ]) + expect( + await value(`try { decodeURIComponent("%zz"); return false } catch (error) { return error instanceof URIError }`), + ).toBe(true) + }) + + test("resolves and mutates URLs with linked search parameters", async () => { + expect( + await value(` + const url = new URL("../users?id=old#top", "https://user:pass@example.com:8443/api/v1/") + url.pathname = "/items/a b" + url.searchParams.set("id", "a b") + url.searchParams.append("tag", "x/y") + url.hash = "part 1" + return { + href: url.href, + origin: url.origin, + host: url.host, + pathname: url.pathname, + search: url.search, + id: url.searchParams.get("id"), + string: String(url), + json: url.toJSON(), + instances: [ + url instanceof URL, + url.searchParams instanceof URLSearchParams, + url.searchParams === url.searchParams, + ], + } + `), + ).toEqual({ + href: "https://user:pass@example.com:8443/items/a%20b?id=a+b&tag=x%2Fy#part%201", + origin: "https://example.com:8443", + host: "example.com:8443", + pathname: "/items/a%20b", + search: "?id=a+b&tag=x%2Fy", + id: "a b", + string: "https://user:pass@example.com:8443/items/a%20b?id=a+b&tag=x%2Fy#part%201", + json: "https://user:pass@example.com:8443/items/a%20b?id=a+b&tag=x%2Fy#part%201", + instances: [true, true, true], + }) + }) + + test("URLSearchParams supports records, pairs, mutation, callbacks, and materialization", async () => { + expect( + await value(` + const params = new URLSearchParams([["tag", "b"], ["tag", "a"], ["q", "a b"]]) + const seen = [] + params.forEach((value, key) => seen.push(key + "=" + value)) + params.delete("tag", "b") + params.append("tag", "c") + params.sort() + return { + text: params.toString(), + size: params.size, + tags: params.getAll("tag"), + has: params.has("tag", "c"), + entries: Array.from(params), + object: Object.fromEntries(params), + record: new URLSearchParams({ page: 2, filter: "open" }).toString(), + seen, + } + `), + ).toEqual({ + text: "q=a+b&tag=a&tag=c", + size: 3, + tags: ["a", "c"], + has: true, + entries: [ + ["q", "a b"], + ["tag", "a"], + ["tag", "c"], + ], + object: { q: "a b", tag: "c" }, + record: "page=2&filter=open", + seen: ["tag=b", "tag=a", "q=a b"], + }) + }) + + test("URL parsing failures are catchable and values use native JSON forms", async () => { + expect( + await value(` + const parsed = URL.parse("/users", "https://example.test/api/") + let invalidIsTypeError = false + try { new URL("not relative without a base") } catch (error) { invalidIsTypeError = error instanceof TypeError } + return { + canParse: URL.canParse("/users", "https://example.test/api/"), + cannotParse: URL.canParse("not relative without a base"), + parsed: parsed.href, + invalidIsTypeError, + boundary: [new URL("https://example.test/a"), new URLSearchParams("q=one")], + json: JSON.stringify({ url: new URL("https://example.test/a"), params: new URLSearchParams("q=one") }), + } + `), + ).toEqual({ + canParse: true, + cannotParse: false, + parsed: "https://example.test/users", + invalidIsTypeError: true, + boundary: ["https://example.test/a", {}], + json: '{"url":"https://example.test/a","params":{}}', + }) + }) + + test("distinguishes omitted URL arguments from explicit undefined", async () => { + expect( + await value(` + function throwsTypeError(run) { + try { run(); return false } catch (error) { return error instanceof TypeError } + } + const params = new URLSearchParams() + const required = [ + () => params.append(), + () => params.delete(), + () => params.get(), + () => params.getAll(), + () => params.has(), + () => params.set(), + () => params.forEach(), + ].map(throwsTypeError) + params.append(undefined, undefined) + return { + construct: throwsTypeError(() => new URL()), + canParse: throwsTypeError(() => URL.canParse()), + parse: throwsTypeError(() => URL.parse()), + explicitUndefined: new URL(undefined, "https://example.test/base/").href, + params: params.toString(), + required, + } + `), + ).toEqual({ + construct: true, + canParse: true, + parse: true, + explicitUndefined: "https://example.test/base/undefined", + params: "undefined=undefined", + required: [true, true, true, true, true, true, true], + }) + }) +}) + describe("Map", () => { test("get/set/has/size with chaining", async () => { expect( diff --git a/packages/console/app/package.json b/packages/console/app/package.json index 25d341593e4e..2e3d2342d010 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.17.13", + "version": "1.17.14", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/core/package.json b/packages/console/core/package.json index fcf9ce998881..8acc8e14ae2c 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.17.13", + "version": "1.17.14", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index 3ce675a28e38..c3c8b8894502 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.17.13", + "version": "1.17.14", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index 270076f3b2be..26af29143794 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.17.13", + "version": "1.17.14", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/console/support/package.json b/packages/console/support/package.json index f3feee275a4f..3091274be278 100644 --- a/packages/console/support/package.json +++ b/packages/console/support/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-support", - "version": "1.17.13", + "version": "1.17.14", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/core/package.json b/packages/core/package.json index 8de83c03377a..fe6c0950d0c2 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.17.13", + "version": "1.17.14", "name": "@opencode-ai/core", "type": "module", "license": "MIT", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 3edd4805cbef..570318130458 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.17.13", + "version": "1.17.14", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index d5d5e3ea24fb..acceabfef0a6 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -19,6 +19,7 @@ import { forwardInitializationFailure } from "./initialization" import { exportDebugLogs, initCrashReporter, initLogging, startNetLog, write as writeLog } from "./logging" import { parseMarkdown } from "./markdown" import { createMenu } from "./menu" +import { finishFirstLaunchOnboarding, isFirstLaunchOnboardingPending } from "./onboarding" import { getDefaultServerUrl, preferAppEnv, @@ -277,6 +278,8 @@ const main = Effect.gen(function* () { consumeInitialDeepLinks: () => pendingDeepLinks.splice(0), getDefaultServerUrl: () => getDefaultServerUrl(), setDefaultServerUrl: (url) => setDefaultServerUrl(url), + isFirstLaunchOnboardingPending, + finishFirstLaunchOnboarding, getDisplayBackend: async () => null, setDisplayBackend: async () => undefined, parseMarkdown: async (markdown) => parseMarkdown(markdown), diff --git a/packages/desktop/src/main/ipc.ts b/packages/desktop/src/main/ipc.ts index 22560ff7da3a..073d288e5cf0 100644 --- a/packages/desktop/src/main/ipc.ts +++ b/packages/desktop/src/main/ipc.ts @@ -27,6 +27,8 @@ type Deps = { consumeInitialDeepLinks: () => Promise | string[] getDefaultServerUrl: () => Promise | string | null setDefaultServerUrl: (url: string | null) => Promise | void + isFirstLaunchOnboardingPending: () => Promise | boolean + finishFirstLaunchOnboarding: (createDefaultProject: boolean) => Promise | string | null getDisplayBackend: () => Promise setDisplayBackend: (backend: string | null) => Promise | void parseMarkdown: (markdown: string) => Promise | string @@ -50,6 +52,10 @@ export function registerIpcHandlers(deps: Deps) { ipcMain.handle("set-default-server-url", (_event: IpcMainInvokeEvent, url: string | null) => deps.setDefaultServerUrl(url), ) + ipcMain.handle("is-first-launch-onboarding-pending", () => deps.isFirstLaunchOnboardingPending()) + ipcMain.handle("finish-first-launch-onboarding", (_event: IpcMainInvokeEvent, createDefaultProject: boolean) => + deps.finishFirstLaunchOnboarding(createDefaultProject), + ) ipcMain.handle("get-display-backend", () => deps.getDisplayBackend()) ipcMain.handle("set-display-backend", (_event: IpcMainInvokeEvent, backend: string | null) => deps.setDisplayBackend(backend), diff --git a/packages/desktop/src/main/onboarding.ts b/packages/desktop/src/main/onboarding.ts new file mode 100644 index 000000000000..926506e7b9ba --- /dev/null +++ b/packages/desktop/src/main/onboarding.ts @@ -0,0 +1,28 @@ +import { mkdir } from "node:fs/promises" +import { join } from "node:path" +import { app } from "electron" +import { getStore } from "./store" +import { FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY } from "./store-keys" +import { write as writeLog } from "./logging" + +const DEFAULT_PROJECT_DIR = "New OpenCode Project" + +export function isFirstLaunchOnboardingPending() { + const pending = getStore().get(FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY) !== true + writeLog("onboarding", "first launch onboarding pending checked", { pending }) + return pending +} + +export async function finishFirstLaunchOnboarding(createDefaultProject: boolean) { + if (!isFirstLaunchOnboardingPending()) { + writeLog("onboarding", "first launch onboarding already completed") + return null + } + + const defaultProject = createDefaultProject ? join(app.getPath("documents"), DEFAULT_PROJECT_DIR) : null + if (defaultProject) await mkdir(defaultProject, { recursive: true }) + + getStore().set(FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY, true) + writeLog("onboarding", "first launch onboarding completed", { createDefaultProject, defaultProject }) + return defaultProject +} diff --git a/packages/desktop/src/main/store-keys.ts b/packages/desktop/src/main/store-keys.ts index 270ffe7504e0..506924b74a66 100644 --- a/packages/desktop/src/main/store-keys.ts +++ b/packages/desktop/src/main/store-keys.ts @@ -1,5 +1,6 @@ export const SETTINGS_STORE = "opencode.settings" export const DEFAULT_SERVER_URL_KEY = "defaultServerUrl" +export const FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY = "firstLaunchOnboardingComplete" export const WSL_SERVERS_KEY = "wslServers" export const PINCH_ZOOM_ENABLED_KEY = "pinchZoomEnabled" export const WINDOW_IDS_KEY = "windowIds" diff --git a/packages/desktop/src/preload/index.ts b/packages/desktop/src/preload/index.ts index 2cb8a9ee073c..47a757a557d3 100644 --- a/packages/desktop/src/preload/index.ts +++ b/packages/desktop/src/preload/index.ts @@ -59,6 +59,9 @@ const api: ElectronAPI = { consumeInitialDeepLinks: () => ipcRenderer.invoke("consume-initial-deep-links"), getDefaultServerUrl: () => ipcRenderer.invoke("get-default-server-url"), setDefaultServerUrl: (url) => ipcRenderer.invoke("set-default-server-url", url), + isFirstLaunchOnboardingPending: () => ipcRenderer.invoke("is-first-launch-onboarding-pending"), + finishFirstLaunchOnboarding: (createDefaultProject) => + ipcRenderer.invoke("finish-first-launch-onboarding", createDefaultProject), getDisplayBackend: () => ipcRenderer.invoke("get-display-backend"), setDisplayBackend: (backend) => ipcRenderer.invoke("set-display-backend", backend), parseMarkdownCommand: (markdown) => ipcRenderer.invoke("parse-markdown", markdown), diff --git a/packages/desktop/src/preload/types.ts b/packages/desktop/src/preload/types.ts index 5401c2070d7a..d7509580ad05 100644 --- a/packages/desktop/src/preload/types.ts +++ b/packages/desktop/src/preload/types.ts @@ -49,6 +49,8 @@ export type ElectronAPI = { consumeInitialDeepLinks: () => Promise getDefaultServerUrl: () => Promise setDefaultServerUrl: (url: string | null) => Promise + isFirstLaunchOnboardingPending: () => Promise + finishFirstLaunchOnboarding: (createDefaultProject: boolean) => Promise getDisplayBackend: () => Promise setDisplayBackend: (backend: LinuxDisplayBackend | null) => Promise parseMarkdownCommand: (markdown: string) => Promise diff --git a/packages/desktop/src/renderer/index.tsx b/packages/desktop/src/renderer/index.tsx index 5369ab21e310..966fb0c0bf3a 100644 --- a/packages/desktop/src/renderer/index.tsx +++ b/packages/desktop/src/renderer/index.tsx @@ -23,6 +23,7 @@ import { render } from "solid-js/web" import pkg from "../../package.json" import { initI18n, t } from "./i18n" import { initializationData, initializationReady } from "./initialization" +import { DesktopFirstLaunchOnboarding } from "./onboarding" import { resetZoom, setPinchZoomEnabled, webviewZoom, zoomIn, zoomOut } from "./webview-zoom" import { availableStartupServer, readyWslConnections } from "./wsl/connections" import "./styles.css" @@ -347,6 +348,7 @@ function DesktopRoot(props: { windowState: DesktopWindowState }) { const router = (props: BaseRouterProps) => ( ) + const onboarding = Promise.withResolvers() function handleClick(e: MouseEvent) { const link = (e.target as HTMLElement).closest("a.external-link") as HTMLAnchorElement | null @@ -400,12 +402,22 @@ function DesktopRoot(props: { windowState: DesktopWindowState }) { const effectiveDefaultServer = createMemo(() => ServerConnection.Key.make(availableStartupServer(defaultServer.latest, wslServers.data)), ) - return ( }> {(key) => ( - + + } + > )} diff --git a/packages/desktop/src/renderer/onboarding.tsx b/packages/desktop/src/renderer/onboarding.tsx new file mode 100644 index 000000000000..47758bc78869 --- /dev/null +++ b/packages/desktop/src/renderer/onboarding.tsx @@ -0,0 +1,79 @@ +import { + ServerConnection, + useLayout, + useProviders, + useServer, + useServerSDK, + useServerSync, + useTabs, +} from "@opencode-ai/app" +import { onMount, startTransition } from "solid-js" + +export function DesktopFirstLaunchOnboarding(props: { initialUrl: string; onLoaded: () => void }) { + const server = useServer() + const serverSDK = useServerSDK() + const serverSync = useServerSync() + const layout = useLayout() + const providers = useProviders() + const tabs = useTabs() + + onMount(() => { + void runFirstLaunchOnboarding().finally(props.onLoaded) + }) + + async function runFirstLaunchOnboarding() { + try { + await Promise.all( + [server.ready.promise, layout.ready.promise, tabs.ready.promise, tabs.recentReady.promise].map( + (p) => p ?? Promise.resolve(), + ), + ) + if (!server.isLocal()) return + + const pending = await window.api.isFirstLaunchOnboardingPending() + if (!pending) return + + const sessions = await serverSDK() + .client.session.list() + .then((x) => x.data ?? []) + .catch(() => undefined) + const connectedProviders = providers.connected() + const paidProviders = providers.paid() + const persistedProjects = layout.projects.list() + const shouldTrigger = + props.initialUrl === "/" && + sessions?.length === 0 && + paidProviders.length === 0 && + persistedProjects.length === 0 && + tabs.store.length === 0 && + server.list.every(ServerConnection.builtin) + + console.info("[desktop-onboarding] first launch onboarding evaluated", { + pending, + shouldTrigger, + initialUrl: props.initialUrl, + sessions: sessions?.length, + connectedProviders: connectedProviders.length, + paidProviders: paidProviders.length, + serverProjects: serverSync().data.project.length, + persistedProjects: persistedProjects.length, + tabs: tabs.store.length, + servers: server.list.map(ServerConnection.key), + }) + + const directory = await window.api.finishFirstLaunchOnboarding(shouldTrigger) + if (!shouldTrigger || !directory) return + + console.info("[desktop-onboarding] starting first launch draft", { directory }) + server.projects.open(directory) + server.projects.touch(directory) + await startTransition(() => { + tabs.newDraft({ server: server.key, directory }) + }) + } catch (error) { + console.error("[desktop-onboarding] first launch onboarding failed", error) + } + } + + return null +} diff --git a/packages/effect-drizzle-sqlite/package.json b/packages/effect-drizzle-sqlite/package.json index cf3c49c666a1..cd524eabb535 100644 --- a/packages/effect-drizzle-sqlite/package.json +++ b/packages/effect-drizzle-sqlite/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.17.13", + "version": "1.17.14", "name": "@opencode-ai/effect-drizzle-sqlite", "type": "module", "license": "MIT", diff --git a/packages/effect-sqlite-node/package.json b/packages/effect-sqlite-node/package.json index 6b47e167e7df..936b5d47c685 100644 --- a/packages/effect-sqlite-node/package.json +++ b/packages/effect-sqlite-node/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.17.13", + "version": "1.17.14", "name": "@opencode-ai/effect-sqlite-node", "type": "module", "license": "MIT", diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index 5a32b59232b3..70e12a009e43 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.17.13", + "version": "1.17.14", "private": true, "type": "module", "license": "MIT", diff --git a/packages/function/package.json b/packages/function/package.json index f484d04a1280..1e9b3e5fc12b 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.17.13", + "version": "1.17.14", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/http-recorder/package.json b/packages/http-recorder/package.json index 5e881435c6b4..0d4f1175ad0b 100644 --- a/packages/http-recorder/package.json +++ b/packages/http-recorder/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.17.13", + "version": "1.17.14", "name": "@opencode-ai/http-recorder", "description": "Record and replay Effect HTTP client traffic with deterministic cassettes", "type": "module", diff --git a/packages/llm/package.json b/packages/llm/package.json index 0b4cd12e2560..f7371c2938c2 100644 --- a/packages/llm/package.json +++ b/packages/llm/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.17.13", + "version": "1.17.14", "name": "@opencode-ai/llm", "type": "module", "license": "MIT", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 2375a28471a3..a7ad66d13747 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.17.13", + "version": "1.17.14", "name": "opencode", "type": "module", "license": "MIT", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 50aeffb8e10a..3069aa104f4a 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.17.13", + "version": "1.17.14", "type": "module", "license": "MIT", "scripts": { @@ -27,9 +27,9 @@ "zod": "catalog:" }, "peerDependencies": { - "@opentui/core": ">=0.3.4", - "@opentui/keymap": ">=0.3.4", - "@opentui/solid": ">=0.3.4" + "@opentui/core": ">=0.4.3", + "@opentui/keymap": ">=0.4.3", + "@opentui/solid": ">=0.4.3" }, "peerDependenciesMeta": { "@opentui/core": { diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 520ff069d5ce..dda5b25445a3 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.17.13", + "version": "1.17.14", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/server/package.json b/packages/server/package.json index 86e5683dcf16..e721d1efdb96 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/server", - "version": "1.17.13", + "version": "1.17.14", "private": true, "type": "module", "license": "MIT", diff --git a/packages/session-ui/package.json b/packages/session-ui/package.json index 6c411254dceb..0419004baa1e 100644 --- a/packages/session-ui/package.json +++ b/packages/session-ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/session-ui", - "version": "1.17.13", + "version": "1.17.14", "private": true, "type": "module", "license": "MIT", diff --git a/packages/slack/package.json b/packages/slack/package.json index eb83475e9228..3758c3567a87 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.17.13", + "version": "1.17.14", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/stats/app/package.json b/packages/stats/app/package.json index 2f86203444f3..2c8503d09b79 100644 --- a/packages/stats/app/package.json +++ b/packages/stats/app/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/stats-app", - "version": "1.17.13", + "version": "1.17.14", "private": true, "type": "module", "license": "MIT", diff --git a/packages/stats/app/src/i18n.ts b/packages/stats/app/src/i18n.ts index 1cffda44306d..5f7597f1223a 100644 --- a/packages/stats/app/src/i18n.ts +++ b/packages/stats/app/src/i18n.ts @@ -210,11 +210,11 @@ const en = { "model.totalModels": "{{count}} models", "model.momentum": "Momentum", "model.vsPreviousWindow": "vs previous window", - "model.usageDescription": "Daily OpenCode Go token volume over the recent two-month window.", + "model.usageDescription": "Daily token volume.", "model.noUsageTitle": "No usage", "model.noUsageDescription": "No usage landed in the current window.", "model.dailyTokenChart": "Daily token usage chart", - "model.usersDescription": "Daily unique OpenCode Go users over the recent two-month window.", + "model.usersDescription": "Daily unique users.", "model.noUsersTitle": "No user data", "model.noUsersDescription": "No user-bearing rows landed in the current window.", "model.dailyUserChart": "Daily unique user chart", diff --git a/packages/stats/app/src/routes/[lab]/[model].tsx b/packages/stats/app/src/routes/[lab]/[model].tsx index 1b3c3cd786c8..e7f32681b707 100644 --- a/packages/stats/app/src/routes/[lab]/[model].tsx +++ b/packages/stats/app/src/routes/[lab]/[model].tsx @@ -15,7 +15,7 @@ import { type UsageRange, } from "@opencode-ai/stats-core/domain/home" import { createAsync, query, useParams } from "@solidjs/router" -import { createMemo, createSignal, For, onMount, Show, type JSX } from "solid-js" +import { createMemo, createSignal, createUniqueId, For, onMount, Show, type JSX } from "solid-js" import { getRequestEvent } from "solid-js/web" import type { FeatureCollection, GeometryObject, GeoJsonProperties } from "geojson" import type { GeometryCollection, Topology } from "topojson-specification" @@ -121,6 +121,8 @@ export default function StatsModel() { const modelHeaderLinks = createMemo(() => [ { href: "#overview", label: i18n.t("nav.overview") }, { href: "#momentum", label: i18n.t("model.momentum") }, + { href: "#usage", label: i18n.t("nav.usage") }, + { href: "#unique-users", label: i18n.t("model.uniqueUsers") }, { href: "#efficiency", label: i18n.t("nav.efficiency") }, { href: "#geo-breakdown", label: i18n.t("nav.geoBreakdown") }, { href: "#peers", label: i18n.t("nav.peers") }, @@ -183,6 +185,8 @@ export default function StatsModel() { /> + + @@ -561,6 +565,303 @@ function MomentumMetric(props: { label: string; value: string; watermark?: strin ) } +function ModelUsageSection(props: { data: StatsModelData | null }) { + const i18n = useI18n() + return ( + point.tokens} + formatValue={formatTokens} + valueUnit={i18n.t("lab.tokens")} + rowLabel={i18n.t("lab.dailyTokens")} + lineTone="muted" + /> + ) +} + +function ModelUniqueUsersSection(props: { data: StatsModelData | null }) { + const i18n = useI18n() + return ( + point.users} + formatValue={formatUsers} + valueUnit={i18n.t("format.users")} + rowLabel={i18n.t("model.uniqueUsers")} + lineTone="active" + activeLineBaseTone="muted" + highlightBars={false} + area + /> + ) +} + +function ModelTrendSection(props: { + data: StatsModelData | null + id: string + title: string + description: string + ariaLabel: string + emptyTitle: string + emptyDescription: string + value: (point: ModelUsagePoint) => number + formatValue: (value: number) => string + valueUnit: string + rowLabel: string + lineTone: "muted" | "active" + activeLineBaseTone?: "muted" | "active" + highlightBars?: boolean + area?: boolean +}) { + const activeLineClipId = createUniqueId() + const activeLineMaskId = createUniqueId() + const areaGradientId = createUniqueId() + const [activeIndex, setActiveIndex] = createSignal() + const highlightBars = createMemo(() => props.highlightBars ?? true) + const lineScale = 326 / 450 + const areaBottom = 100 / lineScale + const usage = createMemo(() => props.data?.usage ?? []) + const valueMax = createMemo(() => Math.max(0, ...usage().map((item) => props.value(item))) || 1) + const linePoints = createMemo(() => + usage().map((point, index) => ({ + point, + x: modelUsagePointX(index, usage().length), + y: modelUsageLineY(props.value(point), valueMax()), + })), + ) + const linePath = createMemo(() => modelUsageLinePath(linePoints())) + const areaPath = createMemo(() => modelUsageAreaPath(linePoints(), areaBottom)) + const activeLineBreak = createMemo(() => { + const index = activeIndex() + if (index === undefined || linePoints().length < 2) return undefined + return modelUsageColumnBounds(index, linePoints().length) + }) + const activeLineClip = createMemo(() => { + const index = activeIndex() + if (index === undefined || linePoints().length < 2) return undefined + return modelUsageColumnInnerBounds(index, linePoints().length) + }) + const activeTooltip = createMemo(() => { + const index = activeIndex() + const point = index === undefined ? undefined : usage()[index] + if (index === undefined || !point) return undefined + const bounds = modelUsageColumnBounds(index, usage().length) + return { + bounds, + index, + point, + tooltipY: (linePoints()[index]?.y ?? 100) * lineScale, + } + }) + const monthTicks = createMemo(() => modelUsageMonthTicks(usage(), props.data?.updatedAt ?? null)) + + return ( +
+ + props.value(item) > 0)} + fallback={} + > +
{ + if (event.pointerType === "touch") return + setActiveIndex(undefined) + }} + > +
+ + {(path) => ( + <> + + {(area) => ( + + )} + + + + + {(clip) => ( + + )} + + + )} + + + {(point) => ( + +
+ + {(point, index) => ( +
{ + if (event.pointerType !== "touch") return + setActiveIndex(index()) + }} + onPointerEnter={() => setActiveIndex(index())} + onPointerMove={(event) => { + if (event.pointerType === "touch") return + setActiveIndex(index()) + }} + onClick={() => setActiveIndex(index())} + onFocus={() => setActiveIndex(index())} + onBlur={() => setActiveIndex(undefined)} + onKeyDown={(event) => { + if (event.key !== "Enter" && event.key !== " ") return + event.preventDefault() + setActiveIndex(index()) + }} + > +
+
+
+
+ )} + +
+ + {(active) => ( +
usage().length * 0.62 ? "left" : "right"} + style={ + { + "--model-trend-tooltip-left": `${active.bounds.x}%`, + "--model-trend-tooltip-right": `${active.bounds.x + active.bounds.width}%`, + "--model-trend-tooltip-y": `${active.tooltipY}`, + } as JSX.CSSProperties + } + > + {formatModelUsageTooltipDate(active.point.date, props.data?.updatedAt ?? null)} + + {props.formatValue(props.value(active.point))} {props.valueUnit} + +
+

+ + {props.rowLabel} + + {props.formatValue(props.value(active.point))} +

+
+ )} + +
+ +
+ +
+ ) +} + function ModelEfficiencySection(props: { data: StatsModelData | null; catalog: ModelCatalogEntry | null }) { const i18n = useI18n() return ( @@ -1014,6 +1315,101 @@ function formatMomentumDateLabel(date: string) { return `${shortMonths[parsed.getUTCMonth()]} ${parsed.getUTCDate()}` } +function modelUsageStripHeight(value: number, max: number) { + if (value <= 0 || max <= 0) return "0px" + return `max(2px, ${(value / max) * 100}%)` +} + +function modelUsageLineY(value: number, max: number) { + if (value <= 0 || max <= 0) return 100 + return Math.max(0, 100 - (value / max) * 100) +} + +function modelUsagePointX(index: number, count: number) { + if (count <= 1) return 50 + return ((index + 0.5) / count) * 100 +} + +function modelUsageColumnBounds(index: number, count: number) { + if (count <= 0) return { x: 0, width: 100 } + return { x: (index / count) * 100, width: 100 / count } +} + +function modelUsageColumnInnerBounds(index: number, count: number) { + const bounds = modelUsageColumnBounds(index, count) + const inset = Math.min(bounds.width * 0.1, 0.24) + return { x: bounds.x + inset, width: Math.max(0.001, bounds.width - inset * 2) } +} + +type ModelUsageLinePoint = { x: number; y: number } + +function modelUsageLinePath(points: ModelUsageLinePoint[]) { + if (points.length === 0) return "" + if (points.length === 1) return `M ${formatUsagePathNumber(points[0].x)} ${formatUsagePathNumber(points[0].y)}` + + return points.slice(0, -1).reduce( + (path, point, index) => { + const next = points[index + 1] + const previous = points[index - 1] ?? point + const afterNext = points[index + 2] ?? next + const controlA = { + x: point.x + (next.x - previous.x) / 6, + y: clampUsagePercent(point.y + (next.y - previous.y) / 6), + } + const controlB = { + x: next.x - (afterNext.x - point.x) / 6, + y: clampUsagePercent(next.y - (afterNext.y - point.y) / 6), + } + return `${path} C ${formatUsagePathNumber(controlA.x)} ${formatUsagePathNumber(controlA.y)}, ${formatUsagePathNumber(controlB.x)} ${formatUsagePathNumber(controlB.y)}, ${formatUsagePathNumber(next.x)} ${formatUsagePathNumber(next.y)}` + }, + `M ${formatUsagePathNumber(points[0].x)} ${formatUsagePathNumber(points[0].y)}`, + ) +} + +function modelUsageAreaPath(points: ModelUsageLinePoint[], bottom: number) { + const path = modelUsageLinePath(points) + if (!path || points.length === 0) return "" + const first = points[0] + const last = points[points.length - 1] + return `${path} L ${formatUsagePathNumber(last.x)} ${formatUsagePathNumber(bottom)} L ${formatUsagePathNumber(first.x)} ${formatUsagePathNumber(bottom)} Z` +} + +function formatUsagePathNumber(value: number) { + return Number(value.toFixed(3)) +} + +function clampUsagePercent(value: number) { + return Math.max(0, Math.min(100, value)) +} + +function modelUsageMonthTicks(points: ModelUsagePoint[], updatedAt: string | null) { + const seen = new Set() + return points.flatMap((point, index) => { + const parsed = parseMomentumDate(point.date, updatedAt) + const label = shortMonths[parsed.getUTCMonth()] + if (!label || seen.has(label)) return [] + seen.add(label) + return [ + { + label, + left: modelUsagePointX(index, points.length), + align: index === 0 ? "start" : index >= points.length - 2 ? "end" : "center", + }, + ] + }) +} + +function formatModelUsageTooltipDate(value: string, updatedAt: string | null) { + const date = parseMomentumDate(value, updatedAt) + if (date.getUTCFullYear() === 1970) return value + return new Intl.DateTimeFormat("en", { + month: "short", + day: "numeric", + year: "numeric", + timeZone: "UTC", + }).format(date) +} + function formatRankLabel(rank: number | null) { if (rank === null) return "--" return `#${String(rank).padStart(2, "0")}` diff --git a/packages/stats/app/src/routes/index.css b/packages/stats/app/src/routes/index.css index 739b255343e4..0a7b131ceba7 100644 --- a/packages/stats/app/src/routes/index.css +++ b/packages/stats/app/src/routes/index.css @@ -4871,6 +4871,248 @@ background: var(--lab-usage-line); } +[data-page="stats"] [data-component="model-usage-chart"][data-variant="model-trend"] { + --model-trend-gap: 4px; + --model-trend-column-gutter: calc(var(--model-trend-gap) / 2); + --model-trend-bar-height: 76px; + --model-trend-line-bottom: 124px; + --model-trend-bar: #dbdbdb; + --model-trend-line: #808080; + --model-trend-active: #3b5cf6; + --model-trend-band: #f2f2f2; + grid-template-rows: minmax(0, 450px) 14px; + gap: 40px; + height: 504px; +} + +[data-page="stats"] [data-slot="model-trend-plot"] { + position: relative; + min-width: 0; + min-height: 0; + overflow: visible; +} + +[data-page="stats"] [data-slot="model-trend-plot"]::after { + position: absolute; + inset: 0; + z-index: 0; + background: linear-gradient(to bottom, transparent 42%, color-mix(in srgb, var(--stats-text) 3%, transparent)); + content: ""; + pointer-events: none; +} + +[data-page="stats"] [data-slot="model-trend-line"] { + position: absolute; + top: 0; + right: 0; + bottom: auto; + left: 0; + display: block; + width: 100%; + height: calc(100% - var(--model-trend-line-bottom)); + overflow: visible; + pointer-events: none; +} + +[data-page="stats"] [data-slot="model-trend-line"][data-layer="base"] { + z-index: 2; +} + +[data-page="stats"] [data-slot="model-trend-line"][data-layer="active"] { + z-index: 4; +} + +[data-page="stats"] [data-slot="model-trend-area-layer"] { + position: absolute; + inset: 0; + z-index: 1; + display: block; + width: 100%; + height: 100%; + overflow: visible; + pointer-events: none; +} + +[data-page="stats"] [data-slot="model-trend-area-layer"] [data-slot="model-trend-area"] { + stroke: none; +} + +[data-page="stats"] [data-slot="model-trend-line"] path { + fill: none; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 1.5; + vector-effect: non-scaling-stroke; +} + +[data-page="stats"] [data-slot="model-trend-line-base"] { + stroke: var(--model-trend-line); +} + +[data-page="stats"] [data-slot="model-trend-line-base"][data-tone="active"] { + stroke: var(--model-trend-active); +} + +[data-page="stats"] [data-slot="model-trend-line-active"] { + stroke: var(--model-trend-active); +} + +[data-page="stats"] [data-slot="model-trend-end-marker"] { + position: absolute; + top: var(--model-trend-end-top); + left: var(--model-trend-end-x); + z-index: 3; + width: 6px; + height: 6px; + background: var(--model-trend-bar); + transform: translate(-50%, -50%); + pointer-events: none; +} + +[data-page="stats"] [data-slot="model-trend-end-marker"][data-tone="active"] { + background: var(--model-trend-active); +} + +[data-page="stats"] [data-slot="model-trend-bars"] { + position: absolute; + inset: 0; + z-index: 3; + display: flex; + gap: 0; + min-width: 0; +} + +[data-page="stats"] [data-slot="model-trend-column"] { + position: relative; + box-sizing: border-box; + flex: 1 1 0; + min-width: 0; + height: 100%; + outline: none; + cursor: pointer; +} + +[data-page="stats"] [data-slot="model-trend-column"]::before { + position: absolute; + inset: 0; + z-index: 1; + background: transparent; + content: ""; + pointer-events: none; + transition: background 120ms ease; +} + +[data-page="stats"] [data-slot="model-trend-column"][data-active="true"]::before, +[data-page="stats"] [data-slot="model-trend-column"]:focus-visible::before { + background: var(--model-trend-band); + box-shadow: + inset var(--model-trend-column-gutter) 0 0 var(--stats-bg), + inset calc(var(--model-trend-column-gutter) * -1) 0 0 var(--stats-bg); +} + +[data-page="stats"] [data-slot="model-trend-token-band"] { + position: absolute; + right: var(--model-trend-column-gutter); + bottom: 0; + left: var(--model-trend-column-gutter); + z-index: 2; + height: var(--model-trend-bar-height); +} + +[data-page="stats"] [data-slot="model-trend-token-bar"] { + position: absolute; + right: 0; + bottom: 0; + left: 0; + height: var(--model-trend-token-height); + background: var(--model-trend-bar); + transition: + background 120ms ease, + opacity 120ms ease; +} + +[data-page="stats"] + [data-component="model-usage-chart"][data-highlight-bars="true"] + [data-slot="model-trend-column"][data-active="true"] + [data-slot="model-trend-token-bar"] { + background: var(--model-trend-active); +} + +[data-page="stats"] + [data-component="model-usage-chart"][data-highlight-bars="true"] + [data-slot="model-trend-column"][data-muted="true"] + [data-slot="model-trend-token-bar"] { + opacity: 0.64; +} + +[data-page="stats"] [data-slot="model-trend-months"] { + position: relative; + height: 14px; + color: var(--stats-muted); + font-size: 11px; + font-weight: 500; + line-height: 14px; +} + +[data-page="stats"] [data-slot="model-trend-months"] span { + position: absolute; + top: 0; + left: var(--model-trend-month-left); + transform: translateX(-50%); +} + +[data-page="stats"] [data-slot="model-trend-months"] span[data-align="start"] { + transform: none; +} + +[data-page="stats"] [data-slot="model-trend-months"] span[data-align="end"] { + transform: translateX(-100%); +} + +[data-page="stats"] [data-component="model-usage-chart"][data-variant="model-trend"] [data-component="chart-tooltip"] { + z-index: 6; + top: clamp(12px, calc(var(--model-trend-tooltip-y) * 1% - 46px), calc(100% - 108px)); + width: 192px; + min-width: 192px; + background: var(--stats-bg); +} + +[data-page="stats"] + [data-component="model-usage-chart"][data-variant="model-trend"] + [data-component="chart-tooltip"][data-placement="right"] { + right: auto; + left: calc(var(--model-trend-tooltip-right) + 8px); +} + +[data-page="stats"] + [data-component="model-usage-chart"][data-variant="model-trend"] + [data-component="chart-tooltip"][data-placement="left"] { + right: calc(100% - var(--model-trend-tooltip-left) + 8px); + left: auto; +} + +[data-page="stats"] + [data-component="model-usage-chart"][data-variant="model-trend"] + [data-component="chart-tooltip"] + p { + height: 16px; + margin: 8px 0; +} + +[data-page="stats"] + [data-component="model-usage-chart"][data-variant="model-trend"] + [data-component="chart-tooltip"] + i[data-kind="tokens"] { + background: var(--model-trend-active); +} + +[data-page="stats"] + [data-component="model-usage-chart"][data-variant="model-trend"] + [data-component="chart-tooltip"] + i[data-kind="users"] { + background: var(--model-trend-active); +} + [data-page="stats"] [data-component="model-peer-list"] { display: grid; gap: 8px; @@ -4966,6 +5208,15 @@ --lab-usage-band: #242424; } +[data-page="stats"][data-theme="dark"] [data-component="model-usage-chart"][data-variant="model-trend"], +:root[data-stats-theme="dark"] + [data-page="stats"]:not([data-theme="light"]) + [data-component="model-usage-chart"][data-variant="model-trend"] { + --model-trend-bar: #3a3a3a; + --model-trend-line: #a3a3a3; + --model-trend-band: #242424; +} + [data-page="stats"][data-theme="dark"] [data-component="chart-tooltip"], :root[data-stats-theme="dark"] [data-page="stats"]:not([data-theme="light"]) [data-component="chart-tooltip"] { background: #242424f2; @@ -5131,6 +5382,14 @@ --lab-usage-band: #242424; } + :root:not([data-stats-theme="light"]) + [data-page="stats"]:not([data-theme="light"]) + [data-component="model-usage-chart"][data-variant="model-trend"] { + --model-trend-bar: #3a3a3a; + --model-trend-line: #a3a3a3; + --model-trend-band: #242424; + } + :root:not([data-stats-theme="light"]) [data-page="stats"]:not([data-theme="light"]) [data-component="chart-tooltip"] { background: #242424f2; } @@ -5229,15 +5488,27 @@ [data-page="stats"][data-theme="dark"] [data-component="model-usage-chart"][data-variant="lab-usage"] [data-component="chart-tooltip"], +[data-page="stats"][data-theme="dark"] + [data-component="model-usage-chart"][data-variant="model-trend"] + [data-component="chart-tooltip"], :root[data-stats-theme="dark"] [data-page="stats"]:not([data-theme="light"]) [data-component="model-usage-chart"][data-variant="lab-usage"] [data-component="chart-tooltip"], +:root[data-stats-theme="dark"] + [data-page="stats"]:not([data-theme="light"]) + [data-component="model-usage-chart"][data-variant="model-trend"] + [data-component="chart-tooltip"], :root:not([data-stats-theme="light"]) [data-page="stats"]:not([data-theme="light"]) [data-component="model-usage-chart"][data-variant="lab-usage"] [data-component="chart-tooltip"], -[data-page="stats"] [data-component="model-usage-chart"][data-variant="lab-usage"] [data-component="chart-tooltip"] { +:root:not([data-stats-theme="light"]) + [data-page="stats"]:not([data-theme="light"]) + [data-component="model-usage-chart"][data-variant="model-trend"] + [data-component="chart-tooltip"], +[data-page="stats"] [data-component="model-usage-chart"][data-variant="lab-usage"] [data-component="chart-tooltip"], +[data-page="stats"] [data-component="model-usage-chart"][data-variant="model-trend"] [data-component="chart-tooltip"] { background: var(--stats-bg); } @@ -5514,6 +5785,13 @@ margin-top: 16px; } + [data-page="stats"] + [data-component="model-usage-chart"][data-variant="model-trend"] + [data-component="chart-tooltip"] { + position: absolute; + margin-top: 0; + } + [data-page="stats"] [data-component="footer"] { padding: 88px 24px 24px; } @@ -5935,6 +6213,42 @@ min-width: 0; } + [data-page="stats"] [data-component="model-usage-chart"][data-variant="model-trend"] { + --model-usage-mobile-bar-width: 20px; + --model-usage-mobile-edge-space: 48px; + --model-usage-mobile-track-width: calc( + var(--model-usage-count) * var(--model-usage-mobile-bar-width) + var(--model-usage-mobile-edge-space) + ); + --model-trend-bar-height: 64px; + --model-trend-line-bottom: 104px; + grid-template-rows: minmax(0, 360px) 14px; + gap: 24px; + height: 398px; + } + + [data-page="stats"] [data-component="model-usage-chart"][data-variant="model-trend"] [data-slot="model-trend-plot"], + [data-page="stats"] + [data-component="model-usage-chart"][data-variant="model-trend"] + [data-slot="model-trend-months"] { + direction: ltr; + width: var(--model-usage-mobile-track-width); + min-width: var(--model-usage-mobile-track-width); + } + + [data-page="stats"] + [data-component="model-usage-chart"][data-variant="model-trend"] + [data-slot="model-trend-column"] { + flex: 0 0 var(--model-usage-mobile-bar-width); + } + + [data-page="stats"] + [data-component="model-usage-chart"][data-variant="model-trend"] + [data-component="chart-tooltip"] { + top: auto; + width: auto; + min-width: 0; + } + [data-page="stats"] [data-component="model-peer-list"] a { grid-template-columns: 30px minmax(0, 1fr) minmax(64px, auto); } diff --git a/packages/stats/core/package.json b/packages/stats/core/package.json index de620c74ac1a..bd2f14470a83 100644 --- a/packages/stats/core/package.json +++ b/packages/stats/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/stats-core", - "version": "1.17.13", + "version": "1.17.14", "private": true, "type": "module", "license": "MIT", diff --git a/packages/stats/server/package.json b/packages/stats/server/package.json index 9e09a8c99431..0ec2927c703d 100644 --- a/packages/stats/server/package.json +++ b/packages/stats/server/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/stats-server", - "version": "1.17.13", + "version": "1.17.14", "private": true, "type": "module", "license": "MIT", diff --git a/packages/tui/package.json b/packages/tui/package.json index 9a830c017d60..12c61b1da5aa 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/tui", - "version": "1.17.13", + "version": "1.17.14", "private": true, "type": "module", "license": "MIT", diff --git a/packages/tui/src/component/dialog-provider.tsx b/packages/tui/src/component/dialog-provider.tsx index 2767508eb2d2..0fd51e3c1c71 100644 --- a/packages/tui/src/component/dialog-provider.tsx +++ b/packages/tui/src/component/dialog-provider.tsx @@ -366,8 +366,8 @@ function ApiMethod(props: ApiMethodProps) { + ({ opencode: ( @@ -390,7 +390,7 @@ function ApiMethod(props: ApiMethodProps) { ), - }[props.providerID] ?? undefined + })[props.providerID] ?? undefined } onConfirm={async (value) => { if (!value) return diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 6df6b00d2d11..6d77b0ea58fd 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -1528,7 +1528,7 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las customBorderChars={SplitBorder.customBorderChars} borderColor={theme.error} > - {props.message.error?.data.message} + {errorMessage(props.message.error)}
diff --git a/packages/tui/src/ui/dialog-prompt.tsx b/packages/tui/src/ui/dialog-prompt.tsx index d627ea2970d9..f518fb2950b7 100644 --- a/packages/tui/src/ui/dialog-prompt.tsx +++ b/packages/tui/src/ui/dialog-prompt.tsx @@ -83,7 +83,7 @@ export function DialogPrompt(props: DialogPromptProps) { - {props.description} + {props.description?.()}