Compare commits

..
13 Commits
Author SHA1 Message Date
Aiden Cline f90beeb9b8 fix(core): say plainly that catalog tools and search only work inside execute (#50384) 2026-09-21 11:13:33 -05:00
Adam 4b9a3d80fc feat(core): enforce Console-managed policies (#49729) 2026-09-21 11:10:57 -05:00
Aiden Cline 1316576720 refactor(codemode): type program values and let built-ins answer for themselves (#50191) 2026-09-21 11:03:16 -05:00
opencode-agent[bot]andnexxeln 2f0c861af0 fix(cli): show actionable upgrade errors (#50346)
Co-authored-by: nexxeln <95541290+nexxeln@users.noreply.github.com>
2026-09-21 19:09:56 +05:30
opencode-agent[bot] 4d94777d4d chore(core): refresh bundled models.dev snapshot 2026-09-21 12:19:57 +00:00
opencode-agent[bot] 46ebde65e9 chore: update nix node_modules hashes 2026-09-21 11:58:21 +00:00
Luke Parker 932c12ad1d chore(desktop): upgrade Electron to 44.4.3 (#50327) 2026-09-21 21:33:49 +10:00
opencode 6f655dcbab sync release versions for v2.0.12 2026-09-21 09:57:01 +00:00
Luke Parker ab60f08c69 fix(app): open Review pane for browser focus in background sessions (#50284) 2026-09-21 06:08:36 +00:00
Luke Parker 60ed84ecd1 feat(app): open agent-referenced files as rich artifact tabs (#49882) 2026-09-21 15:20:15 +10:00
Luke Parker 8aebed170a fix(app): show empty review state for projects without VCS (#50265) 2026-09-21 14:54:47 +10:00
14148a0ea4 fix(app): restore server status spacing (#50273)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-09-21 12:37:19 +08:00
Aiden Cline 7f51fbd878 feat(codemode): rank whole-word path matches above substring matches in search (#50275) 2026-09-20 22:59:01 -05:00
167 changed files with 3468 additions and 1184 deletions
+39 -39
View File
@@ -32,7 +32,7 @@
},
"packages/ai": {
"name": "@opencode/ai",
"version": "2.0.11",
"version": "2.0.12",
"dependencies": {
"@aws-sdk/credential-providers": "3.1057.0",
"@opencode/schema": "workspace:*",
@@ -54,7 +54,7 @@
},
"packages/app": {
"name": "@opencode/app",
"version": "2.0.11",
"version": "2.0.12",
"dependencies": {
"@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0",
@@ -111,7 +111,7 @@
},
"packages/cli": {
"name": "@opencode/cli",
"version": "2.0.11",
"version": "2.0.12",
"bin": {
"opencode2": "./bin/opencode2.cjs",
},
@@ -175,7 +175,7 @@
},
"packages/client": {
"name": "@opencode/client",
"version": "2.0.11",
"version": "2.0.12",
"dependencies": {
"@opencode/protocol": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -201,7 +201,7 @@
},
"packages/codemode": {
"name": "@opencode/codemode",
"version": "2.0.11",
"version": "2.0.12",
"dependencies": {
"acorn": "8.15.0",
"effect": "catalog:",
@@ -214,7 +214,7 @@
},
"packages/console/app": {
"name": "@opencode/console-app",
"version": "2.0.11",
"version": "2.0.12",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -250,7 +250,7 @@
},
"packages/console/core": {
"name": "@opencode/console-core",
"version": "2.0.11",
"version": "2.0.12",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -277,7 +277,7 @@
},
"packages/console/function": {
"name": "@opencode/console-function",
"version": "2.0.11",
"version": "2.0.12",
"dependencies": {
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opencode/console-core": "workspace:*",
@@ -294,7 +294,7 @@
},
"packages/console/mail": {
"name": "@opencode/console-mail",
"version": "2.0.11",
"version": "2.0.12",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -318,7 +318,7 @@
},
"packages/console/support": {
"name": "@opencode/console-support",
"version": "2.0.11",
"version": "2.0.12",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@opencode/console-core": "workspace:*",
@@ -338,7 +338,7 @@
},
"packages/core": {
"name": "@opencode/core",
"version": "2.0.11",
"version": "2.0.12",
"dependencies": {
"@ai-sdk/cohere": "3.0.27",
"@ai-sdk/gateway": "3.0.104",
@@ -406,10 +406,10 @@
},
"packages/desktop": {
"name": "@opencode/desktop",
"version": "2.0.11",
"version": "2.0.12",
"dependencies": {
"@zip.js/zip.js": "2.7.62",
"electron-context-menu": "4.1.2",
"electron-context-menu": "5.0.0",
"electron-log": "^5",
"electron-updater": "6.8.9",
"lighthouse": "13.4.1",
@@ -436,7 +436,7 @@
"drizzle-kit": "catalog:",
"drizzle-orm": "catalog:",
"effect": "catalog:",
"electron": "42.10.1",
"electron": "44.4.3",
"electron-builder": "26.15.7",
"electron-vite": "6.0.0-beta.1",
"puppeteer-core": "25.9.0",
@@ -455,7 +455,7 @@
},
"packages/enterprise": {
"name": "@opencode/enterprise",
"version": "2.0.11",
"version": "2.0.12",
"dependencies": {
"@hono/standard-validator": "catalog:",
"@opencode-ai/sdk": "1.18.21",
@@ -492,7 +492,7 @@
},
"packages/function": {
"name": "@opencode/function",
"version": "2.0.11",
"version": "2.0.12",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -508,7 +508,7 @@
},
"packages/http-recorder": {
"name": "@opencode/http-recorder",
"version": "2.0.11",
"version": "2.0.12",
"dependencies": {
"@effect/platform-node-shared": "4.0.0-rc.112",
},
@@ -527,7 +527,7 @@
},
"packages/httpapi-codegen": {
"name": "@opencode/httpapi-codegen",
"version": "2.0.11",
"version": "2.0.12",
"dependencies": {
"effect": "catalog:",
"prettier": "3.6.2",
@@ -540,7 +540,7 @@
},
"packages/latex": {
"name": "@opencode/latex",
"version": "2.0.11",
"version": "2.0.12",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -554,7 +554,7 @@
},
"packages/merman": {
"name": "@opencode/merman",
"version": "2.0.11",
"version": "2.0.12",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -569,7 +569,7 @@
},
"packages/plugin": {
"name": "@opencode/plugin",
"version": "2.0.11",
"version": "2.0.12",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@opencode/ai": "workspace:*",
@@ -608,7 +608,7 @@
},
"packages/plugin-browser": {
"name": "@opencode/plugin-browser",
"version": "2.0.11",
"version": "2.0.12",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -638,7 +638,7 @@
},
"packages/protocol": {
"name": "@opencode/protocol",
"version": "2.0.11",
"version": "2.0.12",
"dependencies": {
"@opencode/schema": "workspace:*",
"effect": "catalog:",
@@ -653,7 +653,7 @@
},
"packages/schema": {
"name": "@opencode/schema",
"version": "2.0.11",
"version": "2.0.12",
"dependencies": {
"@standard-schema/spec": "catalog:",
"effect": "catalog:",
@@ -677,7 +677,7 @@
},
"packages/sdk": {
"name": "@opencode/sdk",
"version": "2.0.11",
"version": "2.0.12",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -698,7 +698,7 @@
},
"packages/server": {
"name": "@opencode/server",
"version": "2.0.11",
"version": "2.0.12",
"dependencies": {
"@effect/platform-node": "catalog:",
"@effect/platform-node-shared": "catalog:",
@@ -720,7 +720,7 @@
},
"packages/session-ui": {
"name": "@opencode/session-ui",
"version": "2.0.11",
"version": "2.0.12",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode/client": "workspace:*",
@@ -755,7 +755,7 @@
},
"packages/simulation": {
"name": "@opencode/simulation",
"version": "2.0.11",
"version": "2.0.12",
"dependencies": {
"@opencode/ai": "workspace:*",
"@opencode/core": "workspace:*",
@@ -775,7 +775,7 @@
},
"packages/stats/app": {
"name": "@opencode/stats-app",
"version": "2.0.11",
"version": "2.0.12",
"dependencies": {
"@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:",
@@ -809,7 +809,7 @@
},
"packages/stats/core": {
"name": "@opencode/stats-core",
"version": "2.0.11",
"version": "2.0.12",
"dependencies": {
"@aws-sdk/client-athena": "3.933.0",
"@planetscale/database": "1.19.0",
@@ -828,7 +828,7 @@
},
"packages/stats/server": {
"name": "@opencode/stats-server",
"version": "2.0.11",
"version": "2.0.12",
"dependencies": {
"@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:",
@@ -874,7 +874,7 @@
},
"packages/theme": {
"name": "@opencode/theme",
"version": "2.0.11",
"version": "2.0.12",
"dependencies": {
"@opentui/core": "catalog:",
"effect": "catalog:",
@@ -888,7 +888,7 @@
},
"packages/tui": {
"name": "@opencode/tui",
"version": "2.0.11",
"version": "2.0.12",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -923,7 +923,7 @@
},
"packages/ui": {
"name": "@opencode/ui",
"version": "2.0.11",
"version": "2.0.12",
"dependencies": {
"@kobalte/core": "catalog:",
"@pierre/diffs": "catalog:",
@@ -958,7 +958,7 @@
},
"packages/util": {
"name": "@opencode/util",
"version": "2.0.11",
"version": "2.0.12",
"dependencies": {
"@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:",
@@ -991,7 +991,7 @@
},
"packages/web": {
"name": "@opencode/web",
"version": "2.0.11",
"version": "2.0.12",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -1032,7 +1032,7 @@
},
"services/update": {
"name": "@opencode/update",
"version": "2.0.11",
"version": "2.0.12",
"dependencies": {
"jose": "6.0.11",
"semver": "catalog:",
@@ -3880,13 +3880,13 @@
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
"electron": ["electron@42.10.1", "", { "dependencies": { "@electron-internal/extract-zip": "^1.0.1", "@electron/get": "^5.0.0", "@types/node": "^24.9.0" }, "bin": { "electron": "cli.js", "install-electron": "install.js" } }, "sha512-ITc1HPeoDzsxCCaH6MFsN67Nq2nUiJf5N9pBWxzhUpFfKJF0IwiAkKT3emg1lPbvC4OfFE+Cdwe4vhgdOZ1YKg=="],
"electron": ["electron@44.4.3", "", { "dependencies": { "@electron-internal/extract-zip": "^1.0.1", "@electron/get": "^5.0.0", "@types/node": "^24.9.0" }, "bin": { "electron": "cli.js", "install-electron": "install.js" } }, "sha512-LTpSFTB40qVCXIX5xMo+cgHI/Jjkbjw7VpB26PccEbroqOn72LBukeaDwPVo1fBYzSzs0c9iPuAucFCO7Tw81Q=="],
"electron-builder": ["electron-builder@26.15.7", "", { "dependencies": { "app-builder-lib": "26.15.7", "builder-util": "26.15.3", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "ci-info": "^4.2.0", "dmg-builder": "26.15.7", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "simple-update-notifier": "2.0.0", "yargs": "^17.6.2" }, "bin": { "electron-builder": "./cli.js", "install-app-deps": "./install-app-deps.js" } }, "sha512-DBpaNzxsPs1BvEblzFoNriSbzsBqDCy/gseIngeEhYzQG1IxfB7Hvc2tBBVmpWE2BTQGP9J1RrAvDT+Vc/uAxg=="],
"electron-builder-squirrel-windows": ["electron-builder-squirrel-windows@26.15.7", "", { "dependencies": { "app-builder-lib": "26.15.7", "builder-util": "26.15.3", "electron-winstaller": "5.4.0" } }, "sha512-B4uvn2NzFSuf084udWqugludFull6CRJiWe2dLzMnZLl6G5hdAGk0fsBMGlBSpKjvQCJn8IPc+S7OnJ+GXqwLA=="],
"electron-context-menu": ["electron-context-menu@4.1.2", "", { "dependencies": { "cli-truncate": "^4.0.0", "electron-dl": "^4.0.0", "electron-is-dev": "^3.0.1" } }, "sha512-9xYTUV0oRqKL50N9W71IrXNdVRB0LuBp3R1zkUdUc2wfIa2/QZwYYj5RLuO7Tn7ZSLVIaO3X6u+EIBK+cBvzrQ=="],
"electron-context-menu": ["electron-context-menu@5.0.0", "", { "dependencies": { "cli-truncate": "^4.0.0", "electron-dl": "^4.0.0", "electron-is-dev": "^3.0.1" } }, "sha512-rgFpRtwY0/rhsRCoz9rE6VM4WueEsLbIpca7ucOhERVrbGB2dQrxa9xwBNLplU54jRgPuv6nTEbxoplE5bzy2A=="],
"electron-dl": ["electron-dl@4.0.0", "", { "dependencies": { "ext-name": "^5.0.0", "pupa": "^3.1.0", "unused-filename": "^4.0.1" } }, "sha512-USiB9816d2JzKv0LiSbreRfTg5lDk3lWh0vlx/gugCO92ZIJkHVH0UM18EHvKeadErP6Xn4yiTphWzYfbA2Ong=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-8mOzCscBAuogG4tm8CroqjTX5E5yzCvTobbhKvxQP0U=",
"aarch64-linux": "sha256-drrRSpzxC8bfaTXBpOyaN0QAyBVV7WJzm0NstJ+8sAE=",
"aarch64-darwin": "sha256-YY5A/zxLPONvgnI+DZlzcD2K5Q9PIw4F2YbDxKbT4UU=",
"x86_64-darwin": "sha256-/c/Ew4onA+9+l6GRKXz3zWq3QFESM7j5RyGLCWRPQFw="
"x86_64-linux": "sha256-/jah4P2a0aGJNJ0aMdlFEbGHAXx33lqxHbc7UmpLFDg=",
"aarch64-linux": "sha256-L3SoZ24qNXicsE2FK6LATQjOmjPxY679RjugrUyO/1Y=",
"aarch64-darwin": "sha256-pI9NT8KWUPi+JCk6DYMqIAYmBqTbi13uL4VdNV3WS6Y=",
"x86_64-darwin": "sha256-rMAGhTTz46KA5Ya7E5J0af7Bn1QzTDhTaNfNJm8qfsw="
}
}
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "https://json.schemastore.org/package.json",
"name": "opencode",
"description": "AI-powered development tool",
"version": "2.0.11",
"version": "2.0.12",
"private": true,
"type": "module",
"packageManager": "bun@1.4.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.11",
"version": "2.0.12",
"name": "@opencode/ai",
"type": "module",
"license": "MIT",
@@ -17,14 +17,12 @@ const milestones = [
"electronSpawnStarted",
"debugEndpointReady",
"electronStarted",
"layersReady",
"windowVisible",
"serviceEnsureStarted",
"serviceSpawnRequested",
"serviceReady",
"backgroundLoadingReady",
"rendererViteConnected",
"rendererInitializationStarted",
"rendererInitializationReady",
"windowVisible",
"homeReady",
] as const
const phases = [
@@ -128,14 +126,12 @@ const milestonePatterns: ReadonlyArray<{ name: Milestone; text: string }> = [
{ name: "electronSpawnStarted", text: "starting electron app..." },
{ name: "debugEndpointReady", text: "DevTools listening on ws://" },
{ name: "electronStarted", text: "app starting" },
{ name: "layersReady", text: "layers ready" },
{ name: "windowVisible", text: "main window visible" },
{ name: "serviceEnsureStarted", text: "starting v2 background service" },
{ name: "serviceSpawnRequested", text: "v2 CLI background service starting" },
{ name: "serviceReady", text: "v2 CLI background service ready" },
{ name: "backgroundLoadingReady", text: "loading task finished" },
{ name: "rendererViteConnected", text: "[vite] connected." },
{ name: "rendererInitializationStarted", text: "awaiting server ready" },
{ name: "rendererInitializationReady", text: "server ready" },
{ name: "windowVisible", text: "main window visible" },
]
async function createColdProfile() {
@@ -404,14 +400,12 @@ function requireMilestones(observed: Partial<Record<Milestone, number>>) {
electronSpawnStarted: get("electronSpawnStarted"),
debugEndpointReady: get("debugEndpointReady"),
electronStarted: get("electronStarted"),
layersReady: get("layersReady"),
windowVisible: get("windowVisible"),
serviceEnsureStarted: get("serviceEnsureStarted"),
serviceSpawnRequested: get("serviceSpawnRequested"),
serviceReady: get("serviceReady"),
backgroundLoadingReady: get("backgroundLoadingReady"),
rendererViteConnected: get("rendererViteConnected"),
rendererInitializationStarted: get("rendererInitializationStarted"),
rendererInitializationReady: get("rendererInitializationReady"),
windowVisible: get("windowVisible"),
homeReady: get("homeReady"),
}
}
@@ -20,14 +20,12 @@ describe("desktop startup benchmark", () => {
["electronSpawnStarted", "starting electron app..."],
["debugEndpointReady", "DevTools listening on ws://"],
["electronStarted", "app starting"],
["layersReady", "layers ready"],
["windowVisible", "main window visible"],
["serviceEnsureStarted", "starting v2 background service"],
["serviceSpawnRequested", "v2 CLI background service starting"],
["serviceReady", "v2 CLI background service ready"],
["backgroundLoadingReady", "loading task finished"],
["rendererViteConnected", "[vite] connected."],
["rendererInitializationStarted", "awaiting server ready"],
["rendererInitializationReady", "server ready"],
["windowVisible", "main window visible"],
] as const
cases.forEach(([milestone, line]) => {
expect(milestoneForLine(`\u001b[32m${line}\u001b[39m`)).toBe(milestone)
@@ -58,14 +56,12 @@ function sample(run: number, commandToHomeReadyMs: number): DesktopStartupSample
electronSpawnStarted: 7,
debugEndpointReady: 8,
electronStarted: 9,
serviceEnsureStarted: 10,
serviceSpawnRequested: 11,
serviceReady: 12,
backgroundLoadingReady: 13,
rendererViteConnected: 14,
rendererInitializationStarted: 15,
rendererInitializationReady: 16,
windowVisible: 17,
layersReady: 10,
windowVisible: 11,
serviceEnsureStarted: 12,
serviceSpawnRequested: 13,
serviceReady: 14,
rendererViteConnected: 15,
homeReady: commandToHomeReadyMs,
}
return {
@@ -80,8 +76,8 @@ function sample(run: number, commandToHomeReadyMs: number): DesktopStartupSample
electronStartup: 2,
serviceSpawnWait: 1,
serviceProcessStartup: 1,
rendererStartup: commandToHomeReadyMs - 14,
visibleWindowToHome: commandToHomeReadyMs - 17,
rendererStartup: commandToHomeReadyMs - 15,
visibleWindowToHome: commandToHomeReadyMs - 11,
},
service: { version: "2.0.0-local-test", url: "http://127.0.0.1:3000", pid: run },
}
@@ -71,7 +71,7 @@ for (const direction of ["ltr", "rtl"] as const) {
const shifted = await content.evaluate((element) => getComputedStyle(element).translate)
await content.evaluate((element) => element.setAttribute("data-summary-motion", ""))
// Keep issuing resize events before the idle timer expires, including crossing the width cutoff.
for (const width of [1520, 1280, 1600]) {
for (const width of [1520, 1280, 1800]) {
await page.setViewportSize({ width, height: 900 })
await expect(panel).toHaveAttribute("data-summary-resizing", "true")
await page.clock.runFor(100)
@@ -84,6 +84,7 @@ for (const direction of ["ltr", "rtl"] as const) {
await expect(content).toHaveAttribute("data-summary-motion", "transitionrun,transitionend,")
await expect(content).not.toHaveCSS("translate", shifted)
await page.setViewportSize({ width: 1440, height: 900 })
await expect(panel).toHaveAttribute("data-summary-resizing", "false")
await expect(content).toHaveCSS("translate", shifted)
await content.evaluate((element) => element.setAttribute("data-summary-motion", ""))
@@ -112,7 +113,8 @@ for (const direction of ["ltr", "rtl"] as const) {
await expect(summary).toBeVisible()
await page.setViewportSize({ width: 1800, height: 900 })
await expect(content).toHaveCSS("translate", "0px")
await expect(panel).toHaveAttribute("data-summary-resizing", "false")
await expect(content).toHaveCSS("translate", "none")
await expect(summary).toBeVisible()
await page.emulateMedia({ reducedMotion: "reduce" })
@@ -116,7 +116,7 @@ test("single-server settings expose scoped pages without a server picker", async
await expect(connection.getByRole("heading", { name: "Connection", exact: true })).toBeVisible()
await expect(connection.locator('[data-component="settings-list"]')).toHaveCSS("padding-left", "16px")
await expect(connection.locator(".settings-servers-row")).toHaveCSS("padding-top", "20px")
await expect(connection.locator(".settings-servers-lead")).toHaveCSS("column-gap", "4px")
await expect(connection.locator(".settings-servers-lead")).toHaveCSS("column-gap", "10px")
await expect(connection.locator(".settings-servers-copy")).toHaveCSS("row-gap", "6px")
await expect(settings.getByRole("heading", { name: "Preferences", exact: true })).toBeVisible()
await expect(settings.getByText("Terminal shell", { exact: true })).toBeVisible()
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/app",
"version": "2.0.11",
"version": "2.0.12",
"description": "",
"type": "module",
"exports": {
+10
View File
@@ -625,6 +625,16 @@ export const dict = {
"toast.file.loadFailed.title": "Failed to load file",
"file.error.notFound": "File not found: {{name}}",
"toast.file.listFailed.title": "Failed to list files",
"file.view.preview": "Preview",
"file.view.source": "Source",
"file.view.openInBrowser": "Open in browser",
"file.view.binary": "Binary file · {{size}}",
"file.view.table.rows.one": "{{count}} row",
"file.view.table.rows.other": "{{count}} rows",
"file.view.table.columns.one": "{{count}} column",
"file.view.table.columns.other": "{{count}} columns",
"file.view.table.truncated": "Showing the first {{shown}} of {{total}} rows.",
"file.view.fontSample": "Sphinx of black quartz, judge my vow.",
"toast.context.noLineSelection.title": "No line selection",
"toast.context.noLineSelection.description": "Select a line range in a file tab first.",
@@ -19,6 +19,7 @@ export type BrowserPaneCommand = Browser.Action
export type BrowserPaneState = Browser.State | null
export type BrowserPaneEvent =
| { type: "focus"; tabID: Browser.TabID }
| { type: "preview"; path: string }
| { type: "state"; state: BrowserPaneState; error?: string }
export type BrowserPaneRegistration = {
+2
View File
@@ -33,6 +33,8 @@ export type FileContent = {
}
encoding?: "base64"
mimeType?: string
/** On-disk size when the bytes themselves are not retained. */
size?: number
}
export type Path = {
+25 -11
View File
@@ -1,14 +1,15 @@
import { createEffect, createMemo, getOwner, on, onCleanup, runWithOwner } from "solid-js"
import { batch, createEffect, createMemo, createRoot, getOwner, on, onCleanup, runWithOwner } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { createSimpleContext } from "@opencode/ui/context"
import type { Browser } from "@opencode/plugin-browser/rpc"
import { useLanguage } from "@/runtime/i18n/language"
import type { BrowserPaneCommand } from "@/runtime/platform/browser-pane"
import { usePlatform } from "@/runtime/platform/platform"
import type { useServer } from "@/runtime/server/current"
import type { SessionStateKey } from "@/runtime/server/scope"
import { useSettings } from "@/settings/model"
import { findSessionTab, tabKey, useTabs } from "@/shell/tabs/tabs"
import { useCurrentRoute } from "@/shell/state/layout"
import { useCurrentRoute, useLayout } from "@/shell/state/layout"
import { sessionBrowserTab } from "@/shell/state/session-tabs"
import { createEventListener } from "@solid-primitives/event-listener"
import { createBrowserConnection, type BrowserConnectionState } from "./connection"
@@ -35,13 +36,14 @@ export const { use: useBrowserAttachments, provider: BrowserAttachmentsProvider
const settings = useSettings()
const language = useLanguage()
const shellTabs = useTabs()
const layout = useLayout()
const route = useCurrentRoute()
const owner = getOwner()
const [store, setStore] = createStore<Record<string, BrowserAttachment | undefined>>({})
// Servers whose plugin lacks the browser RPC; sessions on them stop retrying.
const [unsupported, setUnsupported] = createStore<Record<string, true | undefined>>({})
const live = new Map<string, Live>()
const focus = new Map<string, Set<(tabID: Browser.TabID) => void>>()
const preview = new Map<string, Set<(path: string) => void>>()
const key = (server: Server, sessionID: string) => `${server.key}\n${sessionID}`
const enabled = createMemo(
() => !!platform.browserPane && settings.ready() && settings.general.experimentalBrowser(),
@@ -87,11 +89,14 @@ export const { use: useBrowserAttachments, provider: BrowserAttachmentsProvider
enabled,
supported: (server: Server) => !unsupported[server.key],
state: (server: Server, sessionID: string) => store[key(server, sessionID)],
attach(server: Server, sessionID: string) {
attach(server: Server, sessionID: string, sessionKey: SessionStateKey) {
const id = key(server, sessionID)
if (live.has(id)) return
const pane = platform.browserPane
if (!pane || !enabled() || unsupported[server.key] || server.health?.incompatible) return
// Focus requests write to the owning session's layout even while another shell tab is routed,
// so the Review pane and browser tab are already selected when the user returns to it.
const tabs = createRoot((dispose) => ({ dispose, layout: layout.tabs(sessionKey) }), owner)
const connection = createBrowserConnection({
pane,
// Resolve the current port at every wake, including after sidecar replacement.
@@ -100,7 +105,15 @@ export const { use: useBrowserAttachments, provider: BrowserAttachmentsProvider
sessionID,
endpoint: { ...server.conn.http, url: server.ctx.sdk.url },
}),
focus: (tabID) => focus.get(id)?.forEach((listener) => listener(tabID)),
focus: (tabID) => {
const tab = sessionBrowserTab(tabID)
batch(() => {
shellTabs.setPane(findSessionTab(shellTabs.store, server.key, sessionID), "review", true)
if (!tabs.layout.all().includes(tab)) tabs.layout.setAll([...tabs.layout.all(), tab])
tabs.layout.setActive(tab)
})
},
preview: (path) => preview.get(id)?.forEach((listener) => listener(path)),
change: (state) => {
if (state.error === "browser.pane.unsupported") {
setUnsupported(server.key, true)
@@ -138,17 +151,18 @@ export const { use: useBrowserAttachments, provider: BrowserAttachmentsProvider
entry.dispose = () => {
unsubscribe?.forEach((dispose) => dispose())
connection.dispose()
tabs.dispose()
}
},
/** Desktop focus requests for a mounted session route; nothing is replayed to routes mounted later. */
onFocus(server: Server, sessionID: string, listener: (tabID: Browser.TabID) => void) {
/** Agent requests to show a file in this session's Review pane. */
onPreview(server: Server, sessionID: string, listener: (path: string) => void) {
const id = key(server, sessionID)
const listeners = focus.get(id) ?? new Set()
const listeners = preview.get(id) ?? new Set()
listeners.add(listener)
focus.set(id, listeners)
preview.set(id, listeners)
return () => {
listeners.delete(listener)
if (!listeners.size) focus.delete(id)
if (!listeners.size) preview.delete(id)
}
},
command(server: Server, sessionID: string, command: BrowserPaneCommand) {
@@ -28,10 +28,12 @@ function fixture() {
commands: Browser.Action[]
}[] = []
const endpoint = { url: "http://localhost:4096" }
const previews: string[] = []
const connection = createBrowserConnection({
target: () => ({ serverKey: "browser-test", sessionID: "ses_browser", endpoint: { ...endpoint } }),
change: (state) => states.push(state),
focus: () => {},
preview: (path) => previews.push(path),
pane: {
register(target, emit) {
const call = { target, emit, closed: false, commands: [] as Browser.Action[] }
@@ -50,9 +52,21 @@ function fixture() {
})
connection.wake()
calls[0].emit({ type: "state", state: browser })
return { connection, calls, states, endpoint }
return { connection, calls, states, endpoint, previews }
}
test("preview requests reach the session without touching connection state", () => {
const app = fixture()
try {
const before = app.states.length
app.calls[0].emit({ type: "preview", path: "docs/report.pdf" })
expect(app.previews).toEqual(["docs/report.pdf"])
expect(app.states).toHaveLength(before)
} finally {
app.connection.dispose()
}
})
test("suspension retains tabs and reconnects once on demand using the current endpoint", async () => {
const app = fixture()
try {
@@ -19,6 +19,7 @@ export function createBrowserConnection(input: {
target: () => BrowserPaneTarget
change: (state: BrowserConnectionState) => void
focus: (tabID: Browser.TabID) => void
preview: (path: string) => void
}) {
const state: BrowserConnectionState = { browser: null, suspended: false }
let disposed = false
@@ -33,6 +34,7 @@ export function createBrowserConnection(input: {
(event) => {
if (disposed || state.registration !== registration) return
if (event.type === "focus") return input.focus(event.tabID)
if (event.type === "preview") return input.preview(event.path)
if (event.error === "browser.pane.unsupported" || event.error === "browser.pane.replaced") {
blocked = true
registration.close()
+2 -10
View File
@@ -1,4 +1,4 @@
import { batch, createEffect, createMemo, on, onCleanup } from "solid-js"
import { batch, createEffect, createMemo, on } from "solid-js"
import type { Browser } from "@opencode/plugin-browser/rpc"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/runtime/i18n/language"
@@ -33,13 +33,6 @@ export function createSessionBrowser(session: SessionModel) {
attachment()?.browser?.tabs.filter((tab) => session.layout.tabs().all().includes(sessionBrowserTab(tab.id))) ??
[],
)
const focus = (tabID: Browser.TabID) => {
session.layout.view().reviewPanel.open()
const tabs = session.layout.tabs()
const key = sessionBrowserTab(tabID)
if (!tabs.all().includes(key)) tabs.setAll([...tabs.all(), key])
tabs.setActive(key)
}
const command = (command: BrowserPaneCommand) => {
const sessionID = session.identity.sessionID()
if (!sessionID) return
@@ -66,8 +59,7 @@ export function createSessionBrowser(session: SessionModel) {
createEffect(() => {
const sessionID = session.identity.sessionID()
if (!sessionID) return
if (attachments.enabled()) attachments.attach(server, sessionID)
onCleanup(attachments.onFocus(server, sessionID, focus))
if (attachments.enabled()) attachments.attach(server, sessionID, session.layout.sessionKey())
})
createEffect(
on(
@@ -0,0 +1,94 @@
/* The control defaults to a fixed 232px with equal segments; here it hugs its two labels. */
[data-slot="artifact-toolbar"] [data-slot="segmented-control-v2"] {
width: auto;
}
[data-slot="artifact-toolbar"] [data-slot="segmented-control-v2-item"] {
flex: 0 0 auto;
padding: 0 14px;
}
/* Shrink-wrapped segments round the label box down a fraction; the ellipsis overflow then clips glyph edges. */
[data-slot="artifact-toolbar"] [data-slot="segmented-control-v2-item-label"] {
overflow: visible;
max-width: none;
}
[data-slot="artifact-stage"] {
--artifact-check: color-mix(in oklch, var(--v2-text-text-base) 5%, transparent);
background-color: var(--v2-background-bg-deep);
}
[data-slot="artifact-stage"][data-checker] {
background-image:
linear-gradient(45deg, var(--artifact-check) 25%, transparent 25%),
linear-gradient(-45deg, var(--artifact-check) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, var(--artifact-check) 75%),
linear-gradient(-45deg, transparent 75%, var(--artifact-check) 75%);
background-size: 16px 16px;
background-position:
0 0,
0 8px,
8px -8px,
-8px 0;
}
[data-slot="artifact-media"] {
display: block;
border-radius: 4px;
box-shadow:
0 0 0 0.5px var(--v2-border-border-base),
0 12px 32px -12px color-mix(in oklch, var(--v2-text-text-base) 25%, transparent);
}
[data-slot="artifact-stage"][data-zoom="fit"] [data-slot="artifact-media"] {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
[data-slot="artifact-stage"][data-zoom="fit"][data-overflow] [data-slot="artifact-media"] {
cursor: zoom-in;
}
[data-slot="artifact-stage"][data-zoom="actual"] [data-slot="artifact-media"] {
cursor: zoom-out;
}
[data-slot="artifact-table"] {
border-collapse: separate;
border-spacing: 0;
font-variant-numeric: tabular-nums;
}
[data-slot="artifact-table"] th,
[data-slot="artifact-table"] td {
padding: 5px 12px;
border-bottom: 1px solid var(--v2-border-border-muted);
border-inline-end: 1px solid var(--v2-border-border-muted);
white-space: pre;
max-width: 40ch;
overflow: hidden;
text-overflow: ellipsis;
text-align: start;
line-height: var(--line-height-compact);
}
[data-slot="artifact-table"] th {
position: sticky;
top: 0;
z-index: 1;
background: var(--v2-background-bg-layer-01);
font-weight: var(--font-weight-medium);
color: var(--v2-text-text-muted);
}
[data-slot="artifact-table"] tbody tr:hover td {
background: color-mix(in oklch, var(--v2-text-text-base) 3%, transparent);
}
[data-slot="artifact-table"] td[data-index] {
color: var(--v2-text-text-faint);
text-align: end;
user-select: none;
}
@@ -0,0 +1,454 @@
import { createEffect, createMemo, For, Match, on, onCleanup, Show, Switch, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { Button } from "@opencode/ui/button"
import { FileIcon } from "@opencode/ui/file-icon"
import { SegmentedControl, SegmentedControlItem } from "@opencode/ui/segmented-control"
import { ScrollView } from "@opencode/ui/scroll-view"
import { Markdown } from "@opencode/session-ui/markdown"
import { MarkdownProvider, useMarkdown } from "@opencode/session-ui/context/markdown"
import { getDirectory, getFilename } from "@opencode/util/path"
import type { FileContent } from "@/runtime/server/types"
import { useLanguage } from "@/runtime/i18n/language"
import {
artifactKind,
blobUrlFromContent,
contentBytes,
parseDelimited,
resolveArtifactPath,
type ArtifactKind,
} from "@/workspaces/files/artifact"
import { useArtifactOpener } from "@/session/files/open-artifact"
import "./artifact-view.css"
type ArtifactMode = "preview" | "source"
/** Facts a viewer learns from the decoded media, shown in the toolbar. */
type ArtifactInfo = { width?: number; height?: number; duration?: number; rows?: number; columns?: number }
type MediaProps = {
path: string
content: FileContent
onInfo: (info: ArtifactInfo) => void
/** The browser could not decode the bytes; the host falls back to the binary placeholder. */
onError: () => void
}
/** Kinds that render a preview from their text and can toggle back to highlighted source. */
const previewableKinds = new Set<ArtifactKind>(["svg", "html", "markdown", "mermaid", "table"])
/**
* Renders a loaded non-text file: media, documents, and data get a dedicated viewer with a toolbar;
* previewable text kinds can switch to `source`, which the host supplies (its code view).
*/
export function ArtifactView(props: { path: string; content: FileContent; cacheKey?: string; source: JSX.Element }) {
const language = useLanguage()
const [state, setState] = createStore({
mode: "preview" as ArtifactMode,
info: {} as ArtifactInfo,
// Media the browser could not decode falls back to the binary placeholder.
undecodable: false,
})
createEffect(
on(
() => props.content,
() => setState({ mode: "preview", info: {}, undecodable: false }),
{ defer: true },
),
)
const kind = createMemo<ArtifactKind | "binary">(() => {
if (props.content.type === "binary" && !props.content.mimeType) return "binary"
if (state.undecodable) return "binary"
return artifactKind(props.path)
})
const previewable = createMemo(() => {
const value = kind()
return value !== "binary" && previewableKinds.has(value)
})
const meta = createMemo(() => {
const info = state.info
return [
info.width && info.height ? `${info.width} × ${info.height}` : undefined,
info.duration ? formatDuration(info.duration) : undefined,
info.rows !== undefined ? language.plural("file.view.table.rows", Math.max(0, info.rows - 1)) : undefined,
info.columns !== undefined ? language.plural("file.view.table.columns", info.columns) : undefined,
formatBytes(language.intl(), contentBytes(props.content)),
].filter((item): item is string => !!item)
})
const media = { onInfo: (info: ArtifactInfo) => setState("info", info), onError: () => setState("undecodable", true) }
const rendered = () => (
<ScrollView class="min-h-0 flex-1">
<Show
when={kind() === "markdown"}
fallback={<ArtifactMermaid text={props.content.content} cacheKey={props.cacheKey} />}
>
<ArtifactMarkdown path={props.path} text={props.content.content} cacheKey={props.cacheKey} />
</Show>
</ScrollView>
)
return (
<>
<ArtifactToolbar
mode={state.mode}
onModeChange={previewable() ? (mode) => setState("mode", mode) : undefined}
meta={meta()}
actions={
<Show when={kind() === "html"}>
<OpenInBrowserButton path={props.path} />
</Show>
}
/>
<Show when={!previewable() || state.mode === "preview"} fallback={props.source}>
<Switch>
<Match when={kind() === "image" || kind() === "svg"}>
<ArtifactImage path={props.path} content={props.content} {...media} />
</Match>
<Match when={kind() === "video"}>
<ArtifactVideo path={props.path} content={props.content} {...media} />
</Match>
<Match when={kind() === "audio"}>
<ArtifactAudio path={props.path} content={props.content} {...media} />
</Match>
<Match when={kind() === "pdf" || kind() === "html"}>
<ArtifactFrame path={props.path} content={props.content} kind={kind() === "pdf" ? "pdf" : "html"} />
</Match>
<Match when={kind() === "font"}>
<ArtifactFont path={props.path} content={props.content} />
</Match>
<Match when={kind() === "table"}>
<ArtifactTable path={props.path} text={props.content.content} onInfo={media.onInfo} />
</Match>
<Match when={kind() === "markdown" || kind() === "mermaid"}>{rendered()}</Match>
<Match when={kind() === "binary"}>
<ArtifactBinary path={props.path} size={formatBytes(language.intl(), contentBytes(props.content))} />
</Match>
</Switch>
</Show>
</>
)
}
function formatBytes(locale: string, bytes: number) {
const units = ["byte", "kilobyte", "megabyte", "gigabyte"] as const
const index = Math.min(units.length - 1, bytes > 0 ? Math.floor(Math.log10(bytes) / 3) : 0)
const value = bytes / 1000 ** index
return new Intl.NumberFormat(locale, {
style: "unit",
unit: units[index],
// "short" bytes render as the singular "byte"; the long form pluralizes correctly.
unitDisplay: index === 0 ? "long" : "short",
maximumFractionDigits: value >= 100 || index === 0 ? 0 : 1,
}).format(value)
}
function formatDuration(seconds: number) {
const total = Math.round(seconds)
const minutes = Math.floor(total / 60)
return `${minutes}:${String(total % 60).padStart(2, "0")}`
}
function ArtifactToolbar(props: {
mode?: ArtifactMode
onModeChange?: (mode: ArtifactMode) => void
meta: string[]
actions?: JSX.Element
}) {
const language = useLanguage()
return (
<div data-slot="artifact-toolbar" class="flex h-10 shrink-0 items-center gap-3 px-4">
<Show when={props.onModeChange}>
<SegmentedControl
value={props.mode ?? "preview"}
onChange={(value) => {
if (value === "preview" || value === "source") props.onModeChange?.(value)
}}
>
<SegmentedControlItem value="preview">{language.t("file.view.preview")}</SegmentedControlItem>
<SegmentedControlItem value="source">{language.t("file.view.source")}</SegmentedControlItem>
</SegmentedControl>
</Show>
<div class="ms-auto flex min-w-0 items-center gap-3">
<div class="flex min-w-0 items-center gap-2 text-12-regular text-text-weak">
<For each={props.meta}>
{(item, index) => (
<>
<Show when={index() > 0}>
<span aria-hidden class="text-text-faint">
·
</span>
</Show>
<span class="truncate tabular-nums">{item}</span>
</>
)}
</For>
</div>
{props.actions}
</div>
</div>
)
}
function OpenInBrowserButton(props: { path: string }) {
const language = useLanguage()
const artifacts = useArtifactOpener()
return (
<Show when={artifacts.canOpenInBrowser(props.path)}>
<Button size="small" variant="ghost" icon="globe" onClick={() => artifacts.openInBrowser(props.path)}>
{language.t("file.view.openInBrowser")}
</Button>
</Show>
)
}
function createBlobUrl(content: () => FileContent) {
return createMemo(() => {
const value = blobUrlFromContent(content())
onCleanup(() => URL.revokeObjectURL(value))
return value
})
}
/** Images and SVG previews: fit the pane, click to inspect at 1:1 when the image is larger. */
function ArtifactImage(props: MediaProps) {
const url = createBlobUrl(() => props.content)
const [state, setState] = createStore({ zoom: "fit" as "fit" | "actual", overflow: false, width: 0, height: 0 })
let stage: HTMLDivElement | undefined
const measure = () => {
if (!stage) return
setState("overflow", state.width > stage.clientWidth - 48 || state.height > stage.clientHeight - 48)
}
createResizeObserver(
() => stage,
() => measure(),
)
createEffect(() => {
url()
setState({ zoom: "fit", overflow: false })
})
return (
<div
ref={stage}
data-slot="artifact-stage"
data-checker
data-zoom={state.zoom}
data-overflow={state.overflow || undefined}
class="relative min-h-0 flex-1 overflow-auto"
>
<div
classList={{
"absolute inset-0 flex items-center justify-center p-6": state.zoom === "fit",
"flex min-h-full min-w-full w-max items-center justify-center p-6": state.zoom === "actual",
}}
>
<img
data-slot="artifact-media"
src={url()}
alt={getFilename(props.path)}
draggable={false}
onError={() => props.onError()}
onLoad={(event) => {
const image = event.currentTarget
setState({ width: image.naturalWidth, height: image.naturalHeight })
props.onInfo({ width: image.naturalWidth, height: image.naturalHeight })
measure()
}}
onClick={() => {
if (!state.overflow && state.zoom === "fit") return
setState("zoom", state.zoom === "fit" ? "actual" : "fit")
}}
/>
</div>
</div>
)
}
function ArtifactVideo(props: MediaProps) {
const url = createBlobUrl(() => props.content)
return (
<div data-slot="artifact-stage" data-zoom="fit" class="relative min-h-0 flex-1 overflow-hidden">
<div class="absolute inset-0 flex items-center justify-center p-6">
<video
data-slot="artifact-media"
class="w-full bg-black"
controls
preload="metadata"
playsinline
onError={() => props.onError()}
src={url()}
onLoadedMetadata={(event) => {
const video = event.currentTarget
props.onInfo({ width: video.videoWidth, height: video.videoHeight, duration: video.duration })
}}
/>
</div>
</div>
)
}
function ArtifactAudio(props: MediaProps) {
const url = createBlobUrl(() => props.content)
return (
<div data-slot="artifact-stage" class="relative min-h-0 flex-1 overflow-auto">
<div class="absolute inset-0 flex items-center justify-center p-6">
<div class="flex w-full max-w-lg flex-col items-center gap-5 rounded-xl border border-v2-border-border-muted bg-v2-background-bg-base px-8 py-8 shadow-[var(--v2-elevation-raised)]">
<div class="flex size-14 items-center justify-center rounded-full bg-v2-background-bg-layer-02">
<FileIcon node={{ path: props.path, type: "file" }} class="size-7" />
</div>
<div class="max-w-full truncate text-14-medium text-text-strong">{getFilename(props.path)}</div>
<audio
class="w-full"
onError={() => props.onError()}
controls
preload="metadata"
src={url()}
onLoadedMetadata={(event) => props.onInfo({ duration: event.currentTarget.duration })}
/>
</div>
</div>
</div>
)
}
function ArtifactFrame(props: { path: string; content: FileContent; kind: "pdf" | "html" }) {
const url = createBlobUrl(() => props.content)
// PDF Open Parameters: start with the thumbnail pane closed and the page fitted to the pane width.
const src = () => (props.kind === "pdf" ? `${url()}#navpanes=0&view=FitH` : url())
return (
<iframe
class="block h-full w-full flex-1 border-0 bg-white"
title={getFilename(props.path)}
src={src()}
// The PDF viewer is Chromium's own and does not run in a sandboxed frame. HTML runs as an
// opaque origin: no app storage, cookies, or credentialed requests reach it.
sandbox={props.kind === "html" ? "allow-scripts allow-popups allow-forms allow-modals" : undefined}
referrerPolicy="no-referrer"
/>
)
}
function ArtifactMarkdown(props: { path: string; text: string; cacheKey?: string }) {
const parent = useMarkdown()
const artifacts = useArtifactOpener()
// getDirectory yields "/" for a root-level file, which would make relative links absolute.
const dir = createMemo(() => (props.path.includes("/") || props.path.includes("\\") ? getDirectory(props.path) : ""))
// Absolute references bypass the file's directory; relative ones resolve against it.
const resolve = (href: string) => (/^([a-z]:)?\//i.test(href) ? href : (resolveArtifactPath(dir(), href) ?? href))
return (
<MarkdownProvider
readImage={(src, signal) => parent?.readImage?.(resolve(src), signal) ?? Promise.resolve(undefined)}
openLocalFile={(href) => artifacts.open(href, dir())}
>
<div class="mx-auto w-full max-w-3xl px-8 py-6">
<Markdown text={props.text} cacheKey={props.cacheKey} class="select-text" />
</div>
</MarkdownProvider>
)
}
/** Mermaid sources render through the same fenced-block pipeline the timeline uses. */
function ArtifactMermaid(props: { text: string; cacheKey?: string }) {
return (
<div class="mx-auto w-full max-w-4xl px-8 py-6">
<Markdown text={`\`\`\`mermaid\n${props.text}\n\`\`\``} cacheKey={props.cacheKey} class="select-text" />
</div>
)
}
function ArtifactTable(props: { path: string; text: string; onInfo: (info: ArtifactInfo) => void }) {
const language = useLanguage()
const parsed = createMemo(() => parseDelimited(props.text, props.path.toLowerCase().endsWith(".tsv") ? "\t" : ","))
createEffect(() => props.onInfo({ rows: parsed().total, columns: parsed().columns }))
// Pad the header to the widest row so no data column is dropped.
const header = () => Array.from({ length: parsed().columns }, (_, index) => parsed().rows[0]?.[index] ?? "")
const body = () => parsed().rows.slice(1)
return (
<div class="min-h-0 flex-1 overflow-auto">
<table data-slot="artifact-table" class="min-w-full text-13-regular text-text-base">
<thead>
<tr>
<th data-index />
<For each={header()}>{(cell) => <th>{cell}</th>}</For>
</tr>
</thead>
<tbody>
<For each={body()}>
{(row, index) => (
<tr>
<td data-index>{index() + 1}</td>
<For each={header()}>{(_, column) => <td>{row[column()] ?? ""}</td>}</For>
</tr>
)}
</For>
</tbody>
</table>
<Show when={parsed().total > parsed().rows.length}>
<div class="px-4 py-3 text-12-regular text-text-weak">
{language.t("file.view.table.truncated", { shown: parsed().rows.length - 1, total: parsed().total - 1 })}
</div>
</Show>
</div>
)
}
const specimenSizes = [12, 16, 24, 40, 64]
function ArtifactFont(props: { path: string; content: FileContent }) {
const language = useLanguage()
const url = createBlobUrl(() => props.content)
const family = createMemo(() => `artifact-${Math.random().toString(36).slice(2)}`)
createEffect(() => {
const face = new FontFace(family(), `url(${url()})`)
document.fonts.add(face)
void face.load().catch(() => undefined)
onCleanup(() => document.fonts.delete(face))
})
return (
<div class="min-h-0 flex-1 overflow-auto">
<div class="mx-auto flex w-full max-w-3xl flex-col gap-6 px-8 py-8" style={{ "font-family": `"${family()}"` }}>
<div class="text-text-strong" style={{ "font-size": "56px", "line-height": "1.1" }}>
{getFilename(props.path).replace(/\.[^.]+$/, "")}
</div>
<div class="break-all text-text-base" style={{ "font-size": "22px", "line-height": "1.4" }}>
ABCDEFGHIJKLMNOPQRSTUVWXYZ
<br />
abcdefghijklmnopqrstuvwxyz
<br />
0123456789 !?&@#%(){}[]
</div>
<div class="flex flex-col gap-3 border-t border-v2-border-border-muted pt-6">
<For each={specimenSizes}>
{(size) => (
<div class="flex items-baseline gap-4">
<span
class="w-8 shrink-0 text-12-regular text-text-faint tabular-nums"
style={{ "font-family": "var(--font-family-mono)" }}
>
{size}
</span>
<span class="text-text-base" style={{ "font-size": `${size}px`, "line-height": "1.25" }}>
{language.t("file.view.fontSample")}
</span>
</div>
)}
</For>
</div>
</div>
</div>
)
}
function ArtifactBinary(props: { path: string; size: string }) {
const language = useLanguage()
return (
<div data-slot="artifact-stage" class="relative min-h-0 flex-1">
<div class="absolute inset-0 flex flex-col items-center justify-center gap-3 p-6 text-center">
<FileIcon node={{ path: props.path, type: "file" }} class="size-8 text-text-weak" />
<div class="text-14-medium text-text-strong">{getFilename(props.path)}</div>
<div class="text-13-regular text-text-weak">{language.t("file.view.binary", { size: props.size })}</div>
</div>
</div>
)
}
+39 -30
View File
@@ -1,4 +1,4 @@
import { createEffect, createMemo, createSignal, Match, on, onCleanup, Switch } from "solid-js"
import { createEffect, createMemo, createSignal, Match, on, onCleanup, Show, Switch } from "solid-js"
import { createStore } from "solid-js/store"
import { Dynamic } from "solid-js/web"
import { makeEventListener } from "@solid-primitives/event-listener"
@@ -11,8 +11,9 @@ import { LineCommentOverflowIcon } from "@opencode/ui/line-comment"
import { Menu } from "@opencode/ui/menu"
import { Tabs } from "@opencode/ui/tabs"
import { ScrollView } from "@opencode/ui/scroll-view"
import { showToast } from "@/shell/notifications/toast"
import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/workspaces/files/model"
import { artifactKind } from "@/workspaces/files/artifact"
import { ArtifactView } from "@/session/files/artifact-view"
import { useComments } from "@/composer/comments"
import { useLanguage } from "@/runtime/i18n/language"
import { useComposerState } from "@/composer/persistence"
@@ -205,6 +206,11 @@ export function SessionFileView(props: SessionFileViewProps) {
})
const contents = createMemo(() => state()?.content?.content ?? "")
const cacheKey = createMemo(() => sampledChecksum(contents()))
// Plain text keeps the code view; every other kind is rendered by ArtifactView.
const artifact = createMemo(() => {
const content = state()?.content
return content?.type === "binary" || artifactKind(path() ?? "") !== "text"
})
const selectedLines = createMemo<SelectedLineRange | null>(() => {
const p = path()
if (!p) return null
@@ -433,39 +439,42 @@ export function SessionFileView(props: SessionFileViewProps) {
}}
search={search}
class="select-text"
media={{
mode: "auto",
path: path(),
current: state()?.content,
onLoad: scrollSync.queueRestore,
onError: (args: { kind: "image" | "audio" | "svg" }) => {
if (args.kind !== "svg") return
showToast({
variant: "error",
title: language.t("toast.file.loadFailed.title"),
})
},
}}
// Media and previews have their own viewers below; the code view only ever shows text.
media={{ mode: "off" }}
/>
</div>
)
// The code view scrolls inside ScrollView so line state and scroll position persist per tab.
const codeView = (source: string) => (
<ScrollView class="min-h-0 flex-1" viewportRef={scrollSync.setViewport} onScroll={scrollSync.handleScroll}>
{renderFile(source)}
</ScrollView>
)
const content = () => (
<div class="mt-3 relative h-full min-h-0">
<ScrollView class="h-full" viewportRef={scrollSync.setViewport} onScroll={scrollSync.handleScroll}>
<Switch>
<Match when={state()?.loaded}>{renderFile(contents())}</Match>
<Match when={state()?.loading}>
<div class="px-6 py-4 text-text-weak">{language.t("common.loading")}</div>
</Match>
<Match when={state()?.notFound ? state()?.name : undefined}>
{(name) => (
<div class="px-6 py-4 text-text-weak">{language.t("file.error.notFound", { name: name() })}</div>
)}
</Match>
<Match when={state()?.error}>{(err) => <div class="px-6 py-4 text-text-weak">{err()}</div>}</Match>
</Switch>
</ScrollView>
<div class="mt-3 relative h-full min-h-0 flex flex-col">
<Switch>
<Match when={state()?.loaded ? state()?.content : undefined}>
{(value) => (
<Show when={artifact()} fallback={codeView(value().content)}>
<ArtifactView
path={path() ?? ""}
content={value()}
cacheKey={cacheKey()}
source={codeView(value().content)}
/>
</Show>
)}
</Match>
<Match when={state()?.loading}>
<div class="px-6 py-4 text-text-weak">{language.t("common.loading")}</div>
</Match>
<Match when={state()?.notFound ? state()?.name : undefined}>
{(name) => <div class="px-6 py-4 text-text-weak">{language.t("file.error.notFound", { name: name() })}</div>}
</Match>
<Match when={state()?.error}>{(err) => <div class="px-6 py-4 text-text-weak">{err()}</div>}</Match>
</Switch>
</div>
)
@@ -0,0 +1,105 @@
import { createEffect, onCleanup, type ParentProps } from "solid-js"
import { createSimpleContext } from "@opencode/ui/context"
import { MarkdownProvider, useMarkdown } from "@opencode/session-ui/context/markdown"
import { useBrowserAttachments } from "@/session/browser/attachments"
import type { SessionModel } from "@/session/model"
import { useFile } from "@/workspaces/files/model"
import { artifactKind, resolveArtifactPath } from "@/workspaces/files/artifact"
import { encodeFilePath } from "@/workspaces/files/path"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useServer } from "@/runtime/server/current"
import { ServerConnection } from "@/runtime/server/registry"
import { useSessionLayout } from "@/session/session-layout"
import { createOpenSessionFileTab } from "@/session/helpers"
import type { createSessionBrowser } from "@/session/browser/model"
/** Routes local links in timeline markdown to the artifact opener while keeping image loading. */
export function ArtifactMarkdownProvider(props: ParentProps) {
const markdown = useMarkdown()
const artifacts = useArtifactOpener()
return (
<MarkdownProvider readImage={markdown?.readImage} openLocalFile={(path) => artifacts.open(path)}>
{props.children}
</MarkdownProvider>
)
}
/**
* Opens files the agent references as side-panel tabs, inside or outside the workspace, or as
* a browser tab for HTML when the desktop can load the file directly.
*/
export const { use: useArtifactOpener, provider: ArtifactOpenerProvider } = createSimpleContext({
name: "ArtifactOpener",
init: (props: { session: SessionModel; browser: ReturnType<typeof createSessionBrowser> }) => {
const file = useFile()
const server = useServer()
const location = useWorkspaceLocation()
const attachments = useBrowserAttachments()
const { tabs, view } = useSessionLayout()
const root = () => location().directory.replaceAll("\\", "/").replace(/\/+$/, "")
/**
* Turn a link into a path `useFile` can load: workspace-relative when it is under the root,
* otherwise absolute. Relative links resolve against `base`; ones that climb past the root
* become absolute too, so a `../../shared/report.pdf` still opens.
*/
const resolve = (href: string, base?: string) => {
// Agents cite locations as path:line or path:line:col; the file is what opens.
const value = href.replaceAll("\\", "/").replace(/:\d+(?::\d+)?$/, "")
if (/^[a-z]:\//i.test(value) || value.startsWith("/")) return file.normalize(value)
const relative = resolveArtifactPath(base ?? "", value)
if (relative !== undefined) return file.normalize(relative)
// Climbing past the workspace root: resolve from the referencing folder's absolute location.
const dir = base ? `${root()}/${base.replace(/\/+$/, "")}` : root()
return file.normalize(resolveArtifactPath(dir, value) ?? value)
}
const showTab = createOpenSessionFileTab({
normalizeTab: (tab) => tab,
openTab: (tab) => tabs().open(tab),
pathFromTab: file.pathFromTab,
loadFile: () => undefined,
openReviewPanel: () => {
if (!view().reviewPanel.opened()) view().reviewPanel.open()
},
setActive: (tab) => tabs().setActive(tab),
})
// Inline paths are guessed from text, so confirm the file exists before a tab appears for it.
const openTab = (path: string) => {
void file.load(path).then(() => {
if (file.get(path)?.loaded) showTab(file.tab(path))
})
}
// The desktop's own sidecar shares this disk, and its browser pane accepts file:// URLs inside the
// session workspace only. Forwarded loopback servers do not qualify, matching the desktop policy.
const canOpenInBrowser = (path?: string) =>
ServerConnection.builtin(server.conn) &&
props.browser.available() &&
props.browser.attached() &&
(path === undefined || !file.absolute(path))
const openInBrowser = (path: string) => {
props.browser.command({ type: "tabs.open", url: `file://${encodeFilePath(`${root()}/${path}`)}` })
}
/** Open `href` as referenced from `base` (a workspace-relative directory, "" for the root). */
const open = (href: string, base?: string) => {
const path = resolve(href, base)
if (!path) return
if (artifactKind(path) === "html" && canOpenInBrowser(path)) return openInBrowser(path)
openTab(path)
}
// The agent's browser.preview tool arrives through the desktop browser pane attachment.
createEffect(() => {
const sessionID = props.session.identity.sessionID()
if (!sessionID) return
onCleanup(attachments.onPreview(server, sessionID, (path) => open(path)))
})
return { canOpenInBrowser, openInBrowser, open }
},
})
@@ -173,7 +173,9 @@ export function SessionFileBrowserTab(props: {
<SessionFilePanelV2Empty>
<div class="flex flex-col items-center gap-2 text-center text-text-weak">
<Icon name="file-tree" size="large" class="mb-2" />
<div class="text-[13px] font-medium leading-[13px] text-text-strong">{language.t("command.file.open")}</div>
<div class="text-[13px] font-medium leading-[13px] text-text-strong">
{language.t("command.file.open")}
</div>
<div class="h-5 text-13-regular leading-5">{language.t("session.files.selectToOpen")}</div>
</div>
</SessionFilePanelV2Empty>
+1
View File
@@ -137,6 +137,7 @@ export function useSessionModel() {
tabs: layout.tabs,
view: layout.view,
tabKey: layout.tabKey,
sessionKey: layout.sessionKey,
},
ownership: createSessionOwnership(layout.sessionKey),
tabs: {
+3
View File
@@ -155,6 +155,9 @@ export function createSessionReview(input: {
const count = () => diffs().length
const hasChanges = () => count() > 0
const ready = () => {
// A project without VCS never enables vcsQuery, so its status stays "pending" forever.
const project = input.session.project()
if (project && !project.vcs) return true
if (mode() === "git" || mode() === "branch") return !vcsQuery.isPending
return true
}
+2 -2
View File
@@ -249,7 +249,7 @@ function ReviewTitle(props: { review: SessionReviewModel }) {
function ReviewEmpty(props: { review: SessionReviewModel; loadingClass: string }) {
const language = useLanguage()
const loading = () => (props.review.mode() === "git" || props.review.mode() === "branch") && !props.review.ready()
const noGit = () => props.review.mode() === "turn" && props.review.noGit()
const noGit = () => props.review.noGit()
const text = () => {
if (props.review.mode() === "git") return language.t("session.review.noUncommittedChanges")
if (props.review.mode() === "branch") return language.t("session.review.noBranchChanges")
@@ -282,7 +282,7 @@ function ReviewEmpty(props: { review: SessionReviewModel; loadingClass: string }
function ReviewPanelEmpty(props: { review: SessionReviewModel }) {
const language = useLanguage()
const loading = () => (props.review.mode() === "git" || props.review.mode() === "branch") && !props.review.ready()
const noGit = () => props.review.mode() === "turn" && props.review.noGit()
const noGit = () => props.review.noGit()
return (
<Switch>
<Match when={loading()}>
+14 -1
View File
@@ -38,6 +38,7 @@ import { SessionReviewToggle } from "./header/session-header-actions"
import { createAnimatedPresence } from "@/runtime/animated-presence"
import { createSessionBrowser } from "./browser/model"
import { createTimelineCache } from "./timeline/cache"
import { ArtifactMarkdownProvider, ArtifactOpenerProvider } from "./files/open-artifact"
const SessionMobileFiles = lazy(async () => {
const { SessionMobileFiles } = await import("./files/session-mobile-files")
@@ -50,14 +51,26 @@ const SessionSummaryPanel = lazy(async () => {
})
export function SessionScreen(props: { session: SessionModel }) {
// The timeline cache captures its owner when created, so link handling must be provided above it.
const browser = createSessionBrowser(props.session)
return (
<ArtifactOpenerProvider session={props.session} browser={browser}>
<ArtifactMarkdownProvider>
<SessionScreenContent session={props.session} browser={browser} />
</ArtifactMarkdownProvider>
</ArtifactOpenerProvider>
)
}
function SessionScreenContent(props: { session: SessionModel; browser: ReturnType<typeof createSessionBrowser> }) {
const session = props.session
const browser = props.browser
const server = useServer()
const detailsProject = createMemo(() => {
const info = session.data.info()
return info ? projectForSession(info, server.ctx.sync.data.project) : undefined
})
const isDesktop = session.isDesktop
const browser = createSessionBrowser(session)
const screen = createSessionScreenLayout(session)
const timeline = createSessionTimelineInteraction(session)
const timelineSearch = createTimelineSearchController({
-4
View File
@@ -1102,10 +1102,6 @@
padding-block: 20px;
}
.settings-server-connection .settings-servers-lead {
gap: 4px;
}
.settings-servers-row:not(:last-child) {
padding-bottom: 16px;
margin-bottom: 16px;
@@ -0,0 +1,118 @@
import { describe, expect, test } from "bun:test"
import {
artifactKind,
bytesToBase64,
contentBytes,
fileContentFromBytes,
MAX_MEDIA_BYTES,
parseDelimited,
resolveArtifactPath,
} from "./artifact"
describe("artifactKind", () => {
test.each([
["shot.PNG", "image"],
["logo.svg", "svg"],
["song.mp3", "audio"],
["demo.mp4", "video"],
["clip.webm", "video"],
["paper.pdf", "pdf"],
["out/index.html", "html"],
["README.md", "markdown"],
["flow.mmd", "mermaid"],
["data.csv", "table"],
["data.tsv", "table"],
["Inter.woff2", "font"],
["src/app.ts", "text"],
["Makefile", "text"],
[".env", "text"],
["archive.tar.gz", "text"],
] as const)("classifies %s as %s", (path, kind) => {
expect(artifactKind(path)).toBe(kind)
})
})
describe("fileContentFromBytes", () => {
test("keeps media as base64 with a mime type", () => {
const content = fileContentFromBytes("a.png", new Uint8Array([137, 80, 78, 71]))
expect(content).toEqual({ type: "binary", content: "iVBORw==", encoding: "base64", mimeType: "image/png" })
})
test("decodes text and svg with a mime type", () => {
expect(fileContentFromBytes("a.svg", new TextEncoder().encode("<svg/>"))).toEqual({
type: "text",
content: "<svg/>",
mimeType: "image/svg+xml",
})
expect(fileContentFromBytes("a.ts", new TextEncoder().encode("const a = 1"))).toEqual({
type: "text",
content: "const a = 1",
mimeType: undefined,
})
})
test("keeps only the size of media above the cap", () => {
const content = fileContentFromBytes("big.mp4", new Uint8Array(MAX_MEDIA_BYTES + 1))
expect(content).toEqual({ type: "binary", content: "", size: MAX_MEDIA_BYTES + 1 })
})
test("marks unknown binaries without keeping bytes", () => {
expect(fileContentFromBytes("a.bin", new Uint8Array([1, 0, 2]))).toEqual({ type: "binary", content: "", size: 3 })
})
test("encodes large buffers in chunks", () => {
const bytes = new Uint8Array(70_000).fill(65)
expect(bytesToBase64(bytes)).toBe(Buffer.from(bytes).toString("base64"))
})
})
describe("contentBytes", () => {
test("recovers byte counts from base64 and text", () => {
expect(contentBytes({ type: "binary", content: "iVBORw==", encoding: "base64" })).toBe(4)
expect(contentBytes({ type: "binary", content: "iVBORwA=", encoding: "base64" })).toBe(5)
expect(contentBytes({ type: "text", content: "héllo" })).toBe(6)
})
})
describe("parseDelimited", () => {
test("handles quotes, embedded delimiters, newlines, and CRLF", () => {
const parsed = parseDelimited('name,note\r\n"Smith, J","says ""hi""\nand more"\nplain,\n', ",")
expect(parsed.rows).toEqual([
["name", "note"],
["Smith, J", 'says "hi"\nand more'],
["plain", ""],
])
expect(parsed.total).toBe(3)
expect(parsed.columns).toBe(2)
})
test("counts rows past the limit without keeping them", () => {
const parsed = parseDelimited("a\tb\n1\t2\n3\t4\n5\t6", "\t", 2)
expect(parsed.rows).toHaveLength(2)
expect(parsed.total).toBe(4)
})
})
describe("resolveArtifactPath", () => {
test.each([
["docs", "guide.md", "docs/guide.md"],
["docs", "./img/a.png", "docs/img/a.png"],
["docs/api", "../index.md", "docs/index.md"],
["", "src/app.ts", "src/app.ts"],
["docs", "sub\\win.md", "docs/sub/win.md"],
["", "docs/guide.md", "docs/guide.md"],
["/tmp/notes/", "../out/a.pdf", "/tmp/out/a.pdf"],
["C:/tmp/notes/", "img.png", "C:/tmp/notes/img.png"],
["/repo", "../shared/report.pdf", "/shared/report.pdf"],
])("resolves %s + %s", (base, href, expected) => {
expect(resolveArtifactPath(base, href)).toBe(expected)
})
test.each([
["docs", "../../etc/passwd"],
["", "../x"],
["docs", "/abs/path"],
])("rejects %s + %s", (base, href) => {
expect(resolveArtifactPath(base, href)).toBeUndefined()
})
})
@@ -0,0 +1,211 @@
import type { FileContent } from "@/runtime/server/types"
export type ArtifactKind =
| "image"
| "svg"
| "audio"
| "video"
| "pdf"
| "html"
| "markdown"
| "mermaid"
| "table"
| "font"
| "text"
const mimes = new Map([
["png", "image/png"],
["jpg", "image/jpeg"],
["jpeg", "image/jpeg"],
["gif", "image/gif"],
["webp", "image/webp"],
["avif", "image/avif"],
["bmp", "image/bmp"],
["ico", "image/x-icon"],
["tif", "image/tiff"],
["tiff", "image/tiff"],
["heic", "image/heic"],
["svg", "image/svg+xml"],
["mp3", "audio/mpeg"],
["wav", "audio/wav"],
["ogg", "audio/ogg"],
["oga", "audio/ogg"],
["m4a", "audio/mp4"],
["aac", "audio/aac"],
["flac", "audio/flac"],
["opus", "audio/ogg"],
["weba", "audio/webm"],
["mp4", "video/mp4"],
["m4v", "video/mp4"],
["webm", "video/webm"],
["mov", "video/quicktime"],
["ogv", "video/ogg"],
["mkv", "video/x-matroska"],
["pdf", "application/pdf"],
["html", "text/html"],
["htm", "text/html"],
["md", "text/markdown"],
["markdown", "text/markdown"],
["mdx", "text/markdown"],
["mmd", "text/vnd.mermaid"],
["mermaid", "text/vnd.mermaid"],
["csv", "text/csv"],
["tsv", "text/tab-separated-values"],
["ttf", "font/ttf"],
["otf", "font/otf"],
["woff", "font/woff"],
["woff2", "font/woff2"],
])
export function artifactExtension(path: string) {
const name = path.split(/[\\/]/).pop() ?? ""
const index = name.lastIndexOf(".")
if (index <= 0) return ""
return name.slice(index + 1).toLowerCase()
}
export function artifactMime(path: string) {
return mimes.get(artifactExtension(path))
}
export function artifactKind(path: string): ArtifactKind {
const mime = artifactMime(path)
if (!mime) return "text"
if (mime === "image/svg+xml") return "svg"
if (mime === "application/pdf") return "pdf"
if (mime === "text/html") return "html"
if (mime === "text/markdown") return "markdown"
if (mime === "text/vnd.mermaid") return "mermaid"
if (mime === "text/csv" || mime === "text/tab-separated-values") return "table"
if (mime.startsWith("image/")) return "image"
if (mime.startsWith("audio/")) return "audio"
if (mime.startsWith("font/")) return "font"
return "video"
}
/** Kinds whose bytes are kept as base64 so media elements can play them without a text round trip. */
const binaryKinds = new Set<ArtifactKind>(["image", "audio", "video", "pdf", "font"])
/** Text files never contain NUL; a NUL in the first 8 KiB marks an unknown binary. */
function isBinaryBytes(bytes: Uint8Array) {
return bytes.subarray(0, 8192).includes(0)
}
export function bytesToBase64(bytes: Uint8Array) {
const parts: string[] = []
for (let index = 0; index < bytes.length; index += 0x8000) {
parts.push(String.fromCharCode(...bytes.subarray(index, index + 0x8000)))
}
return btoa(parts.join(""))
}
/** Media above this stays a placeholder: base64 encoding on the main thread and the LRU budget both suffer. */
export const MAX_MEDIA_BYTES = 25 * 1024 * 1024
export function fileContentFromBytes(path: string, bytes: Uint8Array): FileContent {
const kind = artifactKind(path)
const mimeType = artifactMime(path)
if (binaryKinds.has(kind)) {
if (bytes.length > MAX_MEDIA_BYTES) return { type: "binary", content: "", size: bytes.length }
return { type: "binary", content: bytesToBase64(bytes), encoding: "base64", mimeType }
}
// Unknown binaries keep no bytes: the viewer only shows a placeholder for them.
if (kind === "text" && isBinaryBytes(bytes)) return { type: "binary", content: "", size: bytes.length }
return { type: "text", content: new TextDecoder().decode(bytes), mimeType }
}
/** Approximate on-disk size of loaded content. */
export function contentBytes(content: FileContent) {
if (content.size !== undefined) return content.size
if (content.encoding === "base64") {
const padding = content.content.endsWith("==") ? 2 : content.content.endsWith("=") ? 1 : 0
return Math.floor((content.content.length * 3) / 4) - padding
}
return new TextEncoder().encode(content.content).length
}
/**
* Parse RFC 4180 style delimited text. Quoted fields may contain the delimiter, newlines, and
* doubled quotes. Rows beyond `limit` are counted but not returned.
*/
export function parseDelimited(text: string, delimiter: string, limit = 1000) {
const rows: string[][] = []
let row: string[] = []
let field = ""
let quoted = false
let total = 0
const endRow = () => {
row.push(field)
field = ""
const blank = row.length === 1 && row[0] === ""
if (!blank) {
total++
if (rows.length < limit) rows.push(row)
}
row = []
}
for (let index = 0; index < text.length; index++) {
const char = text[index]!
if (quoted) {
if (char !== '"') {
field += char
continue
}
if (text[index + 1] === '"') {
field += '"'
index++
continue
}
quoted = false
continue
}
if (char === '"' && field === "") {
quoted = true
continue
}
if (char === delimiter) {
row.push(field)
field = ""
continue
}
if (char === "\r") continue
if (char === "\n") {
endRow()
continue
}
field += char
}
if (field !== "" || row.length > 0) endRow()
const columns = rows.reduce((max, current) => Math.max(max, current.length), 0)
return { rows, total, columns }
}
/** Build a blob URL from loaded content. Callers revoke it when the viewer unmounts. */
export function blobUrlFromContent(content: FileContent) {
const type = content.mimeType ?? "application/octet-stream"
if (content.encoding !== "base64") return URL.createObjectURL(new Blob([content.content], { type }))
const raw = atob(content.content)
const bytes = Uint8Array.from(raw, (char) => char.charCodeAt(0))
return URL.createObjectURL(new Blob([bytes], { type }))
}
/**
* Resolve a relative link against a directory. A relative base yields a workspace-relative path and
* an absolute base an absolute one; undefined when the link climbs past the base's root.
*/
export function resolveArtifactPath(base: string, href: string) {
const target = href.replaceAll("\\", "/")
if (target.startsWith("/")) return undefined
const dir = base.replaceAll("\\", "/")
const segments = [...dir.split("/").filter(Boolean)]
for (const segment of target.split("/")) {
if (!segment || segment === ".") continue
if (segment !== "..") {
segments.push(segment)
continue
}
if (segments.length === 0) return undefined
segments.pop()
}
return `${dir.startsWith("/") ? "/" : ""}${segments.join("/")}`
}
+10 -5
View File
@@ -5,11 +5,12 @@ import { createSimpleContext } from "@opencode/ui/context"
import { showToast } from "@/shell/notifications/toast"
import { useParams } from "@solidjs/router"
import { base64Encode } from "@opencode/util/encode"
import { getFilename } from "@opencode/util/path"
import { getDirectory, getFilename } from "@opencode/util/path"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useLanguage } from "@/runtime/i18n/language"
import { useLayout } from "@/shell/state/layout"
import { createPathHelpers } from "./path"
import { fileContentFromBytes } from "./artifact"
import {
approxBytes,
evictContentLru,
@@ -185,14 +186,17 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
setLoading(file)
// Files outside the workspace are read from their own directory, like markdown images.
// The trailing separator from getDirectory keeps "/" and "C:/" valid, like readLocalImage.
const request = path.absolute(file)
? { path: getFilename(file), location: { directory: getDirectory(file) } }
: { path: file, location: { directory } }
const promise = serverSDK.api.file
.read({ path: file, location: { directory } })
.read(request)
.then((data) => {
if (scope() !== directory) return
const content = { type: "text" as const, content: new TextDecoder().decode(data) }
const content = fileContentFromBytes(file, data)
setLoaded(file, content)
if (!content) return
touchFileContent(file, approxBytes(content))
evictContent(new Set([file]))
})
@@ -281,6 +285,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
return {
ready: () => view().ready(),
normalize: path.normalize,
absolute: path.absolute,
tab: path.tab,
pathFromTab: path.pathFromTab,
tree: {
@@ -21,6 +21,24 @@ describe("file path helpers", () => {
expect(path.normalize("c:\\repo\\src\\app.ts")).toBe("src\\app.ts")
})
test("keeps files outside the workspace absolute and round-trips them through tabs", () => {
const posix = createPathHelpers(() => "/repo")
expect(posix.normalize("/tmp/out/report.pdf")).toBe("/tmp/out/report.pdf")
expect(posix.absolute("/tmp/out/report.pdf")).toBe(true)
expect(posix.absolute("src/app.ts")).toBe(false)
expect(posix.tab("/tmp/out/report.pdf")).toBe("file:///tmp/out/report.pdf")
expect(posix.pathFromTab("file:///tmp/out/report.pdf")).toBe("/tmp/out/report.pdf")
expect(posix.normalize("/repository/x.ts")).toBe("/repository/x.ts")
const windows = createPathHelpers(() => "C:\\repo")
expect(windows.normalize("C:\\tmp\\font.ttf")).toBe("C:\\tmp\\font.ttf")
expect(windows.normalize("file:///C:/tmp/font.ttf")).toBe("C:/tmp/font.ttf")
expect(windows.absolute("C:/tmp/font.ttf")).toBe(true)
expect(windows.tab("C:/tmp/font.ttf")).toBe("file:///C:/tmp/font.ttf")
expect(windows.pathFromTab("file:///C:/tmp/font.ttf")).toBe("C:/tmp/font.ttf")
expect(windows.pathFromTab("file:///C:/repo/src/app.ts")).toBe("src/app.ts")
})
test("normalizes Windows directory separators", () => {
const path = createPathHelpers(() => "C:\\repo")
expect(path.normalizeDir("frontend\\")).toBe("frontend")
+12 -6
View File
@@ -105,7 +105,11 @@ export function createPathHelpers(scope: () => string) {
const normalize = (input: string) => {
const root = scope()
let path = unquoteGitPath(decodeFilePath(stripQueryAndHash(stripFileProtocol(input))))
// file:///C:/dir becomes /C:/dir once the protocol is gone; restore the drive form.
let path = unquoteGitPath(decodeFilePath(stripQueryAndHash(stripFileProtocol(input)))).replace(
/^[/\\]([A-Za-z]:)/,
"$1",
)
// Separator-agnostic prefix stripping for Cygwin/native Windows compatibility
// Only case-insensitive on Windows (drive letter or UNC paths)
@@ -116,20 +120,21 @@ export function createPathHelpers(scope: () => string) {
canonPath.startsWith(canonRoot) &&
(canonRoot.endsWith("/") || canonPath === canonRoot || canonPath[canonRoot.length] === "/")
) {
// Slice from original path to preserve native separators
path = path.slice(root.length)
// Slice from original path to preserve native separators, then drop the separator itself.
path = path.slice(root.length).replace(/^[/\\]/, "")
}
if (path.startsWith("./") || path.startsWith(".\\")) {
path = path.slice(2)
}
if (path.startsWith("/") || path.startsWith("\\")) {
path = path.slice(1)
}
// An absolute path that is not under the root stays absolute; it is a file outside the workspace.
return path
}
/** Whether a normalized path points outside the workspace root. */
const absolute = (path: string) => /^[A-Za-z]:[/\\]/.test(path) || path.startsWith("/") || path.startsWith("\\\\")
const tab = (input: string) => {
const path = normalize(input)
return `file://${encodeFilePath(path)}`
@@ -149,6 +154,7 @@ export function createPathHelpers(scope: () => string) {
return {
normalize,
absolute,
tab,
pathFromTab,
normalizeDir,
+21 -1
View File
@@ -1,4 +1,5 @@
import { readFileSync } from "node:fs"
import { createRequire } from "node:module"
import solidPlugin from "vite-plugin-solid"
import tailwindcss from "@tailwindcss/vite"
import { fileURLToPath } from "url"
@@ -17,6 +18,24 @@ if (tailwindGenerate && typeof tailwindHotUpdate === "function") {
}
}
// The markdown worker imports these directly, so they are served unbundled to keep worker startup
// stable. Vite applies `exclude` to every import inside a pre-bundle too, which would leave a bare
// `import "marked"` in mermaid's chunk that the browser cannot resolve from this package.
const workerDeps = ["@shikijs/stream", "marked", "marked-shiki", "remend"]
/** @type {import("rolldown").Plugin} */
const bundleNestedWorkerDeps = {
name: "opencode-desktop:bundle-nested-worker-deps",
resolveId(id, importer) {
if (!importer || !workerDeps.includes(id) || !importer.includes("node_modules")) return
try {
return createRequire(importer).resolve(id)
} catch {
return
}
},
}
export const channel = (() => {
const raw = process.env.OPENCODE_CHANNEL
if (raw === "local" || raw === "dev" || raw === "beta" || raw === "prod") return raw
@@ -44,8 +63,9 @@ export default [
format: "es",
},
optimizeDeps: {
exclude: ["@shikijs/stream", "marked", "marked-shiki", "remend"],
exclude: workerDeps,
include: ["@opencode/session-ui > mermaid", "@opencode/session-ui > mermaid > katex"],
rolldownOptions: { plugins: [bundleNestedWorkerDeps] },
},
}
},
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/cli",
"version": "2.0.11",
"version": "2.0.12",
"type": "module",
"license": "MIT",
"bin": {
+68 -25
View File
@@ -5,34 +5,77 @@ import { Runtime } from "../../framework/runtime"
import { Updater } from "../../services/updater"
import { handlePromptErrors } from "../../ui/prompt"
import { OPENCODE_VERSION } from "../../version"
import { stripVTControlCharacters } from "node:util"
export default Runtime.handler(
Commands.commands.upgrade,
Effect.fn("cli.upgrade")(function* (input) {
intro("Upgrade")
const updater = yield* Updater.Service
const method = Option.getOrUndefined(input.method) ?? (yield* updater.method())
if (!method)
return yield* Effect.fail(
new Error("Could not detect the installation method. Pass --method to choose how to upgrade OpenCode."),
Effect.fn("cli.upgrade")(
function* (input) {
intro("Upgrade")
const updater = yield* Updater.Service
const method = Option.getOrUndefined(input.method) ?? (yield* updater.method())
if (!method)
return yield* Effect.fail(
new Error("Could not detect the installation method. Pass --method to choose how to upgrade OpenCode."),
)
log.info(`Using method: ${method}`)
const target = Option.getOrUndefined(input.target) ?? (yield* updater.latest())
const version = target.trim().replace(/^v/, "")
if (version === OPENCODE_VERSION) {
log.warn(`OpenCode upgrade skipped: ${version} is already installed`)
outro("Done")
return
}
log.info(`From ${OPENCODE_VERSION}${version}`)
const progress = spinner()
progress.start("Upgrading...")
yield* updater.upgrade(method, target).pipe(
Effect.tap(() => Effect.sync(() => progress.stop("Upgrade complete"))),
Effect.tapCause(() => Effect.sync(() => progress.stop("Upgrade failed", 1))),
)
log.info(`Using method: ${method}`)
const target = Option.getOrUndefined(input.target) ?? (yield* updater.latest())
const version = target.trim().replace(/^v/, "")
if (version === OPENCODE_VERSION) {
log.warn(`OpenCode upgrade skipped: ${version} is already installed`)
outro("Done")
return
}
log.info(`From ${OPENCODE_VERSION}${version}`)
const progress = spinner()
progress.start("Upgrading...")
yield* updater.upgrade(method, target).pipe(
Effect.tap(() => Effect.sync(() => progress.stop("Upgrade complete"))),
Effect.tapCause(() => Effect.sync(() => progress.stop("Upgrade failed", 1))),
)
outro("Done")
}, handlePromptErrors),
},
(effect) =>
handlePromptErrors(
effect.pipe(
Effect.mapError((error) =>
error instanceof Updater.UpgradeError ? new Error(formatUpgradeError(error), { cause: error }) : error,
),
),
),
),
)
function formatUpgradeError(error: Updater.UpgradeError) {
const clean = (value: string) => stripVTControlCharacters(value).replaceAll("\r", "").trim()
const line = (value: string) => clean(value).replace(/\s+/g, " ")
const detail = wrap(clean(error.detail), Math.max(24, (process.stdout.columns ?? 80) - 7)).join("\n")
return [
line(error.title),
"",
detail,
...(error.command ? ["", field("Command", line(error.command))] : []),
field("Retry", line(error.retry)),
].join("\n")
}
function field(label: string, value: string) {
const prefix = label.padEnd(9)
const width = Math.max(24, Math.min(61, (process.stdout.columns ?? 80) - prefix.length - 10))
const lines = wrap(value, width)
return lines.map((line, index) => `${index === 0 ? prefix : " ".repeat(prefix.length)}${line}`).join("\n")
}
function wrap(value: string, width: number) {
return value.split("\n").flatMap((source) => {
if (source.length <= width) return [source]
return source.split(/\s+/).reduce<string[]>((result, word) => {
const index = result.length - 1
if (index < 0 || result[index].length + word.length + 1 > width) result.push(word)
else result[index] += ` ${word}`
return result
}, [])
})
}
+187 -27
View File
@@ -5,7 +5,9 @@ import { Context, Duration, Effect, FileSystem, Layer, Option, Ref, Schema } fro
import { ChildProcess } from "effect/unstable/process"
import { parse, type ParseError } from "jsonc-parser"
import path from "node:path"
import { stripVTControlCharacters } from "node:util"
import { action, parseReleaseVersion, type Policy } from "./updater-action"
import { errorMessage } from "../util/error"
export const methods = ["curl", "npm", "pnpm", "bun", "yarn", "vp", "brew"] as const
@@ -13,10 +15,82 @@ export type Method = (typeof methods)[number]
export type RunResult = { readonly type: "available" | "installed"; readonly version: string }
export type CheckResult = RunResult | { readonly type: "unavailable"; readonly message: string }
export class UpgradeError extends Error {
readonly title: string
readonly detail: string
readonly command?: string
readonly retry: string
constructor(
input: {
readonly title: string
readonly detail: string
readonly command?: string
readonly retry: string
},
options?: ErrorOptions,
) {
super(input.detail, options)
this.name = "UpgradeError"
this.title = input.title
this.detail = input.detail
this.command = input.command
this.retry = input.retry
}
}
const decodeVpPackages = Schema.decodeUnknownOption(
Schema.fromJsonString(Schema.Array(Schema.Struct({ name: Schema.String }))),
)
const installNames: Record<Method, string> = {
curl: "The OpenCode installer",
npm: "npm",
pnpm: "pnpm",
bun: "Bun",
yarn: "Yarn",
vp: "Vite+",
brew: "Homebrew",
}
function conciseDetail(input: string) {
const lines = stripVTControlCharacters(input)
.trim()
.replaceAll("\r", "\n")
.split("\n")
.map((line) => line.trimEnd())
.filter((line) => line.trim().length > 0)
const tail = lines.slice(-12).join("\n")
const clipped = tail.length > 2_000
const detail = clipped ? `${tail.slice(-1_999)}` : tail
if (!detail) return
if (lines.length <= 12 && !clipped) return detail
return `${detail}\n\nOutput shortened to the last 12 lines.`
}
function errorDetail(cause: unknown): string {
if (cause instanceof AppProcess.AppProcessError) {
const stderr = conciseDetail(cause.stderr ?? "")
if (stderr) return stderr
if (cause.cause !== undefined) return errorDetail(cause.cause)
return cause.message
}
if (cause instanceof Error) {
const detail = cause.cause === undefined ? undefined : errorDetail(cause.cause)
if (!detail || detail === cause.message) return cause.message
return `${cause.message}: ${detail}`
}
return errorMessage(cause)
}
function resultDetail(result: { code: number; stdout: string; stderr: string }) {
return (
conciseDetail(result.stderr) ??
conciseDetail(result.stdout) ??
`The command exited with code ${result.code} without any error output.`
)
}
export interface Interface {
readonly run: (onInstall?: (version: string) => void) => Effect.Effect<RunResult | undefined>
readonly check: () => Effect.Effect<CheckResult | undefined, Error>
@@ -24,9 +98,9 @@ export interface Interface {
readonly method: () => Effect.Effect<Method | undefined>
readonly latest: () => Effect.Effect<string, Error>
readonly upgrade: (method: Method, version: string) => Effect.Effect<void, Error>
readonly removal: (method: Method) =>
| { readonly command: ReadonlyArray<string>; readonly run: Effect.Effect<void, Error> }
| undefined
readonly removal: (
method: Method,
) => { readonly command: ReadonlyArray<string>; readonly run: Effect.Effect<void, Error> } | undefined
}
export class Service extends Context.Service<Service, Interface>()("@opencode/cli/Updater") {}
@@ -89,7 +163,6 @@ const make = Effect.gen(function* () {
stdout: result.stdout.toString("utf8"),
stderr: result.stderr.toString("utf8"),
})),
Effect.orElseSucceed(() => ({ code: 1, stdout: "", stderr: "" })),
)
})
@@ -119,7 +192,11 @@ const make = Effect.gen(function* () {
]
const results = yield* Effect.forEach(
checks,
(check) => exec(check.command).pipe(Effect.map((result) => ({ check, result }))),
(check) =>
exec(check.command).pipe(
Effect.orElseSucceed(() => ({ code: 1, stdout: "", stderr: "" })),
Effect.map((result) => ({ check, result })),
),
{ concurrency: "unbounded" },
)
return results.find((result) => {
@@ -144,11 +221,7 @@ const make = Effect.gen(function* () {
return {
command,
run: exec(command, "5 minutes").pipe(
Effect.flatMap((result) =>
result.code === 0
? Effect.void
: Effect.fail(new Error(result.stderr.trim() || `Failed to uninstall with ${method}`)),
),
Effect.flatMap((result) => (result.code === 0 ? Effect.void : Effect.fail(new Error(resultDetail(result))))),
),
}
}
@@ -163,14 +236,44 @@ const make = Effect.gen(function* () {
signal: AbortSignal.any([signal, AbortSignal.timeout(10_000)]),
},
),
catch: (cause) => new Error("Failed to check for updates", { cause }),
catch: (cause) =>
new UpgradeError(
{
title: "Could not check for OpenCode updates",
detail: errorDetail(cause),
retry: "Check your network, then run opencode upgrade again.",
},
{ cause },
),
})
if (!response.ok) return yield* Effect.fail(new Error(`Update check failed with status ${response.status}`))
if (!response.ok)
return yield* Effect.fail(
new UpgradeError({
title: "Could not check for OpenCode updates",
detail: `The update service returned HTTP ${response.status}.`,
retry: "Try again in a few minutes.",
}),
)
const data: { version: string; metadata?: { package?: string } } = yield* Effect.tryPromise({
try: () => response.json(),
catch: (cause) => new Error("Failed to read update information", { cause }),
catch: (cause) =>
new UpgradeError(
{
title: "Could not read the OpenCode update information",
detail: errorDetail(cause),
retry: "Try again in a few minutes.",
},
{ cause },
),
})
if (!data.metadata?.package) return yield* Effect.fail(new Error("Update information did not include a package"))
if (!data.metadata?.package)
return yield* Effect.fail(
new UpgradeError({
title: "Could not read the OpenCode update information",
detail: "The update service returned incomplete release information.",
retry: "Try again in a few minutes.",
}),
)
return { package: data.metadata.package, version: data.version }
})
@@ -185,6 +288,40 @@ const make = Effect.gen(function* () {
fs.remove(directory, { recursive: true, force: true }).pipe(Effect.ignore),
)
const runUpgrade = (input: {
readonly method: Method
readonly command: string[]
readonly displayCommand?: string[]
readonly title?: string
readonly retry?: string
}) => {
const failure = (detail: string, cause?: unknown) =>
new UpgradeError(
{
title: input.title ?? `${installNames[input.method]} could not install OpenCode`,
detail,
command: (input.displayCommand ?? input.command).join(" "),
retry: input.retry ?? "Fix the issue above, then run opencode upgrade again.",
},
cause === undefined ? undefined : { cause },
)
return exec(input.command, "5 minutes").pipe(
Effect.flatMap((result) =>
result.code === 0 ? Effect.succeed(result) : Effect.fail(failure(resultDetail(result))),
),
Effect.mapError((cause) =>
cause instanceof UpgradeError
? cause
: failure(
cause instanceof AppProcess.AppProcessError && cause.stderr === undefined && cause.cause === undefined
? `Failed to update with ${input.method}`
: errorDetail(cause),
cause,
),
),
)
}
const upgrade = Effect.fnUntraced(function* (method: Method, input: string) {
if (!parseReleaseVersion(input)) return yield* Effect.fail(new Error(`Invalid version: ${input}`))
const version = input.trim().replace(/^v/, "")
@@ -212,31 +349,54 @@ const make = Effect.gen(function* () {
? ["vp", "install", "-g", "--force", target]
: ["vp", "update", "-g", target],
}
const result = yield* Effect.scoped(
yield* Effect.scoped(
Effect.gen(function* () {
if (method === "bun") {
// Bun does not prune old versions from its shared package cache.
yield* fs.makeDirectory(global.cache, { recursive: true })
const cache = yield* temporaryDirectory("update-")
return yield* exec(["bun", "install", "--global", "--trust", "--cache-dir", cache, target], "5 minutes")
return yield* runUpgrade({
method,
command: ["bun", "install", "--global", "--trust", "--cache-dir", cache, target],
displayCommand: ["bun", "install", "--global", "--trust", target],
})
}
if (method === "curl") {
yield* fs.makeDirectory(global.cache, { recursive: true })
const directory = yield* temporaryDirectory("update-")
const installer = path.join(directory, "install")
const download = yield* exec(
["curl", "-fsSL", "-o", installer, "https://opencode.ai/v2/install"],
"5 minutes",
)
if (download.code !== 0) return download
return yield* exec(["bash", installer, "--version", version, "--no-modify-path"], "5 minutes")
yield* runUpgrade({
method,
command: ["curl", "-fsSL", "-o", installer, "https://opencode.ai/v2/install"],
displayCommand: ["curl", "-fsSL", "https://opencode.ai/v2/install"],
title: "Could not download the OpenCode installer",
retry: "Check your network, then run opencode upgrade again.",
})
return yield* runUpgrade({
method,
command: ["bash", installer, "--version", version, "--no-modify-path"],
displayCommand: ["opencode", "upgrade", version, "--method", "curl"],
title: "The OpenCode installer failed",
})
}
if (method === "brew") return yield* exec(["brew", "upgrade", packageName], "5 minutes")
return yield* exec(commands[method], "5 minutes")
if (method === "brew") return yield* runUpgrade({ method, command: ["brew", "upgrade", packageName] })
return yield* runUpgrade({ method, command: commands[method] })
}),
).pipe(Effect.mapError((cause) => new Error(`Failed to update with ${method}`, { cause })))
if (result.code === 0) return
return yield* Effect.fail(new Error(result.stderr.trim() || `Failed to update with ${method}`))
).pipe(
Effect.mapError((cause) =>
cause instanceof UpgradeError
? cause
: new UpgradeError(
{
title: "Could not prepare the OpenCode upgrade",
detail: errorDetail(cause),
retry: "Fix the issue above, then run opencode upgrade again.",
},
{ cause },
),
),
Effect.asVoid,
)
})
const inspect = Effect.fnUntraced(function* () {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/client",
"version": "2.0.11",
"version": "2.0.12",
"type": "module",
"license": "MIT",
"repository": {
+18 -18
View File
@@ -509,12 +509,12 @@ export type PromptAgentAttachment = { name: string; mention?: PromptMention }
export type PromptSkillAttachment = { id: string; name: string; text?: string; mention?: PromptMention }
export type SessionMessageAssistantText = { type: "text"; text: string; native?: SessionMessageProviderState }
export type SessionMessageAssistantText = { type: "text"; text: string; state?: SessionMessageProviderState }
export type SessionMessageAssistantReasoning = {
type: "reasoning"
text: string
native?: SessionMessageProviderState
state?: SessionMessageProviderState
time?: { created: number; completed?: number }
}
@@ -1354,12 +1354,12 @@ export type SessionToolCalled = {
}
}
export type SessionMessageAssistantText1 = { type: "text"; text: string; native?: SessionMessageProviderState1 }
export type SessionMessageAssistantText1 = { type: "text"; text: string; state?: SessionMessageProviderState1 }
export type SessionMessageAssistantReasoning1 = {
type: "reasoning"
text: string
native?: SessionMessageProviderState1
state?: SessionMessageProviderState1
time?: { created: number; completed?: number }
}
@@ -1762,7 +1762,7 @@ export type SessionMessageCompactionCompleted = {
status: "completed"
reason: "auto" | "manual"
model?: ModelRef
native?: SessionMessageProviderState
providerState?: SessionMessageProviderState
summary: string
recent: string
providerContext?: SessionProviderContext
@@ -2228,7 +2228,7 @@ export type SessionMessageAssistant = {
snapshot?: { start?: string; end?: string; files?: Array<string> }
finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
rawFinish?: string
native?: SessionMessageProviderState
providerState?: SessionMessageProviderState
cost?: MoneyUSD
tokens?: TokenUsageInfo
error?: SessionStructuredError
@@ -3102,11 +3102,11 @@ export type SessionImportInput = {
readonly agent: string
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly content: ReadonlyArray<
| { readonly type: "text"; readonly text: string; readonly native?: { readonly [x: string]: JsonValue } }
| { readonly type: "text"; readonly text: string; readonly state?: { readonly [x: string]: JsonValue } }
| {
readonly type: "reasoning"
readonly text: string
readonly native?: { readonly [x: string]: JsonValue }
readonly state?: { readonly [x: string]: JsonValue }
readonly time?: { readonly created: number; readonly completed?: number }
}
| {
@@ -3180,7 +3180,7 @@ export type SessionImportInput = {
readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray<string> }
readonly finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
readonly rawFinish?: string
readonly native?: { readonly [x: string]: JsonValue }
readonly providerState?: { readonly [x: string]: JsonValue }
readonly cost?: number
readonly tokens?: {
readonly input: number
@@ -3214,7 +3214,7 @@ export type SessionImportInput = {
readonly status: "completed"
readonly reason: "auto" | "manual"
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly native?: { readonly [x: string]: JsonValue }
readonly providerState?: { readonly [x: string]: JsonValue }
readonly summary: string
readonly recent: string
readonly providerContext?: {
@@ -3419,11 +3419,11 @@ export type SessionImportInput = {
readonly agent: string
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly content: ReadonlyArray<
| { readonly type: "text"; readonly text: string; readonly native?: { readonly [x: string]: JsonValue } }
| { readonly type: "text"; readonly text: string; readonly state?: { readonly [x: string]: JsonValue } }
| {
readonly type: "reasoning"
readonly text: string
readonly native?: { readonly [x: string]: JsonValue }
readonly state?: { readonly [x: string]: JsonValue }
readonly time?: { readonly created: number; readonly completed?: number }
}
| {
@@ -3497,7 +3497,7 @@ export type SessionImportInput = {
readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray<string> }
readonly finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
readonly rawFinish?: string
readonly native?: { readonly [x: string]: JsonValue }
readonly providerState?: { readonly [x: string]: JsonValue }
readonly cost?: number
readonly tokens?: {
readonly input: number
@@ -3531,7 +3531,7 @@ export type SessionImportInput = {
readonly status: "completed"
readonly reason: "auto" | "manual"
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly native?: { readonly [x: string]: JsonValue }
readonly providerState?: { readonly [x: string]: JsonValue }
readonly summary: string
readonly recent: string
readonly providerContext?: {
@@ -3736,11 +3736,11 @@ export type SessionImportInput = {
readonly agent: string
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly content: ReadonlyArray<
| { readonly type: "text"; readonly text: string; readonly native?: { readonly [x: string]: JsonValue } }
| { readonly type: "text"; readonly text: string; readonly state?: { readonly [x: string]: JsonValue } }
| {
readonly type: "reasoning"
readonly text: string
readonly native?: { readonly [x: string]: JsonValue }
readonly state?: { readonly [x: string]: JsonValue }
readonly time?: { readonly created: number; readonly completed?: number }
}
| {
@@ -3814,7 +3814,7 @@ export type SessionImportInput = {
readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray<string> }
readonly finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
readonly rawFinish?: string
readonly native?: { readonly [x: string]: JsonValue }
readonly providerState?: { readonly [x: string]: JsonValue }
readonly cost?: number
readonly tokens?: {
readonly input: number
@@ -3848,7 +3848,7 @@ export type SessionImportInput = {
readonly status: "completed"
readonly reason: "auto" | "manual"
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly native?: { readonly [x: string]: JsonValue }
readonly providerState?: { readonly [x: string]: JsonValue }
readonly summary: string
readonly recent: string
readonly providerContext?: {
+7 -7
View File
@@ -846,7 +846,7 @@ export function createData(config: CreateDataInput) {
existing.error = undefined
existing.finish = undefined
existing.rawFinish = undefined
existing.native = undefined
existing.providerState = undefined
existing.time.created = event.data.started
existing.time.streamed = undefined
existing.time.completed = undefined
@@ -880,7 +880,7 @@ export function createData(config: CreateDataInput) {
assistant.time.completed = event.created
assistant.finish = event.data.finish
assistant.rawFinish = event.data.rawFinish
assistant.native = event.data.providerState
assistant.providerState = event.data.providerState
assistant.cost = event.data.cost
assistant.tokens = event.data.tokens
if (event.data.snapshot) assistant.snapshot = { ...assistant.snapshot, end: event.data.snapshot }
@@ -892,7 +892,7 @@ export function createData(config: CreateDataInput) {
assistant.time.completed = event.created
assistant.finish = event.data.finish ?? "error"
assistant.rawFinish = event.data.rawFinish
assistant.native = event.data.providerState
assistant.providerState = event.data.providerState
assistant.error = event.data.error
assistant.retry = undefined
if (event.data.cost !== undefined && event.data.tokens !== undefined) {
@@ -984,7 +984,7 @@ export function createData(config: CreateDataInput) {
assistant.content.push({
type: "reasoning",
text: "",
native: event.data.state,
state: event.data.state,
time: { created: event.created },
})
})
@@ -998,7 +998,7 @@ export function createData(config: CreateDataInput) {
message.editReasoning(event.data.sessionID, event.data.assistantMessageID, (reasoning) => {
reasoning.text = event.data.text
reasoning.time = { created: reasoning.time?.created ?? event.created, completed: event.created }
if (event.data.state !== undefined) reasoning.native = event.data.state
if (event.data.state !== undefined) reasoning.state = event.data.state
})
return
case "session.retry.scheduled":
@@ -1105,7 +1105,7 @@ export function createData(config: CreateDataInput) {
status: "completed",
reason: event.data.reason,
model: event.data.model,
native: event.data.providerState,
providerState: event.data.providerState,
providerContext: event.data.providerContext,
summary: event.data.text,
recent: event.data.recent,
@@ -1120,7 +1120,7 @@ export function createData(config: CreateDataInput) {
status: "completed",
reason: event.data.reason,
model: event.data.model,
native: event.data.providerState,
providerState: event.data.providerState,
providerContext: event.data.providerContext,
summary: event.data.text,
recent: event.data.recent,
@@ -135,7 +135,7 @@ test.each(["started", "cancelled", "failed"])(
status: "completed",
summary: "Summary",
model,
native: providerState,
providerState,
providerContext,
cost: 0.01,
tokens,
+2
View File
@@ -6,6 +6,8 @@
- Tool schemas are the model-facing Interface. Keep arguments minimal and natural to the operation; never add unrelated IDs as ambient capability tokens.
- State model-visible diagnostics, logs, tool descriptions, and instructions directly. The execution context is already clear; do not repeat `Code Mode` or `CodeMode` unless the distinction is necessary.
- When interpreter behavior or support changes, update `interpreter-support.md` and direct tests in the same PR.
- Program values are `Value` (`src/interpreter/objects.ts`); host values are `unknown` and are copied in at the boundaries (`fromHost`, `fromJson`). Do not widen program-facing signatures back to `unknown`.
- A built-in kind of object is one `Obj` subclass (`Wrapper` for host-backed data such as Date or Map, `Opaque` for machinery such as functions and promises) that overrides `tag`, `toString`, `toPrimitive`, `inspect`, `toHost`, and `iterator` as needed. Do not add `instanceof` ladders over the built-in classes elsewhere; ask the object.
## OpenAPI
+2 -1
View File
@@ -275,7 +275,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
- [x] `Object.is` for supported data values.
- [x] `Object.groupBy` over finite collections and custom synchronous iterators/generators, with string-key coercion
and plain-object results.
- [x] `Object.prototype` methods on values: `toString` (`"[object Array]"`), `toLocaleString` (calls the value's
- [x] `Object.prototype` methods on values: `toString` (`"[object Array]"`, `"[object Map]"`, `"[object Promise]"`, and so
on for every built-in kind, as JS reports through `Symbol.toStringTag`), `toLocaleString` (calls the value's
`toString`, as in JS), `valueOf`, `hasOwnProperty`, `isPrototypeOf`, and `propertyIsEnumerable`.
## Arrays
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/codemode",
"version": "2.0.11",
"version": "2.0.12",
"description": "Effect-native confined code execution over schema-described tools",
"type": "module",
"license": "MIT",
+6 -5
View File
@@ -15,6 +15,7 @@ import {
Obj,
PromiseObj,
record,
type Value,
SetObj,
URLSearchParamsObj,
HeadersObj,
@@ -23,14 +24,14 @@ import { typeofValue } from "./interpreter/references.js"
export type Json = Schema.Json
type Replacer<R> = (args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
type Replacer<R> = (args: Array<Value>) => Effect.Effect<Value, unknown, R>
/**
* What `JSON.stringify` would serialize for a program value, as host JSON: `toJSON` is honored, functions and
* `undefined` vanish, non-finite numbers become null, and everything else is copied. Two departures from JS
* so a mistake is not a silent `{}`: an Error serializes as `{ name, message, ...own }`, and a promise throws.
*/
export const toJson = <R>(ctx: Interpreter<R>, value: unknown, replacer?: Replacer<R>) =>
export const toJson = <R>(ctx: Interpreter<R>, value: Value, replacer?: Replacer<R>) =>
walk(ctx, value, replacer, false)
/**
@@ -38,11 +39,11 @@ export const toJson = <R>(ctx: Interpreter<R>, value: unknown, replacer?: Replac
* awaited, a Set crosses as an array, a URLSearchParams as its query string, a Uint8Array asks to be encoded as
* text first, and a `__proto__` key is dropped so host code can never receive one.
*/
export const toBoundary = <R>(ctx: Interpreter<R>, value: unknown) => walk(ctx, value, undefined, true)
export const toBoundary = <R>(ctx: Interpreter<R>, value: Value) => walk(ctx, value, undefined, true)
const walk = <R>(
ctx: Interpreter<R>,
value: unknown,
value: Value,
replacer: Replacer<R> | undefined,
boundary: boolean,
): Effect.Effect<Json | undefined, unknown, R> => {
@@ -104,7 +105,7 @@ const walk = <R>(
}
/** Host JSON as program values: objects and arrays are copied, primitives pass through. */
export const fromJson = <R>(ctx: Interpreter<R>, value: unknown): unknown => {
export const fromJson = <R>(ctx: Interpreter<R>, value: Json | undefined): Value => {
if (value === null || typeof value !== "object") return value
if (Array.isArray(value))
return new Arr(
+10 -11
View File
@@ -1,13 +1,12 @@
import { Effect, Exit } from "effect"
import { coerceToNumber, coerceToString } from "../stdlib/value.js"
import type { Interpreter } from "./interpreter.js"
import { primitivePrototype } from "./intrinsics.js"
import { typeError } from "./model.js"
import { Callable, get, Native, DateObj, Obj } from "./objects.js"
import { Callable, get, Native, DateObj, Obj, coerceToNumber, coerceToString, type Value } from "./objects.js"
import { typeofValue } from "./references.js"
export type IteratorCursor<R> = {
readonly next: Effect.Effect<{ readonly done: boolean; readonly value: unknown }, unknown, R>
readonly next: Effect.Effect<{ readonly done: boolean; readonly value: Value }, unknown, R>
readonly close: Effect.Effect<void, unknown, R>
}
@@ -27,9 +26,9 @@ export const preserveConsumerError = <A, R>(
*/
export const toPrimitive = <R>(
ctx: Interpreter<R>,
value: unknown,
value: Value,
hint: "number" | "string" | "default",
): Effect.Effect<unknown, unknown, R> => {
): Effect.Effect<Value, unknown, R> => {
if (!(value instanceof Obj)) return Effect.succeed(value)
const asString = hint === "string" || (hint === "default" && value instanceof DateObj)
const order = asString ? ["toString", "valueOf"] : ["valueOf", "toString"]
@@ -45,30 +44,30 @@ export const toPrimitive = <R>(
}
/** Invoke(value, name): calls the method the value would find through its prototype. */
export const invoke = <R>(ctx: Interpreter<R>, value: unknown, name: string, label: string) => {
export const invoke = <R>(ctx: Interpreter<R>, value: Value, name: string, label: string) => {
const target = value instanceof Obj ? value : primitivePrototype(ctx.builtins, value)
if (target === undefined) throw typeError(`${label} called on null or undefined.`)
return ctx.call(get(target, name), value, [])
}
export const toPrimitiveString = <R>(ctx: Interpreter<R>, value: unknown) =>
export const toPrimitiveString = <R>(ctx: Interpreter<R>, value: Value) =>
Effect.map(toPrimitive(ctx, value, "string"), coerceToString)
export const toPrimitiveNumber = <R>(ctx: Interpreter<R>, value: unknown) =>
export const toPrimitiveNumber = <R>(ctx: Interpreter<R>, value: Value) =>
Effect.map(toPrimitive(ctx, value, "number"), coerceToNumber)
// The single acceptance list for callbacks: collections, sort, string replacers,
// Array.from mappers, and promise reactions all admit exactly these callables.
// Admission means dispatchable, not necessarily invocable: new-requiring
// constructors pass the gate and throw a TypeError on call, like JS.
export const isSupportedCallback = (value: unknown): value is Callable =>
export const isSupportedCallback = (value: Value): value is Callable =>
value instanceof Callable && !(value instanceof Native && !value.callback)
export const applyCollectionCallback = <R>(
ctx: Interpreter<R>,
callback: unknown,
callback: Value,
name: string,
): ((args: Array<unknown>) => Effect.Effect<unknown, unknown, R>) => {
): ((args: Array<Value>) => Effect.Effect<Value, unknown, R>) => {
if (!isSupportedCallback(callback)) {
if (typeofValue(callback) === "function") {
throw typeError(
+18 -7
View File
@@ -6,10 +6,21 @@ import { type AstNode, formatLocation, PendingThrow, Throw, sourceLocation, type
import { containsRuntimeReference } from "./references.js"
import { createErrorValue, type ErrorType, isErrorType } from "./intrinsics.js"
import { constructor, methods, prototypeFrom, receiver } from "./native.js"
import { type Callable, define, get, has, hidden, type Native, Arr, ErrorObj, Obj } from "./objects.js"
import {
type Callable,
define,
get,
has,
hidden,
type Native,
Arr,
ErrorObj,
Obj,
coerceToString,
type Value,
} from "./objects.js"
import type { Interpreter } from "./interpreter.js"
import { formatValue } from "../stdlib/console.js"
import { coerceToString } from "../stdlib/value.js"
export const normalizeError = (error: unknown): Diagnostic => {
if (error instanceof PendingThrow) {
@@ -86,7 +97,7 @@ export const locate = (error: unknown, node?: AstNode): unknown => {
}
/** The program value a handler receives for a failure; one failure always yields the same value. */
export const materialize = <R>(ctx: Interpreter<R>, thrown: unknown): unknown => {
export const materialize = <R>(ctx: Interpreter<R>, thrown: unknown): Value => {
if (thrown instanceof Throw) return thrown.value
const builtins = ctx.builtins
if (thrown instanceof PendingThrow) {
@@ -113,7 +124,7 @@ const errorToString = (self: Obj): string => {
export const createAggregateErrorValue = <R>(
ctx: Interpreter<R>,
errors: Array<unknown>,
errors: Array<Value>,
message: string,
proto: Obj = ctx.builtins.AggregateError,
) => {
@@ -124,13 +135,13 @@ export const createAggregateErrorValue = <R>(
const constructAggregateErrorValue = <R>(
ctx: Interpreter<R>,
args: Array<unknown>,
args: Array<Value>,
proto: Obj,
): Effect.Effect<ErrorObj, unknown, R> =>
Effect.gen(function* () {
const cursor = yield* ctx.iterate(args[0])
if (cursor === undefined) throw typeError("new AggregateError(...) expects a synchronous iterable of errors.")
const errors: Array<unknown> = []
const errors: Array<Value> = []
while (true) {
const step = yield* cursor.next
if (step.done) {
@@ -144,7 +155,7 @@ const constructAggregateErrorValue = <R>(
export const errorGlobal = <R>(type: ErrorType, ctx: Interpreter<R>) => {
const builtins = ctx.builtins
const prototype = builtins[type]
const construct = (args: Array<unknown>, newTarget: Callable) => {
const construct = (args: Array<Value>, newTarget: Callable) => {
const proto = prototypeFrom(newTarget, prototype)
const created =
type === "AggregateError"
+2 -1
View File
@@ -4,6 +4,7 @@ import type { DataValue, Diagnostic, ResolvedExecutionLimits, Result } from "../
import { toBoundary } from "../data.js"
import { ToolRuntime } from "../tool-runtime.js"
import { normalizeError } from "./errors.js"
import type { Value } from "./objects.js"
import { createBuiltins } from "./intrinsics.js"
import { Pending } from "./promises.js"
import { Interpreter } from "./interpreter.js"
@@ -13,7 +14,7 @@ export const executeProgram = <R>(
prepared: ToolRuntime.Prepared<R>,
limits: ResolvedExecutionLimits,
hooks: ToolRuntime.Hooks<R>,
globals?: (ctx: Interpreter<R>) => ReadonlyArray<readonly [string, unknown]>,
globals?: (ctx: Interpreter<R>) => ReadonlyArray<readonly [string, Value]>,
): Effect.Effect<Result, never, R> => {
if (code.trim().length === 0) {
return Effect.succeed({
+13 -41
View File
@@ -1,6 +1,5 @@
import { Effect } from "effect"
import type { Extension } from "../extension.js"
import { coerceToString } from "../stdlib/value.js"
import { type ExtensionInvocation, hooked } from "../tool-runtime.js"
import type { Interpreter } from "./interpreter.js"
import { createErrorValue, isErrorType } from "./intrinsics.js"
@@ -8,9 +7,7 @@ import { MAX_VALUE_DEPTH } from "./limits.js"
import { PendingThrow, Throw, typeError } from "./model.js"
import { fn } from "./native.js"
import {
Callable,
define,
entries,
get,
has,
hidden,
@@ -19,18 +16,17 @@ import {
Bytes,
DateObj,
ErrorObj,
GeneratorObj,
IteratorObj,
HeadersObj,
MapObj,
Obj,
PromiseObj,
RegExpObj,
SetObj,
URLObj,
URLSearchParamsObj,
HeadersObj,
coerceToString,
type Value,
} from "./objects.js"
import { describeValue } from "./references.js"
import { describeValue, isOpaque } from "./references.js"
/**
* The global bindings of one run's extensions. Everything crossing the boundary is converted: plain data and
@@ -40,35 +36,18 @@ import { describeValue } from "./references.js"
export const extensionGlobals = <R>(
ctx: Interpreter<R>,
extensions: ReadonlyArray<Extension>,
): ReadonlyArray<readonly [string, unknown]> => {
): ReadonlyArray<readonly [string, Value]> => {
const builtins = ctx.builtins
const toHost = (value: unknown, label: string, depth = 0, seen = new Set<object>()): unknown => {
const toHost = (value: Value, label: string, depth = 0, seen = new Set<object>()): unknown => {
if (depth > MAX_VALUE_DEPTH) throw typeError(`${label} exceeds the maximum value depth of ${MAX_VALUE_DEPTH}.`)
if (value === null || typeof value !== "object") {
if (isPrimitive(value)) return value
throw typeError(`${label} contains ${describeValue(value)}, which cannot be passed to an extension.`)
}
if (value instanceof Bytes) return new Uint8Array(value.bytes)
if (value instanceof DateObj) return new Date(value.time)
if (value instanceof RegExpObj) return new RegExp(value.regex.source, value.regex.flags)
if (value instanceof URLObj) return new URL(value.url.href)
if (value instanceof URLSearchParamsObj) return new URLSearchParams(value.params)
if (value instanceof HeadersObj) return new Headers(value.headers)
const next = (item: unknown) => toHost(item, label, depth + 1, seen)
if (value instanceof MapObj) return new Map([...value.map].map(([key, item]) => [next(key), next(item)]))
if (value instanceof SetObj) return new Set([...value.set].map(next))
if (
!(value instanceof Obj) ||
value instanceof Callable ||
value instanceof GeneratorObj ||
value instanceof IteratorObj ||
value instanceof PromiseObj
) {
if (isPrimitive(value)) return value
if (!(value instanceof Obj) || isOpaque(value)) {
throw typeError(`${label} contains ${describeValue(value)}, which cannot be passed to an extension.`)
}
if (seen.has(value)) throw typeError(`${label} contains a circular value.`)
seen.add(value)
const next = (item: Value) => toHost(item, label, depth + 1, seen)
if (value instanceof ErrorObj) {
const name = coerceToString(get(value, "name"))
const message = get(value, "message")
@@ -89,19 +68,12 @@ export const extensionGlobals = <R>(
seen.delete(value)
return copied
}
const copied =
value instanceof Arr
? value.items.map(next)
: Object.fromEntries(
entries(value)
.filter(([key]) => key !== "__proto__")
.map(([key, item]) => [key, next(item)]),
)
const copied = value.toHost(next)
seen.delete(value)
return copied
}
const fromHost = (value: unknown, label: string, depth = 0, seen = new Set<object>()): unknown => {
const fromHost = (value: unknown, label: string, depth = 0, seen = new Set<object>()): Value => {
if (depth > MAX_VALUE_DEPTH) throw typeError(`${label} exceeds the maximum value depth of ${MAX_VALUE_DEPTH}.`)
if (isPrimitive(value)) return value
if (typeof value === "function") return wrap(value, label)
@@ -200,7 +172,7 @@ export const extensionGlobals = <R>(
*/
const uncrossed = new Set(["stack", "constructor", "toString", "__proto__"])
const left = Symbol("left behind")
const crossing = (convert: () => unknown): unknown => {
const crossing = <T>(convert: () => T): T | typeof left => {
try {
return convert()
} catch (reason) {
@@ -219,7 +191,7 @@ const hostErrors = new Map<string, ErrorConstructor>([
])
// The primitives the interpreter operates on; symbols and BigInts are not among them.
const isPrimitive = (value: unknown): boolean =>
const isPrimitive = (value: unknown): value is string | number | boolean | null | undefined =>
value === null ||
value === undefined ||
typeof value === "string" ||
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import { fn, type Method, methods, receiver } from "./native.js"
import { AsyncIteratorSymbol, type GeneratorRequestKind, IteratorSymbol } from "./model.js"
import { define, hidden, GeneratorObj } from "./objects.js"
import { define, hidden, GeneratorObj, type Value } from "./objects.js"
import type { Interpreter } from "./interpreter.js"
/** `next`/`return`/`throw` on the generator prototypes; async generators answer with promises. */
@@ -13,9 +13,9 @@ export const generatorGlobals = <R>(ctx: Interpreter<R>): void => {
const request = (kind: GeneratorRequestKind): Method => [
kind,
1,
(thisValue: unknown, args: Array<unknown>) => {
(thisValue: Value, args: Array<Value>) => {
const generator = receiver(GeneratorObj, thisValue, `${label}.prototype.${kind}`)
const requested = generator.request(kind, args[0]) as Effect.Effect<unknown, unknown, R>
const requested = generator.request(kind, args[0]) as Effect.Effect<Value, unknown, R>
return generator.asynchronous ? ctx.pending.create(requested) : requested
},
]
+3 -2
View File
@@ -1,4 +1,5 @@
import { Effect } from "effect"
import type { Value } from "./objects.js"
import { arrayGlobal } from "../stdlib/array.js"
import { textDecoderGlobal, textEncoderGlobal, uint8ArrayGlobal } from "../stdlib/bytes.js"
import { mapGlobal, setGlobal } from "../stdlib/collections.js"
@@ -51,7 +52,7 @@ const symbolGlobal = <R>(ctx: Interpreter<R>) => {
return symbol
}
type Factory = <R>(ctx: Interpreter<R>) => unknown
type Factory = <R>(ctx: Interpreter<R>) => Value
// A table rather than a list so the names are known before any runtime exists.
const table: Record<string, Factory> = {
@@ -100,7 +101,7 @@ const table: Record<string, Factory> = {
export const globalNames: ReadonlySet<string> = new Set(Object.keys(table))
/** The immutable global bindings of every program, in declaration order. */
export const globals = <R>(ctx: Interpreter<R>): ReadonlyArray<readonly [string, unknown]> => {
export const globals = <R>(ctx: Interpreter<R>): ReadonlyArray<readonly [string, Value]> => {
generatorGlobals(ctx)
iteratorGlobals(ctx)
return Object.entries(table).map(([name, factory]) => [name, factory(ctx)] as const)
+106 -141
View File
@@ -32,6 +32,7 @@ import type {
Statement,
Super,
SwitchStatement,
Literal,
TemplateLiteral,
ThrowStatement,
TryStatement,
@@ -75,20 +76,16 @@ import {
Native,
parseArrayIndex,
Arr,
Bytes,
DateObj,
Fn,
GeneratorObj,
IteratorObj,
MapObj,
Obj,
PromiseObj,
SetObj,
URLSearchParamsObj,
HeadersObj,
record,
remove,
set,
coerceToNumber,
coerceToString,
type Value,
} from "./objects.js"
import { preserveConsumerError } from "./callback.js"
import { Pending, resolvePromise, resolvePromiseValue } from "./promises.js"
@@ -96,7 +93,7 @@ import { containsOpaqueReference, describeValue, rejectCircularInsertion, typeof
import { ScopeStack } from "./scope.js"
import { constructRegExp } from "../stdlib/regexp.js"
import { enumerableSource } from "../stdlib/object.js"
import { coerceToNumber, coerceToString, compoundOperators } from "../stdlib/value.js"
import { compoundOperators } from "../stdlib/value.js"
// What a loop does with its body's result: exit with a StatementResult, or undefined to keep iterating.
// Unlabelled break ends this loop; a label the loop does not carry propagates outward.
@@ -132,7 +129,14 @@ const calleeDescription = (node: Expression | Super | undefined): string | undef
}
// OrdinaryHasInstance: walk the left operand's chain looking for the constructor's `prototype`.
const instanceofValue = (lhs: unknown, rhs: unknown, node: AstNode): boolean => {
// acorn types every literal as possibly a BigInt or RegExp; regex literals become RegExp objects before this is asked.
const literal = (node: Literal): Value => {
if (typeof node.value === "bigint") throw typeError("BigInt literals are not supported.", node)
if (node.value instanceof RegExp) throw unsupportedSyntax("RegExpLiteral", node)
return node.value
}
const instanceofValue = (lhs: Value, rhs: Value, node: AstNode): boolean => {
if (!(rhs instanceof Callable)) {
throw typeError("The right-hand side of 'instanceof' is not callable.", node)
}
@@ -229,7 +233,7 @@ const loopDeclaration = (left: VariableDeclaration | Pattern, statement: "for...
type CustomIterator = {
iterator: Obj
next: unknown
next: Value
asynchronous: boolean
}
@@ -237,13 +241,13 @@ type CustomIterator = {
type MemberReference = {
target: Obj
key: PropertyKey
receiver: unknown
receiver: Value
}
type GeneratorRequest = {
kind: GeneratorRequestKind
value: unknown
response: Deferred.Deferred<unknown, unknown>
value: Value
response: Deferred.Deferred<Value, unknown>
}
type GeneratorState = {
@@ -269,7 +273,7 @@ export class Interpreter<R> {
readonly pending: Pending<R>
readonly builtins: Builtins
readonly logs?: Array<string>
readonly globals?: (ctx: Interpreter<R>) => ReadonlyArray<readonly [string, unknown]>
readonly globals?: (ctx: Interpreter<R>) => ReadonlyArray<readonly [string, Value]>
}) {
this.tools = options.tools
this.pending = options.pending
@@ -283,27 +287,27 @@ export class Interpreter<R> {
}
}
run(program: Program): Effect.Effect<unknown, unknown, R> {
run(program: Program): Effect.Effect<Value, unknown, R> {
return this.root.run(program)
}
call(callable: unknown, thisValue: unknown, args: Array<unknown>): Effect.Effect<unknown, unknown, R> {
call(callable: Value, thisValue: Value, args: Array<Value>): Effect.Effect<Value, unknown, R> {
return this.root.call(callable, thisValue, args)
}
await(promise: PromiseObj): Effect.Effect<unknown, unknown, never> {
await(promise: PromiseObj): Effect.Effect<Value, unknown, never> {
return this.root.await(promise)
}
iterate(value: unknown) {
iterate(value: Value) {
return this.root.iterate(value)
}
/** Runs one host tool: arguments cross as JSON and the result comes back as program values. */
tool(
run: (args: Array<Json | undefined>) => Effect.Effect<Json | undefined, unknown, R>,
args: Array<unknown>,
): Effect.Effect<unknown, unknown, R> {
args: Array<Value>,
): Effect.Effect<Value, unknown, R> {
const ctx = this
return Effect.gen(function* () {
const json = yield* Effect.forEach(args, (arg) => toBoundary(ctx, arg))
@@ -326,7 +330,7 @@ class Frame<R> {
private depth = 0,
) {}
run(program: Program): Effect.Effect<unknown, unknown, R> {
run(program: Program): Effect.Effect<Value, unknown, R> {
const self = this
// Keep top-level declarations separate so they can shadow builtins.
this.scopes.push()
@@ -334,7 +338,7 @@ class Frame<R> {
self.predeclareLexical(program.body)
self.hoistFunctions(program.body)
self.hoistVars(program.body)
let value: unknown = undefined
let value: Value = undefined
for (const [index, statement] of program.body.entries()) {
if (index === program.body.length - 1 && statement.type === "ExpressionStatement") {
value = yield* self.evaluateExpression(statement.expression)
@@ -359,15 +363,12 @@ class Frame<R> {
}
// Fork at the call site so admission and hooks occur when the call is made.
private createToolCallPromise(
path: ReadonlyArray<string>,
args: Array<unknown>,
): Effect.Effect<PromiseObj, never, R> {
private createToolCallPromise(path: ReadonlyArray<string>, args: Array<Value>): Effect.Effect<PromiseObj, never, R> {
return this.ctx.pending.create(this.ctx.tool((json) => this.ctx.tools.execute(path, json), args))
}
// Fiber exits make settlement idempotent; yielding prevents inline continuation.
await(promise: PromiseObj): Effect.Effect<unknown, unknown, never> {
await(promise: PromiseObj): Effect.Effect<Value, unknown, never> {
const pending = this.ctx.pending
return Effect.suspend(() => {
pending.markObserved(promise)
@@ -458,7 +459,7 @@ class Frame<R> {
}
// NamedEvaluation: an anonymous function definition takes the name of what it is assigned to.
private evaluateNamed(node: Expression, name: string): Effect.Effect<unknown, unknown, R> {
private evaluateNamed(node: Expression, name: string): Effect.Effect<Value, unknown, R> {
if (node.type === "ArrowFunctionExpression" || (node.type === "FunctionExpression" && !node.id)) {
return Effect.sync(() => this.createFunction(node, name))
}
@@ -660,7 +661,7 @@ class Frame<R> {
const iterator = cursor === undefined ? yield* self.customIterator(right, node, awaiting) : undefined
if (iterator === undefined && cursor === undefined) {
throw invalidData(
`${awaiting ? "for await...of" : "for...of"} requires an array, string, Map, Set, URLSearchParams, or Headers, or custom iterator value.`,
`${awaiting ? "for await...of" : "for...of"} requires an iterable value, received ${describeValue(right)}.`,
node,
)
}
@@ -676,7 +677,7 @@ class Frame<R> {
}
const assignment = left.type === "VariableDeclaration" ? undefined : left
const evaluateBody = (value: unknown) =>
const evaluateBody = (value: Value) =>
Effect.gen(function* () {
if (declared?.lexical) {
self.scopes.push()
@@ -725,7 +726,7 @@ class Frame<R> {
)
}
private awaitValue(value: unknown): Effect.Effect<unknown, unknown, R> {
private awaitValue(value: Value): Effect.Effect<Value, unknown, R> {
return Effect.flatMap(resolvePromise(this.ctx, value), (promise) =>
Effect.ensuring(
this.await(promise),
@@ -736,10 +737,10 @@ class Frame<R> {
private awaitAsyncFromSyncValue(
iterator: CustomIterator,
value: unknown,
value: Value,
node: AstNode | undefined,
closeOnRejection: boolean,
): Effect.Effect<unknown, unknown, R> {
): Effect.Effect<Value, unknown, R> {
const self = this
return Effect.gen(function* () {
const settled = yield* Effect.exit(self.awaitValue(value))
@@ -751,7 +752,7 @@ class Frame<R> {
})
}
iterate(value: unknown, node?: AstNode) {
iterate(value: Value, node?: AstNode) {
const cursor = this.hostCursor(value)
if (cursor !== undefined) return Effect.succeed(cursor)
const self = this
@@ -765,40 +766,24 @@ class Frame<R> {
)
}
private hostCursor(value: unknown) {
private hostCursor(value: Value) {
const iterator =
value instanceof Arr
? value.items[Symbol.iterator]()
: typeof value === "string"
? value[Symbol.iterator]()
: value instanceof MapObj
? value.map.entries()
: value instanceof SetObj
? value.set.values()
: value instanceof URLSearchParamsObj
? value.params.entries()
: value instanceof HeadersObj
? value.headers.entries()
: value instanceof Bytes
? value.bytes.values()
: value instanceof IteratorObj
? value.iterator
: undefined
typeof value === "string"
? value[Symbol.iterator]()
: value instanceof Obj
? value.iterator(this.ctx.builtins)
: undefined
if (iterator === undefined) return undefined
const proto = this.ctx.builtins.Array
return {
next: Effect.sync(() => {
const step = iterator.next()
return {
done: Boolean(step.done),
value: Array.isArray(step.value) ? new Arr(proto, step.value) : step.value,
}
return { done: Boolean(step.done), value: step.value }
}),
close: Effect.void,
}
}
private customIterator(value: unknown, node: AstNode | undefined, allowAsync = true) {
private customIterator(value: Value, node: AstNode | undefined, allowAsync = true) {
if (!(value instanceof Obj)) return Effect.undefined
const asyncMethod = allowAsync ? get(value, AsyncIteratorSymbol) : undefined
const method = asyncMethod ?? get(value, IteratorSymbol)
@@ -888,18 +873,18 @@ class Frame<R> {
})
}
private requireIteratorObject(value: unknown, context: string, node?: AstNode): Obj {
private requireIteratorObject(value: Value, context: string, node?: AstNode): Obj {
if (value instanceof Obj) return value
throw typeError(`${context} must be an object.`, node)
}
private requireIteratorMethod(value: unknown, context: string, node?: AstNode): unknown {
private requireIteratorMethod(value: Value, context: string, node?: AstNode): Value {
if (typeofValue(value) === "function") return value
throw typeError(`${context} must be a function.`, node)
}
// for...in over null/undefined iterates nothing, like JS.
private enumerableKeys(value: unknown, node: AstNode): Array<string> {
private enumerableKeys(value: Value, node: AstNode): Array<string> {
if (value instanceof ToolReference) return [...this.ctx.tools.keys(value.path)]
if (value === null || value === undefined) return []
return keys(enumerableSource(this.ctx, "for...in", value, node))
@@ -1063,7 +1048,7 @@ class Frame<R> {
private declarePattern(
pattern: Pattern,
value: unknown,
value: Value,
mutable: boolean,
node: AstNode,
initialize = false,
@@ -1123,7 +1108,7 @@ class Frame<R> {
})
}
private assignPattern(pattern: Pattern, value: unknown, node: AstNode): Effect.Effect<void, unknown, R> {
private assignPattern(pattern: Pattern, value: Value, node: AstNode): Effect.Effect<void, unknown, R> {
const self = this
return Effect.gen(function* () {
if (pattern.type === "Identifier") {
@@ -1175,7 +1160,7 @@ class Frame<R> {
})
}
private evaluateDefault(pattern: AssignmentPattern): Effect.Effect<unknown, unknown, R> {
private evaluateDefault(pattern: AssignmentPattern): Effect.Effect<Value, unknown, R> {
return pattern.left.type === "Identifier"
? this.evaluateNamed(pattern.right, pattern.left.name)
: this.evaluateExpression(pattern.right)
@@ -1183,8 +1168,8 @@ class Frame<R> {
private destructureArrayPattern(
pattern: ArrayPattern,
value: unknown,
consume: (target: Pattern, value: unknown, context: AstNode) => Effect.Effect<void, unknown, R>,
value: Value,
consume: (target: Pattern, value: Value, context: AstNode) => Effect.Effect<void, unknown, R>,
): Effect.Effect<void, unknown, R> {
const self = this
return Effect.gen(function* () {
@@ -1208,7 +1193,7 @@ class Frame<R> {
done = step.done
if (element === null) continue
if (element.type === "RestElement") {
const rest: Array<unknown> = []
const rest: Array<Value> = []
if (!step.done) rest.push(step.value)
while (!done) {
const next = yield* cursor.next
@@ -1238,13 +1223,12 @@ class Frame<R> {
throw unsupportedSyntax(keyNode.type, keyNode)
}
private evaluateExpression(node: Expression): Effect.Effect<unknown, unknown, R> {
private evaluateExpression(node: Expression): Effect.Effect<Value, unknown, R> {
switch (node.type) {
case "Literal": {
const regex = node.regex
if (regex) return Effect.sync(() => constructRegExp(this.ctx.builtins, [regex.pattern, regex.flags]))
if (typeof node.value === "bigint") throw typeError("BigInt literals are not supported.", node)
return Effect.succeed(node.value)
return Effect.succeed(literal(node))
}
case "Identifier":
return Effect.sync(() => this.scopes.get(node.name, node))
@@ -1259,7 +1243,7 @@ class Frame<R> {
case "SequenceExpression": {
const self = this
return Effect.gen(function* () {
let result: unknown
let result: Value
for (const expression of node.expressions) {
result = yield* self.evaluateExpression(expression)
}
@@ -1300,7 +1284,7 @@ class Frame<R> {
}
}
private evaluateNewExpression(node: NewExpression): Effect.Effect<unknown, unknown, R> {
private evaluateNewExpression(node: NewExpression): Effect.Effect<Value, unknown, R> {
const self = this
return Effect.gen(function* () {
const callee = yield* self.evaluateExpression(node.callee)
@@ -1324,7 +1308,7 @@ class Frame<R> {
})
}
private evaluateBinaryExpression(node: BinaryExpression): Effect.Effect<unknown, unknown, R> {
private evaluateBinaryExpression(node: BinaryExpression): Effect.Effect<Value, unknown, R> {
const operator = node.operator
const left = node.left
if (left.type === "PrivateIdentifier") throw unsupportedSyntax(left.type, left)
@@ -1337,7 +1321,7 @@ class Frame<R> {
})
}
private applyBinaryOperator(operator: string, lhs: unknown, rhs: unknown, node: AstNode): unknown {
private applyBinaryOperator(operator: string, lhs: Value, rhs: Value, node: AstNode): Value {
if (operator === "===") return lhs === rhs
if (operator === "!==") return lhs !== rhs
if (operator === "in" && rhs instanceof Obj && !containsOpaqueReference(lhs)) {
@@ -1346,14 +1330,9 @@ class Frame<R> {
if (containsOpaqueReference(lhs) || containsOpaqueReference(rhs)) {
throw invalidData("Binary operators require data values.", node)
}
// Null-prototype data needs explicit primitive coercion; identity and `in` retain raw objects.
// Dates use their default string hint for addition and loose equality, and epoch time elsewhere.
const coerceOperand = (operand: unknown): unknown => {
if (operand instanceof DateObj) {
return operator === "+" || operator === "==" || operator === "!=" ? coerceToString(operand) : operand.time
}
return operand !== null && typeof operand === "object" ? coerceToString(operand) : operand
}
// Addition and loose equality use the default hint; every other operator asks for a number.
const hint = operator === "+" || operator === "==" || operator === "!=" ? "default" : "number"
const coerceOperand = (operand: Value) => (operand instanceof Obj ? operand.toPrimitive(hint) : operand)
const bothObjects = lhs !== null && typeof lhs === "object" && rhs !== null && typeof rhs === "object"
const l = coerceOperand(lhs)
const r = coerceOperand(rhs)
@@ -1407,7 +1386,7 @@ class Frame<R> {
}
}
private evaluateLogicalExpression(node: LogicalExpression): Effect.Effect<unknown, unknown, R> {
private evaluateLogicalExpression(node: LogicalExpression): Effect.Effect<Value, unknown, R> {
const operator = node.operator
return Effect.flatMap(this.evaluateExpression(node.left), (left) => {
if (operator === "&&") return left ? this.evaluateExpression(node.right) : Effect.succeed(left)
@@ -1418,7 +1397,7 @@ class Frame<R> {
})
}
private evaluateUnaryExpression(node: UnaryExpression): Effect.Effect<unknown, unknown, R> {
private evaluateUnaryExpression(node: UnaryExpression): Effect.Effect<Value, unknown, R> {
const operator = node.operator
const argument = node.argument
if (operator === "delete") return this.evaluateDeleteExpression(argument)
@@ -1433,13 +1412,8 @@ class Frame<R> {
if (containsOpaqueReference(value)) {
throw invalidData("Unary operators require data values.", node)
}
const operand =
value instanceof DateObj
? value.time
: value !== null && typeof value === "object"
? coerceToString(value)
: value
let result: unknown
const operand = value instanceof Obj ? value.toPrimitive("number") : value
let result: Value
switch (operator) {
case "+":
result = +(operand as number)
@@ -1457,7 +1431,7 @@ class Frame<R> {
})
}
private evaluateAssignmentExpression(node: AssignmentExpression): Effect.Effect<unknown, unknown, R> {
private evaluateAssignmentExpression(node: AssignmentExpression): Effect.Effect<Value, unknown, R> {
const left = node.left
const operator = node.operator
const self = this
@@ -1497,9 +1471,9 @@ class Frame<R> {
node: AssignmentExpression,
left: Pattern,
operator: string,
): Effect.Effect<unknown, unknown, R> {
): Effect.Effect<Value, unknown, R> {
const self = this
const shouldAssign = (current: unknown): boolean =>
const shouldAssign = (current: Value): boolean =>
operator === "??=" ? current === null || current === undefined : operator === "||=" ? !current : Boolean(current)
if (left.type === "Identifier") {
const name = left.name
@@ -1524,7 +1498,7 @@ class Frame<R> {
throw typeError("Assignment target must be an Identifier or MemberExpression.", left)
}
private evaluateUpdateExpression(node: UpdateExpression): Effect.Effect<unknown, unknown, R> {
private evaluateUpdateExpression(node: UpdateExpression): Effect.Effect<Value, unknown, R> {
const operator = node.operator
const argument = node.argument
const prefix = node.prefix
@@ -1537,7 +1511,7 @@ class Frame<R> {
// CodeMode numeric coercion, not host Number(): null-prototype data objects would make
// the host throw during ToPrimitive, and opaque runtime references must reject clearly.
const operand = (current: unknown): number => {
const operand = (current: Value): number => {
if (containsOpaqueReference(current)) {
throw invalidData(`'${operator}' requires a data value.`, argument)
}
@@ -1566,7 +1540,7 @@ class Frame<R> {
}
// EvaluateCall: a member callee supplies its base object as `this`; anything else calls with undefined.
private evaluateCallExpression(node: CallExpression): Effect.Effect<unknown, unknown, R> {
private evaluateCallExpression(node: CallExpression): Effect.Effect<Value, unknown, R> {
const callee = node.callee
const self = this
@@ -1584,7 +1558,7 @@ class Frame<R> {
})
}
private readMethod(node: MemberExpression): Effect.Effect<{ callable: unknown; thisValue: unknown }, unknown, R> {
private readMethod(node: MemberExpression): Effect.Effect<{ callable: Value; thisValue: Value }, unknown, R> {
return Effect.map(this.getMemberReference(node), (reference) => {
if (reference === OptionalShortCircuit) return { callable: OptionalShortCircuit, thisValue: undefined }
if (reference instanceof ToolReference) return { callable: reference, thisValue: undefined }
@@ -1595,12 +1569,12 @@ class Frame<R> {
// The single dispatch for every invocation: call expressions and callbacks share it.
call(
callable: unknown,
thisValue: unknown,
args: Array<unknown>,
callable: Value,
thisValue: Value,
args: Array<Value>,
node?: AstNode,
callee?: Expression,
): Effect.Effect<unknown, unknown, R> {
): Effect.Effect<Value, unknown, R> {
const self = this
return Effect.gen(function* () {
if (callable instanceof ToolReference) {
@@ -1618,7 +1592,7 @@ class Frame<R> {
}
// Built-ins throw without a location, synchronously or inside their Effect; the call site supplies it.
private native(body: () => Effect.Effect<unknown, unknown, R>, node?: AstNode): Effect.Effect<unknown, unknown, R> {
private native(body: () => Effect.Effect<Value, unknown, R>, node?: AstNode): Effect.Effect<Value, unknown, R> {
return Effect.provideService(
Effect.catchDefect(Effect.suspend(body), (defect) => Effect.die(locate(defect, node))),
CallSite,
@@ -1628,10 +1602,10 @@ class Frame<R> {
private evaluateCallArguments(
argNodes: ReadonlyArray<Expression | SpreadElement>,
): Effect.Effect<Array<unknown>, unknown, R> {
): Effect.Effect<Array<Value>, unknown, R> {
const self = this
return Effect.gen(function* () {
const args: Array<unknown> = []
const args: Array<Value> = []
for (const argNode of argNodes) {
if (argNode.type === "SpreadElement") {
const spread = yield* self.evaluateExpression(argNode.argument)
@@ -1651,7 +1625,7 @@ class Frame<R> {
}
// A callback invoked by a built-in runs below the call that invoked the built-in, so the deeper of the two counts.
invokeFunction(fn: Fn, args: Array<unknown>, node?: AstNode): Effect.Effect<unknown, unknown, R> {
invokeFunction(fn: Fn, args: Array<Value>, node?: AstNode): Effect.Effect<Value, unknown, R> {
const self = this
return Effect.flatMap(CallSite, (site) => {
const depth = Math.max(self.depth, site.depth) + 1
@@ -1698,16 +1672,16 @@ class Frame<R> {
private createGenerator(
invocation: Frame<R>,
run: Effect.Effect<unknown, unknown, R>,
run: Effect.Effect<Value, unknown, R>,
asynchronous: boolean,
): GeneratorObj {
const state: GeneratorState = { started: false, completed: false, draining: false, pending: [], pendingIndex: 0 }
invocation.generatorState = state
invocation.generatorAsync = asynchronous
const builtins = this.ctx.builtins
const result = (value: unknown, done: boolean) => record(builtins.Object, { value, done })
const request = (kind: GeneratorRequestKind, value: unknown) => {
const request = { kind, value, response: Deferred.makeUnsafe<unknown, unknown>() }
const result = (value: Value, done: boolean) => record(builtins.Object, { value, done })
const request = (kind: GeneratorRequestKind, value: Value) => {
const request = { kind, value, response: Deferred.makeUnsafe<Value, unknown>() }
if (!asynchronous && state.active) return Effect.die(typeError("Generator is already running."))
if (asynchronous && (state.completed || (!state.started && kind !== "next"))) {
state.started = true
@@ -1778,7 +1752,7 @@ class Frame<R> {
private completeGeneratorRequests(state: GeneratorState, asynchronous: boolean): Effect.Effect<void, never, R> {
const self = this
const result = (value: unknown, done: boolean) => record(self.ctx.builtins.Object, { value, done })
const result = (value: Value, done: boolean) => record(self.ctx.builtins.Object, { value, done })
return Effect.gen(function* () {
while (true) {
const pending = self.dequeueGeneratorRequest(state)
@@ -1824,7 +1798,7 @@ class Frame<R> {
return request
}
private evaluateYieldExpression(node: YieldExpression): Effect.Effect<unknown, unknown, R> {
private evaluateYieldExpression(node: YieldExpression): Effect.Effect<Value, unknown, R> {
const argument = node.argument
const self = this
return Effect.gen(function* () {
@@ -1839,7 +1813,7 @@ class Frame<R> {
})
}
private suspendGenerator(value: unknown, node: AstNode): Effect.Effect<unknown, unknown, R> {
private suspendGenerator(value: Value, node: AstNode): Effect.Effect<Value, unknown, R> {
const state = this.generatorState
if (!state?.active) throw typeError("Generator has no active request.", node)
Deferred.doneUnsafe(state.active.response, Exit.succeed(record(this.ctx.builtins.Object, { value, done: false })))
@@ -1854,20 +1828,11 @@ class Frame<R> {
})
}
private delegateYield(value: unknown, node: AstNode): Effect.Effect<unknown, unknown, R> {
private delegateYield(value: Value, node: AstNode): Effect.Effect<Value, unknown, R> {
const self = this
return Effect.gen(function* () {
if (
value instanceof Arr ||
typeof value === "string" ||
value instanceof MapObj ||
value instanceof SetObj ||
value instanceof URLSearchParamsObj ||
value instanceof HeadersObj ||
value instanceof Bytes
) {
const cursor = yield* self.iterate(value, node)
if (!cursor) throw typeError("Built-in iterator is unavailable.", node)
const cursor = self.hostCursor(value)
if (cursor !== undefined) {
while (true) {
const step = yield* cursor.next
if (step.done) return undefined
@@ -1891,7 +1856,7 @@ class Frame<R> {
const iterator = yield* self.customIterator(value, node, self.generatorAsync)
if (!iterator) throw typeError("yield* requires a compatible iterable value.", node)
let kind: GeneratorRequestKind = "next"
let input: unknown = undefined
let input: Value = undefined
while (true) {
const method = kind === "next" ? iterator.next : get(iterator.iterator, kind)
if (method === undefined || method === null) {
@@ -1911,7 +1876,7 @@ class Frame<R> {
node,
)
const done = Boolean(get(result, "done"))
const resultValue: unknown =
const resultValue: Value =
self.generatorAsync && !iterator.asynchronous
? yield* self.awaitAsyncFromSyncValue(iterator, get(result, "value"), node, kind !== "return" && !done)
: get(result, "value")
@@ -1920,7 +1885,7 @@ class Frame<R> {
return resultValue
}
const resumed: Exit.Exit<unknown, unknown> = yield* Effect.exit(self.suspendGenerator(resultValue, node))
const resumed: Exit.Exit<Value, unknown> = yield* Effect.exit(self.suspendGenerator(resultValue, node))
if (Exit.isSuccess(resumed)) {
kind = "next"
input = resumed.value
@@ -1961,7 +1926,7 @@ class Frame<R> {
} else if (keyNode.type === "Identifier") {
key = keyNode.name
} else if (keyNode.type === "Literal") {
key = self.toPropertyKey(keyNode.value, keyNode)
key = self.toPropertyKey(literal(keyNode), keyNode)
} else {
throw typeError("Unsupported object property key shape.", keyNode)
}
@@ -1980,7 +1945,7 @@ class Frame<R> {
}
private evaluateArrayExpression(node: ArrayExpression): Effect.Effect<Arr, unknown, R> {
const values: Array<unknown> = []
const values: Array<Value> = []
const self = this
return Effect.gen(function* () {
@@ -2035,13 +2000,13 @@ class Frame<R> {
})
}
private evaluateConditionalExpression(node: ConditionalExpression): Effect.Effect<unknown, unknown, R> {
private evaluateConditionalExpression(node: ConditionalExpression): Effect.Effect<Value, unknown, R> {
return Effect.flatMap(this.evaluateExpression(node.test), (test) =>
this.evaluateExpression(test ? node.consequent : node.alternate),
)
}
private applyCompoundAssignment(operator: string, current: unknown, incoming: unknown, node: AstNode): unknown {
private applyCompoundAssignment(operator: string, current: Value, incoming: Value, node: AstNode): Value {
if (!compoundOperators.has(operator)) {
throw typeError(`Unsupported assignment operator '${operator}'.`, node)
}
@@ -2050,7 +2015,7 @@ class Frame<R> {
private getMemberReference(
node: MemberExpression,
): Effect.Effect<MemberReference | ToolReference | { value: unknown } | typeof OptionalShortCircuit, unknown, R> {
): Effect.Effect<MemberReference | ToolReference | { value: Value } | typeof OptionalShortCircuit, unknown, R> {
const objectNode = node.object
const propertyNode = node.property
if (objectNode.type === "Super") throw unsupportedSyntax(objectNode.type, objectNode)
@@ -2092,7 +2057,7 @@ class Frame<R> {
})
}
private readReference(reference: MemberReference, node: MemberExpression): unknown {
private readReference(reference: MemberReference, node: MemberExpression): Value {
// Reject unknown promise properties so a missing await cannot hide.
if (reference.target instanceof PromiseObj && !has(reference.target, reference.key)) {
throw invalidData(
@@ -2104,7 +2069,7 @@ class Frame<R> {
}
// Accessors throw without a location; the member or pattern that read them supplies it.
private readProperty(target: Obj, key: PropertyKey, node: AstNode, receiver: unknown = target): unknown {
private readProperty(target: Obj, key: PropertyKey, node: AstNode, receiver: Value = target): Value {
try {
return get(target, key, receiver)
} catch (error) {
@@ -2112,7 +2077,7 @@ class Frame<R> {
}
}
private readMember(node: MemberExpression): Effect.Effect<unknown, unknown, R> {
private readMember(node: MemberExpression): Effect.Effect<Value, unknown, R> {
return Effect.map(this.getMemberReference(node), (reference) => {
if (reference === OptionalShortCircuit) return OptionalShortCircuit
if (reference instanceof ToolReference) return reference
@@ -2121,7 +2086,7 @@ class Frame<R> {
})
}
private writeMember(node: MemberExpression, value: unknown): Effect.Effect<unknown, unknown, R> {
private writeMember(node: MemberExpression, value: Value): Effect.Effect<Value, unknown, R> {
return this.modifyMember(node, () => Effect.succeed({ write: true, next: value, result: value }))
}
@@ -2143,8 +2108,8 @@ class Frame<R> {
// Resolve side-effecting object and key expressions exactly once.
private modifyMember(
node: MemberExpression,
compute: (current: unknown) => Effect.Effect<{ write: boolean; next: unknown; result: unknown }, unknown, R>,
): Effect.Effect<unknown, unknown, R> {
compute: (current: Value) => Effect.Effect<{ write: boolean; next: Value; result: Value }, unknown, R>,
): Effect.Effect<Value, unknown, R> {
const self = this
return Effect.gen(function* () {
const reference = yield* self.getMemberReference(node)
@@ -2164,7 +2129,7 @@ class Frame<R> {
})
}
private assignToReference(target: Obj, key: PropertyKey, next: unknown, node: AstNode): void {
private assignToReference(target: Obj, key: PropertyKey, next: Value, node: AstNode): void {
const written = (() => {
try {
rejectCircularInsertion(
@@ -2182,7 +2147,7 @@ class Frame<R> {
throw typeError(`Cannot assign to read only property '${String(key)}'.`, node)
}
private toPropertyKey(value: unknown, node: AstNode): PropertyKey {
private toPropertyKey(value: Value, node: AstNode): PropertyKey {
if (typeof value === "string" || typeof value === "number") {
return value
}
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import { define, hidden, Native, Arr, ErrorObj, Obj } from "./objects.js"
import { define, hidden, Native, Arr, ErrorObj, Obj, type Value } from "./objects.js"
export const errorTypes = [
"Error",
@@ -53,7 +53,7 @@ export const createErrorValue = (prototype: Obj, message: string | undefined): E
}
/** The prototype a primitive reads its methods from without being boxed; none for null, undefined, and symbols. */
export const primitivePrototype = (builtins: Builtins, value: unknown): Obj | undefined => {
export const primitivePrototype = (builtins: Builtins, value: Value): Obj | undefined => {
if (typeof value === "string") return builtins.String
if (typeof value === "number") return builtins.Number
if (typeof value === "boolean") return builtins.Boolean
+5 -8
View File
@@ -2,7 +2,7 @@ import type { Node } from "acorn"
import { Context } from "effect"
import type { ErrorType } from "./intrinsics.js"
import type { DiagnosticKind } from "../codemode.js"
import type { ErrorObj } from "./objects.js"
import type { ErrorObj, Value } from "./objects.js"
/** Any parsed node; the interpreter narrows on `type` and reads `loc` for diagnostics. */
export type AstNode = Node
@@ -14,13 +14,13 @@ export const CallSite = Context.Reference<{ readonly node?: AstNode; readonly de
export type Binding = {
mutable: boolean
value: unknown
value: Value
initialized?: boolean
}
export type StatementResult =
| { kind: "none" }
| { kind: "return"; value: unknown }
| { kind: "return"; value: Value }
| { kind: "break"; label?: string }
| { kind: "continue"; label?: string }
@@ -31,11 +31,11 @@ export const IteratorSymbol: unique symbol = Symbol("codemode.iterator")
export const IteratorSymbols = [AsyncIteratorSymbol, IteratorSymbol] as const
export class Throw {
constructor(readonly value: unknown) {}
constructor(readonly value: Value) {}
}
export class GeneratorReturn {
constructor(readonly value: unknown) {}
constructor(readonly value: Value) {}
}
export const OptionalShortCircuit: unique symbol = Symbol("codemode.optional-short-circuit")
@@ -83,9 +83,6 @@ export const unsupportedSyntax = (kind: string, node: AstNode): PendingThrow =>
[supportedSyntaxMessage],
)
export const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null
// Acorn lines are 1-based and its columns are 0-based. Diagnostics use 1-based columns of the submitted source.
export const sourceLocation = (node: AstNode): { readonly line: number; readonly column: number } => ({
line: node.loc?.start.line ?? 1,
+7 -7
View File
@@ -1,18 +1,18 @@
import { Effect } from "effect"
import type { Builtins } from "./intrinsics.js"
import { typeError } from "./model.js"
import { type Callable, define, frozen, hidden, Native, type NativeOptions, Obj } from "./objects.js"
import { type Callable, define, frozen, hidden, Native, type NativeOptions, Obj, type Value } from "./objects.js"
import { describeValue } from "./references.js"
/** A native function body: a plain value, a thrown `PendingThrow`, or an Effect. */
export type Impl = (thisValue: unknown, args: Array<unknown>) => unknown
/** A native function body: a value, a thrown `PendingThrow`, or an Effect of a value. */
export type Impl = (thisValue: Value, args: Array<Value>) => Value | Effect.Effect<Value, unknown, unknown>
// The dispatch in `Frame.call` suspends every native call, so a synchronous throw here is a defect.
const lift =
<R>(impl: Impl) =>
(thisValue: unknown, args: Array<unknown>): Effect.Effect<unknown, unknown, R> => {
(thisValue: Value, args: Array<Value>): Effect.Effect<Value, unknown, R> => {
const result = impl(thisValue, args)
return Effect.isEffect(result) ? (result as Effect.Effect<unknown, unknown, R>) : Effect.succeed(result)
return Effect.isEffect(result) ? (result as Effect.Effect<Value, unknown, R>) : Effect.succeed(result)
}
export const native = <R>(builtins: Builtins, options: NativeOptions<R>): Native<R> =>
@@ -27,7 +27,7 @@ export const methods = (builtins: Builtins, target: Obj, table: ReadonlyArray<Me
for (const [name, length, impl] of table) define(target, name, fn(builtins, name, length, impl), hidden)
}
export const constants = (target: Obj, table: Record<string, unknown>): void => {
export const constants = (target: Obj, table: Record<string, Value>): void => {
for (const [name, value] of Object.entries(table)) define(target, name, value, frozen)
}
@@ -54,7 +54,7 @@ export const prototypeFrom = (newTarget: Callable, fallback: Obj): Obj => {
/** Narrows a method receiver to the built-in it belongs to, or throws the TypeError JS would. */
export const receiver = <T extends Obj>(
cls: abstract new (...args: never) => T,
thisValue: unknown,
thisValue: Value,
method: string,
): T => {
if (thisValue instanceof cls) return thisValue
+240 -46
View File
@@ -1,5 +1,7 @@
import type { BlockStatement, Expression, Pattern } from "acorn"
import type { Effect, Fiber } from "effect"
import { ToolReference } from "../tool-runtime.js"
import type { Builtins } from "./intrinsics.js"
import { checkArrayLength } from "./limits.js"
import {
AsyncIteratorSymbol,
@@ -16,12 +18,12 @@ export type Attributes = {
readonly configurable: boolean
}
export type Getter = (receiver: unknown) => unknown
export type Setter = (receiver: unknown, value: unknown) => void
export type Getter = (receiver: Value) => Value
export type Setter = (receiver: Value, value: Value) => void
/** One own property: a data slot or a native accessor pair. */
export type Slot =
| { value: unknown; writable: boolean; enumerable: boolean; configurable: boolean }
| { value: Value; writable: boolean; enumerable: boolean; configurable: boolean }
| { get: Getter | undefined; set: Setter | undefined; enumerable: boolean; configurable: boolean }
/** Ordinary assignment: writable, enumerable, configurable. */
@@ -33,33 +35,119 @@ export const readonly: Attributes = { writable: false, enumerable: false, config
/** Constants such as `Math.PI` and a constructor's `prototype`. */
export const frozen: Attributes = { writable: false, enumerable: false, configurable: false }
/** An object owned by the program: own properties plus a prototype link. */
/**
* An object owned by the program: own properties plus a prototype link. Subclasses answer, in one place, how a
* built-in kind of object prints, coerces, iterates, and crosses to the host.
*/
export class Obj {
readonly props = new Map<string | symbol, Slot>()
constructor(public proto: Obj | null) {}
/** The class name `Object.prototype.toString` reports: `[object Map]`. */
readonly tag: string = "Object"
/** How diagnostics refer to a value of this kind. */
get describe(): string {
if (this.tag === "Object") return "a data object"
return `${/^[AEIO]/.test(this.tag) ? "an" : "a"} ${this.tag}`
}
/** ToString without consulting program-defined methods. */
toString(): string {
return `[object ${this.tag}]`
}
/** ToPrimitive without consulting program-defined methods: only a Date answers a number hint differently. */
toPrimitive(hint: "default" | "number" | "string"): string | number {
return this.toString()
}
/** ToNumber without consulting program-defined methods. */
toNumber(): number {
return Number(this.toPrimitive("number"))
}
/** How `console.log` shows the value; `item` formats a child with cycle and depth tracking. */
inspect(item: (value: Value) => string): string {
return `{${entries(this)
.map(([key, value]) => `${JSON.stringify(key)}:${item(value)}`)
.join(",")}}`
}
/** A copy the host can hold; `item` converts a child. A `__proto__` key never reaches host code. */
toHost(item: (value: Value) => unknown): unknown {
return Object.fromEntries(
entries(this)
.filter(([key]) => key !== "__proto__")
.map(([key, value]) => [key, item(value)]),
)
}
/** The built-in iteration `for...of` and spread use, when this kind of object has one. */
iterator(builtins: Builtins): Iterator<Value, undefined> | undefined {
return undefined
}
}
export class Arr extends Obj {
override readonly tag = "Array"
constructor(
proto: Obj,
readonly items: Array<unknown> = [],
readonly items: Array<Value> = [],
) {
super(proto)
}
override get describe() {
return "an array"
}
override toString() {
return this.items.map((item) => (item === null || item === undefined ? "" : coerceToString(item))).join(",")
}
override inspect(item: (value: Value) => string) {
return `[${this.items.map(item).join(",")}]`
}
override toHost(item: (value: Value) => unknown) {
return this.items.map(item)
}
override iterator() {
return this.items.values()
}
}
/** An object with the [[ErrorData]] slot: what `Error.prototype.toString` and the host boundary recognize as an error. */
export class ErrorObj extends Obj {
override readonly tag = "Error"
/** The interpreter failure this error materialized from, so rethrowing it keeps the diagnostic kind and location. */
host?: PendingThrow
/** Error.prototype.toString: "name: message", or just one when the other is empty. */
override toString() {
const name = get(this, "name")
const message = get(this, "message")
const shownName = typeof name === "string" ? name : "Error"
const shownMessage = typeof message === "string" ? message : ""
if (shownMessage === "") return shownName
if (shownName === "") return shownMessage
return `${shownName}: ${shownMessage}`
}
}
export abstract class Callable extends Obj {
/** Interpreter machinery a program can hold but never inspect, serialize, or hand to the host. */
export abstract class Opaque extends Obj {
override inspect() {
return "[opaque reference]"
}
}
export abstract class Callable extends Opaque {
override readonly tag = "Function"
constructor(proto: Obj, name: string, length: number) {
super(proto)
define(this, "length", length, readonly)
define(this, "name", name, readonly)
}
override get describe() {
return "a function"
}
}
export class Fn extends Callable {
@@ -77,8 +165,8 @@ export class Fn extends Callable {
}
}
export type NativeCall<R> = (thisValue: unknown, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
export type NativeConstruct<R> = (args: Array<unknown>, newTarget: Callable) => Effect.Effect<unknown, unknown, R>
export type NativeCall<R> = (thisValue: Value, args: Array<Value>) => Effect.Effect<Value, unknown, R>
export type NativeConstruct<R> = (args: Array<Value>, newTarget: Callable) => Effect.Effect<Value, unknown, R>
export type NativeOptions<R> = {
readonly name: string
@@ -103,79 +191,163 @@ export class Native<R = never> extends Callable {
}
}
export class PromiseObj extends Obj {
export class PromiseObj extends Opaque {
override readonly tag = "Promise"
constructor(
proto: Obj,
readonly fiber: Fiber.Fiber<unknown, unknown>,
readonly fiber: Fiber.Fiber<Value, unknown>,
) {
super(proto)
}
override get describe() {
return "an un-awaited Promise"
}
override inspect() {
return "[Promise (await it to get its value)]"
}
}
export class GeneratorObj extends Obj {
export class GeneratorObj extends Opaque {
override readonly tag = "Generator"
constructor(
proto: Obj,
readonly asynchronous: boolean,
readonly request: (kind: GeneratorRequestKind, value: unknown) => Effect.Effect<unknown, unknown, unknown>,
readonly request: (kind: GeneratorRequestKind, value: Value) => Effect.Effect<Value, unknown, unknown>,
) {
super(proto)
}
override get describe() {
return "a generator"
}
}
/** A built-in collection iterator: live over the host collection, yielding program values. */
export class IteratorObj extends Obj {
export class IteratorObj extends Opaque {
override readonly tag = "Iterator"
constructor(
proto: Obj,
readonly iterator: IteratorObject<unknown>,
readonly source: IteratorObject<Value, undefined>,
) {
super(proto)
}
override get describe() {
return "an iterator"
}
override iterator() {
return this.source
}
}
export class DateObj extends Obj {
/** A built-in object around a host value: data-like, so it prints as itself and crosses to extensions as a copy. */
export abstract class Wrapper extends Obj {
override inspect(item: (value: Value) => string) {
return this.toString()
}
}
export class DateObj extends Wrapper {
override readonly tag = "Date"
constructor(
proto: Obj,
public time: number,
) {
super(proto)
}
override toString() {
return Number.isFinite(this.time) ? new Date(this.time).toISOString() : "Invalid Date"
}
override toPrimitive(hint: "default" | "number" | "string") {
return hint === "number" ? this.time : this.toString()
}
override toHost() {
return new Date(this.time)
}
}
export class RegExpObj extends Obj {
export class RegExpObj extends Wrapper {
override readonly tag = "RegExp"
readonly regex: RegExp
constructor(proto: Obj, pattern: string, flags: string) {
super(proto)
this.regex = new RegExp(pattern, flags)
}
override toString() {
return `/${this.regex.source}/${this.regex.flags}`
}
override toHost() {
return new RegExp(this.regex.source, this.regex.flags)
}
}
export class MapObj extends Obj {
readonly map = new Map<unknown, unknown>()
export class MapObj extends Wrapper {
override readonly tag = "Map"
readonly map = new Map<Value, Value>()
override inspect(item: (value: Value) => string) {
return `Map(${this.map.size}) [${[...this.map].map(([key, value]) => `[${item(key)},${item(value)}]`).join(",")}]`
}
override toHost(item: (value: Value) => unknown) {
return new Map([...this.map].map(([key, value]) => [item(key), item(value)]))
}
override iterator(builtins: Builtins) {
return this.map.entries().map((entry) => new Arr(builtins.Array, entry))
}
}
export class SetObj extends Obj {
readonly set = new Set<unknown>()
export class SetObj extends Wrapper {
override readonly tag = "Set"
readonly set = new Set<Value>()
override inspect(item: (value: Value) => string) {
return `Set(${this.set.size}) [${[...this.set].map(item).join(",")}]`
}
override toHost(item: (value: Value) => unknown) {
return new Set([...this.set].map(item))
}
override iterator() {
return this.set.values()
}
}
export class URLSearchParamsObj extends Obj {
export class URLSearchParamsObj extends Wrapper {
override readonly tag = "URLSearchParams"
constructor(
proto: Obj,
readonly params: URLSearchParams,
) {
super(proto)
}
override toString() {
return this.params.toString()
}
override toHost() {
return new URLSearchParams(this.params)
}
override iterator(builtins: Builtins) {
return this.params.entries().map((entry) => new Arr(builtins.Array, entry))
}
}
export class HeadersObj extends Obj {
export class HeadersObj extends Wrapper {
override readonly tag = "Headers"
constructor(
proto: Obj,
readonly headers: Headers,
) {
super(proto)
}
override inspect() {
return `Headers ${JSON.stringify(Object.fromEntries(this.headers))}`
}
override toHost() {
return new Headers(this.headers)
}
override iterator(builtins: Builtins) {
// Bun's Headers typings lack the iterator helpers, so the host iterator is lifted first.
return Iterator.from(this.headers.entries()).map((entry) => new Arr(builtins.Array, entry))
}
}
export class URLObj extends Obj {
export class URLObj extends Wrapper {
override readonly tag = "URL"
readonly searchParams: URLSearchParamsObj
constructor(
proto: Obj,
@@ -185,30 +357,52 @@ export class URLObj extends Obj {
super(proto)
this.searchParams = new URLSearchParamsObj(searchParamsProto, url.searchParams)
}
override toString() {
return this.url.href
}
override toHost() {
return new URL(this.url.href)
}
}
/** A `Uint8Array`: the host array does the byte clamping and ignores out-of-range writes, as JS does. */
export class Bytes extends Obj {
export class Bytes extends Wrapper {
override readonly tag = "Uint8Array"
constructor(
proto: Obj,
readonly bytes: Uint8Array,
) {
super(proto)
}
override toString() {
return this.bytes.join(",")
}
override inspect() {
return `Uint8Array(${this.bytes.length}) [${this.bytes.join(",")}]`
}
override toHost() {
return new Uint8Array(this.bytes)
}
override iterator() {
return this.bytes.values()
}
}
/** Built-in objects that wrap a host value; data-like, but never plain data. */
export const isWrapper = (
value: unknown,
): value is DateObj | RegExpObj | MapObj | SetObj | URLObj | URLSearchParamsObj | HeadersObj | Bytes =>
value instanceof DateObj ||
value instanceof RegExpObj ||
value instanceof MapObj ||
value instanceof SetObj ||
value instanceof URLObj ||
value instanceof URLSearchParamsObj ||
value instanceof HeadersObj ||
value instanceof Bytes
/** Every value a program can hold. Host values never appear here; they are copied in at the boundaries. */
export type Value = string | number | boolean | null | undefined | symbol | Obj | ToolReference
/** ToString without consulting program-defined methods. */
export const coerceToString = (value: Value): string => (value instanceof Obj ? value.toString() : String(value))
/** ToNumber without consulting program-defined methods; tool references are not numbers. */
export const coerceToNumber = (value: Value): number => {
if (value instanceof Obj) return value.toNumber()
return value instanceof ToolReference ? Number.NaN : Number(value)
}
/** Values that cannot cross the data boundary: opaque machinery and host-backed wrappers. */
export const isRuntimeReference = (value: Value): boolean =>
value instanceof Opaque || value instanceof Wrapper || value instanceof ToolReference
const MAX_ARRAY_INDEX = 4_294_967_295
@@ -226,7 +420,7 @@ type Indexed = Arr | Bytes
const isIndexed = (target: Obj): target is Indexed => target instanceof Arr || target instanceof Bytes
const elements = (target: Indexed): Array<unknown> | Uint8Array => (target instanceof Arr ? target.items : target.bytes)
const elements = (target: Indexed): Array<Value> | Uint8Array => (target instanceof Arr ? target.items : target.bytes)
const index = (target: Obj, key: string | symbol): number | undefined =>
isIndexed(target) && typeof key === "string" ? parseArrayIndex(key) : undefined
@@ -247,18 +441,18 @@ export const own = (target: Obj, key: PropertyKey): Slot | undefined => {
return target.props.get(name)
}
const read = (slot: Slot, receiver: unknown): unknown =>
const read = (slot: Slot, receiver: Value): Value =>
"value" in slot ? slot.value : slot.get === undefined ? undefined : slot.get(receiver)
export const hasOwn = (target: Obj, key: PropertyKey): boolean => own(target, key) !== undefined
export const getOwn = (target: Obj, key: PropertyKey): unknown => {
export const getOwn = (target: Obj, key: PropertyKey): Value => {
const slot = own(target, key)
return slot === undefined ? undefined : read(slot, target)
}
/** [[Get]]: walks the prototype chain; accessors see `receiver`, which is the primitive for wrapper prototypes. */
export const get = (target: Obj, key: PropertyKey, receiver: unknown = target): unknown => {
export const get = (target: Obj, key: PropertyKey, receiver: Value = target): Value => {
for (let current: Obj | null = target; current !== null; current = current.proto) {
const slot = own(current, key)
if (slot !== undefined) return read(slot, receiver)
@@ -273,14 +467,14 @@ export const has = (target: Obj, key: PropertyKey): boolean => {
return false
}
export const hasPrototype = (value: unknown, proto: Obj): boolean => {
export const hasPrototype = (value: Value, proto: Obj): boolean => {
for (let current = value instanceof Obj ? value.proto : null; current !== null; current = current.proto) {
if (current === proto) return true
}
return false
}
const writeElement = (target: Indexed, name: string | symbol, value: unknown): boolean | undefined => {
const writeElement = (target: Indexed, name: string | symbol, value: Value): boolean | undefined => {
const at = index(target, name)
if (at !== undefined) {
if (target instanceof Bytes) target.bytes[at] = typeof value === "number" ? value : Number(value)
@@ -296,7 +490,7 @@ const writeElement = (target: Indexed, name: string | symbol, value: unknown): b
}
/** [[Set]]: an inherited setter or read-only property decides before an own data property is created. */
export const set = (target: Obj, key: PropertyKey, value: unknown): boolean => {
export const set = (target: Obj, key: PropertyKey, value: Value): boolean => {
const name = canonical(key)
for (let current: Obj | null = target; current !== null; current = current.proto) {
const slot = own(current, name)
@@ -324,7 +518,7 @@ export const set = (target: Obj, key: PropertyKey, value: unknown): boolean => {
}
/** [[DefineOwnProperty]] for a data property, ignoring the chain. */
export const define = (target: Obj, key: PropertyKey, value: unknown, attrs: Attributes = data): void => {
export const define = (target: Obj, key: PropertyKey, value: Value, attrs: Attributes = data): void => {
const name = canonical(key)
if (isIndexed(target) && writeElement(target, name, value) !== undefined) return
target.props.set(name, { value, ...attrs })
@@ -375,9 +569,9 @@ export const keys = (target: Obj): Array<string> =>
ownKeys(target).filter((key): key is string => typeof key === "string" && enumerable(target, key))
/** Own enumerable string entries: `Object.entries` and serialization. */
export const entries = (target: Obj): Array<[string, unknown]> => keys(target).map((key) => [key, getOwn(target, key)])
export const entries = (target: Obj): Array<[string, Value]> => keys(target).map((key) => [key, getOwn(target, key)])
export const record = (proto: Obj, fields: Record<string, unknown>): Obj => {
export const record = (proto: Obj, fields: Record<string, Value>): Obj => {
const target = new Obj(proto)
for (const [key, value] of Object.entries(fields)) define(target, key, value)
return target
+19 -19
View File
@@ -2,7 +2,7 @@ import { Cause, Deferred, Effect, Exit, Fiber, Scope } from "effect"
import type { Diagnostic } from "../codemode.js"
import { MAX_PENDING_PROMISES } from "./limits.js"
import { CallSite, Throw, rangeError, typeError } from "./model.js"
import { Callable, define, get, hidden, Arr, Fn, Obj, PromiseObj, record } from "./objects.js"
import { Callable, define, get, hidden, Arr, Fn, Obj, PromiseObj, record, type Value } from "./objects.js"
import { constructor, fn, methods, native, receiver, requiresNew } from "./native.js"
import { createAggregateErrorValue, locate, materialize, normalizeError } from "./errors.js"
import { typeofValue } from "./references.js"
@@ -10,7 +10,7 @@ import { applyCollectionCallback, isSupportedCallback } from "./callback.js"
import type { Interpreter } from "./interpreter.js"
// A `resolve`/`reject` handed to an executor or thenable: calling it settles the capability.
const capability = <R>(ctx: Interpreter<R>, name: string, settle: (value: unknown) => void) =>
const capability = <R>(ctx: Interpreter<R>, name: string, settle: (value: Value) => void) =>
fn(ctx.builtins, name, 1, (_, args) => {
settle(args[0])
return undefined
@@ -31,7 +31,7 @@ export class Pending<R> {
// Resolution bodies need the promise's own identity to reject `resolve(promise)` self-resolution.
createWithSelf(
body: (self: { promise?: PromiseObj }) => Effect.Effect<unknown, unknown, R>,
body: (self: { promise?: PromiseObj }) => Effect.Effect<Value, unknown, R>,
): Effect.Effect<PromiseObj, never, R> {
const self: { promise?: PromiseObj } = {}
return Effect.map(this.create(body(self)), (promise) => {
@@ -40,7 +40,7 @@ export class Pending<R> {
})
}
create(effect: Effect.Effect<unknown, unknown, R>): Effect.Effect<PromiseObj, never, R> {
create(effect: Effect.Effect<Value, unknown, R>): Effect.Effect<PromiseObj, never, R> {
return Effect.flatMap(CallSite, (site) => {
if (this.active.size >= MAX_PENDING_PROMISES) {
throw rangeError(
@@ -79,7 +79,7 @@ export class Pending<R> {
if (id !== undefined) this.failures.delete(id)
}
await(promise: PromiseObj): Effect.Effect<Exit.Exit<unknown, unknown>> {
await(promise: PromiseObj): Effect.Effect<Exit.Exit<Value, unknown>> {
return Fiber.await(promise.fiber)
}
@@ -105,9 +105,9 @@ export class Pending<R> {
export const resolvePromiseValue = <R>(
ctx: Interpreter<R>,
value: unknown,
value: Value,
own?: { promise?: PromiseObj },
): Effect.Effect<unknown, unknown, R> => {
): Effect.Effect<Value, unknown, R> => {
if (own?.promise !== undefined && value === own.promise) {
return Effect.die(typeError("Chaining cycle detected: a promise cannot resolve with itself."))
}
@@ -119,7 +119,7 @@ export const resolvePromiseValue = <R>(
return Effect.gen(function* () {
// Promise resolution invokes a thenable's method in a later job.
yield* Effect.yieldNow
const deferred = Deferred.makeUnsafe<unknown, unknown>()
const deferred = Deferred.makeUnsafe<Value, unknown>()
const resolve = capability(ctx, "resolve", (result) => Deferred.doneUnsafe(deferred, Exit.succeed(result)))
const reject = capability(ctx, "reject", (reason) => Deferred.doneUnsafe(deferred, Exit.fail(new Throw(reason))))
const executed = yield* Effect.exit(ctx.call(then, value, [resolve, reject]))
@@ -131,7 +131,7 @@ export const resolvePromiseValue = <R>(
})
}
export const resolvePromise = <R>(ctx: Interpreter<R>, value: unknown): Effect.Effect<PromiseObj, never, R> => {
export const resolvePromise = <R>(ctx: Interpreter<R>, value: Value): Effect.Effect<PromiseObj, never, R> => {
if (value instanceof PromiseObj) return Effect.succeed(value)
return ctx.pending.createWithSelf((self) => resolvePromiseValue(ctx, value, self))
}
@@ -141,8 +141,8 @@ const promiseStatics = ["all", "allSettled", "race", "any", "resolve", "reject"]
const invokePromiseMethod = <R>(
ctx: Interpreter<R>,
name: (typeof promiseStatics)[number],
args: Array<unknown>,
): Effect.Effect<unknown, unknown, R> => {
args: Array<Value>,
): Effect.Effect<Value, unknown, R> => {
if (name === "resolve") {
return resolvePromise(ctx, args[0])
}
@@ -175,7 +175,7 @@ const invokePromiseMethod = <R>(
)
}
if (name === "allSettled") {
const outcomes: Array<unknown> = []
const outcomes: Array<Value> = []
for (const item of items) {
const exit = yield* ctx.pending.await(item)
if (Exit.isSuccess(exit)) {
@@ -223,8 +223,8 @@ const invokePromiseMethod = <R>(
const instanceMethod = <R>(
ctx: Interpreter<R>,
name: "then" | "catch" | "finally",
thisValue: unknown,
args: Array<unknown>,
thisValue: Value,
args: Array<Value>,
): Effect.Effect<PromiseObj, unknown, R> => {
const method = `Promise.prototype.${name}`
const promise = receiver(PromiseObj, thisValue, method)
@@ -237,12 +237,12 @@ const instanceMethod = <R>(
return chainReaction(ctx, promise, onFulfilled, onRejected, method)
}
const constructPromise = <R>(ctx: Interpreter<R>, executor: unknown): Effect.Effect<PromiseObj, unknown, R> => {
const constructPromise = <R>(ctx: Interpreter<R>, executor: Value): Effect.Effect<PromiseObj, unknown, R> => {
if (!(executor instanceof Fn)) {
throw typeError("new Promise(...) expects an executor function (e.g. new Promise((resolve, reject) => { ... })).")
}
return Effect.gen(function* () {
const deferred = Deferred.makeUnsafe<unknown, unknown>()
const deferred = Deferred.makeUnsafe<Value, unknown>()
const promise = yield* ctx.pending.createWithSelf((self) =>
Effect.flatMap(Deferred.await(deferred), (value) => resolvePromiseValue(ctx, value, self)),
)
@@ -262,10 +262,10 @@ const settleAfterTurn = <A, E, R>(body: Effect.Effect<A, E, R>): Effect.Effect<A
Effect.flatMap(Effect.exit(body), (exit) => Effect.andThen(Effect.yieldNow, exit))
class PromiseAnyFulfilled {
constructor(readonly value: unknown) {}
constructor(readonly value: Value) {}
}
const reactionHandler = (value: unknown, method: string): Callable | undefined => {
const reactionHandler = (value: Value, method: string): Callable | undefined => {
if (isSupportedCallback(value)) return value
if (typeofValue(value) === "function") {
throw typeError(
@@ -279,7 +279,7 @@ const reactionHandler = (value: unknown, method: string): Callable | undefined =
const reactionExit = <R>(
ctx: Interpreter<R>,
source: PromiseObj,
): Effect.Effect<Exit.Exit<unknown, unknown>, unknown, R> =>
): Effect.Effect<Exit.Exit<Value, unknown>, unknown, R> =>
Effect.gen(function* () {
const exit = yield* ctx.pending.await(source)
if (!Exit.isSuccess(exit) && Cause.hasInterruptsOnly(exit.cause)) return yield* Effect.failCause(exit.cause)
+21 -60
View File
@@ -1,47 +1,18 @@
import { ToolReference } from "../tool-runtime.js"
import { invalidData } from "./model.js"
import {
Callable,
getOwn,
isWrapper,
ownKeys,
Arr,
Bytes,
DateObj,
GeneratorObj,
IteratorObj,
MapObj,
Obj,
PromiseObj,
RegExpObj,
SetObj,
URLObj,
URLSearchParamsObj,
HeadersObj,
} from "./objects.js"
import { Callable, getOwn, isRuntimeReference, Obj, Opaque, ownKeys, type Value } from "./objects.js"
/** Values that cannot cross the data boundary. */
export const isRuntimeReference = (value: unknown): boolean =>
value instanceof Callable ||
value instanceof GeneratorObj ||
value instanceof IteratorObj ||
value instanceof ToolReference ||
value instanceof PromiseObj ||
isWrapper(value)
function* childValues(value: object): Generator {
if (!(value instanceof Obj)) return
for (const key of ownKeys(value)) yield getOwn(value, key)
}
/** Interpreter machinery that is never data, unlike a Date or Map, which cross some boundaries as copies. */
export const isOpaque = (value: Value): boolean => value instanceof Opaque || value instanceof ToolReference
// Depth-first search over a value tree. `match` stops the walk; `skip` prunes a subtree without matching it.
const find = (
value: unknown,
match: (current: unknown) => boolean,
skip: (current: unknown) => boolean,
value: Value,
match: (current: Value) => boolean,
skip: (current: Value) => boolean,
seen: Set<object>,
): boolean => {
const pending: Array<Iterator<unknown>> = [[value].values()]
const pending: Array<Iterator<Value>> = [[value].values()]
while (pending.length > 0) {
const next = pending.at(-1)!.next()
if (next.done) {
@@ -50,25 +21,28 @@ const find = (
}
const current = next.value
if (match(current)) return true
if (current === null || typeof current !== "object" || skip(current) || seen.has(current)) continue
if (!(current instanceof Obj) || skip(current) || seen.has(current)) continue
seen.add(current)
pending.push(childValues(current))
pending.push(
ownKeys(current)
.map((key) => getOwn(current, key))
.values(),
)
}
return false
}
const never = () => false
export const containsRuntimeReference = (value: unknown): boolean => find(value, isRuntimeReference, never, new Set())
export const containsRuntimeReference = (value: Value): boolean => find(value, isRuntimeReference, never, new Set())
// Wrapper values are data here, not opaque interpreter references.
export const containsOpaqueReference = (value: unknown): boolean =>
find(value, (current) => !isWrapper(current) && isRuntimeReference(current), isWrapper, new Set())
export const containsOpaqueReference = (value: Value): boolean =>
find(value, isOpaque, (current) => isRuntimeReference(current) && !isOpaque(current), new Set())
// Reject cycles before mutation so later boundary walks remain safe.
export const rejectCircularInsertion = (
container: object,
value: unknown,
container: Obj,
value: Value,
label: string,
seen = new Set<object>(),
): void => {
@@ -77,27 +51,14 @@ export const rejectCircularInsertion = (
}
}
export const describeValue = (value: unknown): string => {
export const describeValue = (value: Value): string => {
if (value === null || value === undefined) return String(value)
if (value instanceof Arr) return "an array"
if (value instanceof PromiseObj) return "an un-awaited Promise"
if (value instanceof Obj) return value.describe
if (value instanceof ToolReference) return "a tool reference"
if (value instanceof DateObj) return "a Date"
if (value instanceof RegExpObj) return "a RegExp"
if (value instanceof MapObj) return "a Map"
if (value instanceof SetObj) return "a Set"
if (value instanceof URLObj) return "a URL"
if (value instanceof URLSearchParamsObj) return "a URLSearchParams"
if (value instanceof HeadersObj) return "a Headers"
if (value instanceof Bytes) return "a Uint8Array"
if (value instanceof GeneratorObj) return "a generator"
if (value instanceof IteratorObj) return "an iterator"
if (isRuntimeReference(value)) return "a function"
if (typeof value === "object") return "a data object"
return `a ${typeof value}`
}
export const typeofValue = (value: unknown): string => {
export const typeofValue = (value: Value): string => {
if (value instanceof Callable) return "function"
if (value instanceof ToolReference) return value.path.length > 0 ? "function" : "object"
return typeof value
+5 -4
View File
@@ -1,4 +1,5 @@
import { type AstNode, type Binding, referenceError, typeError } from "./model.js"
import type { Value } from "./objects.js"
export class ScopeStack {
private readonly scopes: Array<Map<string, Binding>>
@@ -15,7 +16,7 @@ export class ScopeStack {
scope.set(name, { mutable, value: undefined, initialized: false })
}
initialize(name: string, value: unknown, node: AstNode): void {
initialize(name: string, value: Value, node: AstNode): void {
const binding = this.current().get(name)
if (!binding || binding.initialized !== false) {
throw typeError(`Identifier '${name}' has not been reserved for initialization.`, node)
@@ -24,7 +25,7 @@ export class ScopeStack {
binding.initialized = true
}
declare(name: string, value: unknown, mutable: boolean, node: AstNode): void {
declare(name: string, value: Value, mutable: boolean, node: AstNode): void {
const scope = this.current()
if (scope.has(name)) {
throw typeError(`Identifier '${name}' has already been declared.`, node)
@@ -32,7 +33,7 @@ export class ScopeStack {
scope.set(name, { mutable, value, initialized: true })
}
get(name: string, node: AstNode): unknown {
get(name: string, node: AstNode): Value {
const binding = this.resolve(name)
if (!binding) {
@@ -46,7 +47,7 @@ export class ScopeStack {
return binding.value
}
set(name: string, value: unknown, node: AstNode): unknown {
set(name: string, value: Value, node: AstNode): Value {
const binding = this.resolve(name)
if (!binding) {
+33 -23
View File
@@ -2,14 +2,24 @@ import { Effect } from "effect"
import { constructor, type Method, methods, prototypeFrom, receiver } from "../interpreter/native.js"
import { checkArrayLength, checkStringLength, MAX_ARRAY_LENGTH } from "../interpreter/limits.js"
import { invalidData, IteratorSymbol, rangeError, typeError } from "../interpreter/model.js"
import { define, get, hidden, Arr, GeneratorObj, IteratorObj, Obj } from "../interpreter/objects.js"
import {
define,
get,
hidden,
Arr,
GeneratorObj,
IteratorObj,
Obj,
coerceToNumber,
coerceToString,
type Value,
} from "../interpreter/objects.js"
import { describeValue, rejectCircularInsertion } from "../interpreter/references.js"
import { applyCollectionCallback, invoke, preserveConsumerError } from "../interpreter/callback.js"
import type { Interpreter } from "../interpreter/interpreter.js"
import { compareText } from "../tool-runtime.js"
import { coerceToNumber, coerceToString } from "./value.js"
const arrayLikeSource = (source: unknown): { readonly length: number; readonly source: Obj } => {
const arrayLikeSource = (source: Value): { readonly length: number; readonly source: Obj } => {
if (source instanceof Obj && typeof get(source, "length") === "number") {
const length = get(source, "length") as number
const normalized = Number.isNaN(length) || length <= 0 ? 0 : Math.trunc(length)
@@ -21,7 +31,7 @@ const arrayLikeSource = (source: unknown): { readonly length: number; readonly s
)
}
const arrayFrom = <R>(ctx: Interpreter<R>, args: Array<unknown>): Effect.Effect<unknown, unknown, R> => {
const arrayFrom = <R>(ctx: Interpreter<R>, args: Array<Value>): Effect.Effect<Value, unknown, R> => {
const source = args[0]
const proto = ctx.builtins.Array
const apply =
@@ -33,14 +43,14 @@ const arrayFrom = <R>(ctx: Interpreter<R>, args: Array<unknown>): Effect.Effect<
throw typeError("Array.from expects a synchronous iterable or array-like value.")
}
const arrayLike = arrayLikeSource(source)
const values: Array<unknown> = []
const values: Array<Value> = []
for (let index = 0; index < arrayLike.length; index += 1) {
const item = get(arrayLike.source, index)
values.push(apply === undefined ? item : yield* apply([item, index]))
}
return new Arr(proto, values)
}
const values: Array<unknown> = []
const values: Array<Value> = []
let index = 0
while (true) {
const step = yield* cursor.next
@@ -53,21 +63,21 @@ const arrayFrom = <R>(ctx: Interpreter<R>, args: Array<unknown>): Effect.Effect<
export const sortArray = <R>(
ctx: Interpreter<R>,
target: Array<unknown>,
comparator: unknown,
target: Array<Value>,
comparator: Value,
name: string,
): Effect.Effect<Array<unknown>, unknown, R> => {
): Effect.Effect<Array<Value>, unknown, R> => {
if (comparator === undefined) {
return Effect.sync(() => [...target].sort((a, b) => compareText(coerceToString(a), coerceToString(b))))
}
const apply = applyCollectionCallback(ctx, comparator, name)
const mergeSort = (items: Array<unknown>): Effect.Effect<Array<unknown>, unknown, R> => {
const mergeSort = (items: Array<Value>): Effect.Effect<Array<Value>, 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<unknown> = []
const merged: Array<Value> = []
let leftIndex = 0
let rightIndex = 0
while (leftIndex < left.length && rightIndex < right.length) {
@@ -88,8 +98,8 @@ export const sortArray = <R>(
export const arrayGlobal = <R>(ctx: Interpreter<R>) => {
const builtins = ctx.builtins
const proto = builtins.Array
const wrap = (items: Array<unknown>) => new Arr(proto, items)
const construct = (args: Array<unknown>, into: Obj): Arr => {
const wrap = (items: Array<Value>) => new Arr(proto, items)
const construct = (args: Array<Value>, into: Obj): Arr => {
if (args.length !== 1) return new Arr(into, [...args])
const first = args[0]
if (typeof first !== "number") return new Arr(into, [first])
@@ -109,8 +119,8 @@ export const arrayGlobal = <R>(ctx: Interpreter<R>) => {
["from", 1, (_, args) => arrayFrom(ctx, args)],
])
const self = (thisValue: unknown, name: string) => receiver(Arr, thisValue, `Array.prototype.${name}`)
const optNumber = (name: string, value: unknown, label: string): number | undefined => {
const self = (thisValue: Value, name: string) => receiver(Arr, thisValue, `Array.prototype.${name}`)
const optNumber = (name: string, value: Value, label: string): number | undefined => {
if (value === undefined) return undefined
if (typeof value !== "number") {
throw typeError(`Array.${name} expects ${label} to be a number.`)
@@ -122,11 +132,11 @@ export const arrayGlobal = <R>(ctx: Interpreter<R>) => {
name: string,
length: number,
body: (
target: Array<unknown>,
target: Array<Value>,
receiver: Arr,
apply: (args: Array<unknown>) => Effect.Effect<unknown, unknown, R>,
args: Array<unknown>,
) => Effect.Effect<unknown, unknown, R>,
apply: (args: Array<Value>) => Effect.Effect<Value, unknown, R>,
args: Array<Value>,
) => Effect.Effect<Value, unknown, R>,
): Method => [
name,
length,
@@ -214,7 +224,7 @@ export const arrayGlobal = <R>(ctx: Interpreter<R>) => {
"flat",
0,
(thisValue, args) => {
const flatten = (items: Array<unknown>, depth: number): Array<unknown> =>
const flatten = (items: Array<Value>, depth: number): Array<Value> =>
items.flatMap((item) => (item instanceof Arr && depth > 0 ? flatten(item.items, depth - 1) : [item]))
const flattened = flatten(self(thisValue, "flat").items, optNumber("flat", args[0], "depth") ?? 1)
checkArrayLength(flattened.length)
@@ -369,7 +379,7 @@ export const arrayGlobal = <R>(ctx: Interpreter<R>) => {
iterate("map", 1, (target, receiver, apply) =>
Effect.gen(function* () {
const length = target.length
const values: Array<unknown> = []
const values: Array<Value> = []
values.length = length
for (let index = 0; index < length; index += 1) {
if (!(index in target)) continue
@@ -381,7 +391,7 @@ export const arrayGlobal = <R>(ctx: Interpreter<R>) => {
iterate("flatMap", 1, (target, receiver, apply) =>
Effect.gen(function* () {
const length = target.length
const values: Array<unknown> = []
const values: Array<Value> = []
for (let index = 0; index < length; index += 1) {
if (!(index in target)) continue
const mapped = yield* apply([target[index], index, receiver])
@@ -394,7 +404,7 @@ export const arrayGlobal = <R>(ctx: Interpreter<R>) => {
iterate("filter", 1, (target, receiver, apply) =>
Effect.gen(function* () {
const length = target.length
const values: Array<unknown> = []
const values: Array<Value> = []
for (let index = 0; index < length; index += 1) {
if (!(index in target)) continue
const item = target[index]
+20 -10
View File
@@ -2,13 +2,24 @@ import { Effect } from "effect"
import { checkArrayLength, checkStringLength } from "../interpreter/limits.js"
import { constructor, methods, prototypeFrom, receiver, requiresNew } from "../interpreter/native.js"
import { IteratorSymbol, rangeError, syntaxError, typeError } from "../interpreter/model.js"
import { define, defineAccessor, get, hidden, Arr, Bytes, IteratorObj, Obj } from "../interpreter/objects.js"
import {
define,
defineAccessor,
get,
hidden,
Arr,
Bytes,
IteratorObj,
Obj,
coerceToNumber,
coerceToString,
type Value,
} from "../interpreter/objects.js"
import { describeValue } from "../interpreter/references.js"
import type { Interpreter } from "../interpreter/interpreter.js"
import { coerceToNumber, coerceToString } from "./value.js"
/** The bytes a Uint8Array, array, or other iterable of numbers describes; the host array clamps each value. */
const collectBytes = <R>(ctx: Interpreter<R>, source: unknown, name: string): Effect.Effect<Uint8Array, unknown, R> => {
const collectBytes = <R>(ctx: Interpreter<R>, source: Value, name: string): Effect.Effect<Uint8Array, unknown, R> => {
if (source instanceof Bytes) return Effect.succeed(new Uint8Array(source.bytes))
return Effect.gen(function* () {
const cursor = yield* ctx.iterate(source)
@@ -27,7 +38,7 @@ const collectBytes = <R>(ctx: Interpreter<R>, source: unknown, name: string): Ef
})
}
const constructBytes = <R>(ctx: Interpreter<R>, args: Array<unknown>, proto: Obj) => {
const constructBytes = <R>(ctx: Interpreter<R>, args: Array<Value>, proto: Obj) => {
const source = args[0]
if (source !== null && typeof source === "object") {
return Effect.map(collectBytes(ctx, source, "new Uint8Array(...)"), (bytes) => new Bytes(proto, bytes))
@@ -48,7 +59,7 @@ export const uint8ArrayGlobal = <R>(ctx: Interpreter<R>) => {
call: requiresNew("Uint8Array"),
construct: (args, newTarget) => constructBytes(ctx, args, prototypeFrom(newTarget, proto)),
})
const decode = (name: string, args: Array<unknown>, from: (text: string) => Uint8Array) => {
const decode = (name: string, args: Array<Value>, from: (text: string) => Uint8Array) => {
if (typeof args[0] !== "string") throw typeError(`Uint8Array.${name} expects a string.`)
try {
return wrap(from(args[0]))
@@ -63,13 +74,13 @@ export const uint8ArrayGlobal = <R>(ctx: Interpreter<R>) => {
["fromHex", 1, (_, args) => decode("fromHex", args, (text) => Uint8Array.fromHex(text))],
])
const self = (thisValue: unknown, name: string) => receiver(Bytes, thisValue, `Uint8Array.prototype.${name}`)
const optNumber = (name: string, value: unknown, label: string): number | undefined => {
const self = (thisValue: Value, name: string) => receiver(Bytes, thisValue, `Uint8Array.prototype.${name}`)
const optNumber = (name: string, value: Value, label: string): number | undefined => {
if (value === undefined) return undefined
if (typeof value !== "number") throw typeError(`Uint8Array.${name} expects ${label} to be a number.`)
return value
}
const wrapAll = (items: Array<unknown>) => new Arr(builtins.Array, items)
const wrapAll = (items: Array<Value>) => new Arr(builtins.Array, items)
defineAccessor(proto, "length", (thisValue) => self(thisValue, "length").bytes.length)
methods(builtins, proto, [
["at", 1, (thisValue, args) => self(thisValue, "at").bytes.at(optNumber("at", args[0], "index") ?? 0)],
@@ -222,8 +233,7 @@ const utf8Labels = new Set(["unicode-1-1-utf-8", "unicode11utf8", "unicode20utf8
export const textDecoderGlobal = <R>(ctx: Interpreter<R>) => {
const builtins = ctx.builtins
const proto = builtins.TextDecoder
const self = (thisValue: unknown, name: string) =>
receiver(TextDecoderObj, thisValue, `TextDecoder.prototype.${name}`)
const self = (thisValue: Value, name: string) => receiver(TextDecoderObj, thisValue, `TextDecoder.prototype.${name}`)
defineAccessor(proto, "encoding", (thisValue) => self(thisValue, "encoding").decoder.encoding)
defineAccessor(proto, "fatal", (thisValue) => self(thisValue, "fatal").decoder.fatal)
defineAccessor(proto, "ignoreBOM", (thisValue) => self(thisValue, "ignoreBOM").decoder.ignoreBOM)
+22 -32
View File
@@ -7,15 +7,16 @@ import {
get,
getOwn,
hidden,
isWrapper,
Arr,
coerceToString,
IteratorObj,
MapObj,
Obj,
PromiseObj,
SetObj,
type Value,
} from "../interpreter/objects.js"
import { describeValue, isRuntimeReference } from "../interpreter/references.js"
import { describeValue, isOpaque } from "../interpreter/references.js"
import {
applyCollectionCallback,
isSupportedCallback,
@@ -25,9 +26,9 @@ import {
} from "../interpreter/callback.js"
import type { Interpreter } from "../interpreter/interpreter.js"
const coerceGroupByPropertyKey = <R>(ctx: Interpreter<R>, value: unknown): Effect.Effect<string, unknown, R> => {
if (value instanceof PromiseObj) return Effect.succeed("[object Promise]")
if (!isWrapper(value) && isRuntimeReference(value)) {
const coerceGroupByPropertyKey = <R>(ctx: Interpreter<R>, value: Value): Effect.Effect<string, unknown, R> => {
if (value instanceof PromiseObj) return Effect.succeed(coerceToString(value))
if (isOpaque(value)) {
throw invalidData(`Object.groupBy callback must return a data value, received ${describeValue(value)}.`)
}
return toPrimitiveString(ctx, value)
@@ -81,7 +82,7 @@ export const groupBy = <R>(ctx: Interpreter<R>, namespace: "Map" | "Object") =>
})
})
const constructMap = <R>(ctx: Interpreter<R>, init: unknown, proto: Obj) => {
const constructMap = <R>(ctx: Interpreter<R>, init: Value, proto: Obj) => {
const target = new MapObj(proto)
if (init === undefined || init === null) return Effect.succeed(target)
return Effect.gen(function* () {
@@ -105,7 +106,7 @@ const constructMap = <R>(ctx: Interpreter<R>, init: unknown, proto: Obj) => {
})
}
const constructSet = <R>(ctx: Interpreter<R>, init: unknown, proto: Obj) => {
const constructSet = <R>(ctx: Interpreter<R>, init: Value, proto: Obj) => {
const target = new SetObj(proto)
if (init === undefined || init === null) return Effect.succeed(target)
return Effect.gen(function* () {
@@ -130,8 +131,7 @@ export const mapGlobal = <R>(ctx: Interpreter<R>) => {
construct: (args, newTarget) => constructMap(ctx, args[0], prototypeFrom(newTarget, proto)),
})
define(map, "groupBy", groupBy(ctx, "Map"), hidden)
const self = (thisValue: unknown, name: string) => receiver(MapObj, thisValue, `Map.prototype.${name}`)
const wrap = (items: Array<unknown>) => new Arr(builtins.Array, items)
const self = (thisValue: Value, name: string) => receiver(MapObj, thisValue, `Map.prototype.${name}`)
defineAccessor(proto, "size", (thisValue) => receiver(MapObj, thisValue, "Map.prototype.size").map.size)
methods(builtins, proto, [
["get", 1, (thisValue, args) => self(thisValue, "get").map.get(args[0])],
@@ -179,17 +179,7 @@ export const mapGlobal = <R>(ctx: Interpreter<R>) => {
],
["keys", 0, (thisValue) => new IteratorObj(builtins.Iterator, self(thisValue, "keys").map.keys())],
["values", 0, (thisValue) => new IteratorObj(builtins.Iterator, self(thisValue, "values").map.values())],
[
"entries",
0,
(thisValue) =>
new IteratorObj(
builtins.Iterator,
self(thisValue, "entries")
.map.entries()
.map(([key, item]) => wrap([key, item])),
),
],
["entries", 0, (thisValue) => new IteratorObj(builtins.Iterator, self(thisValue, "entries").iterator(builtins))],
[
"forEach",
1,
@@ -209,26 +199,26 @@ export const mapGlobal = <R>(ctx: Interpreter<R>) => {
type SetRecord<R> = {
readonly size: number
readonly has: (item: unknown) => Effect.Effect<boolean, unknown, R>
readonly keys: () => Effect.Effect<Iterable<unknown>, unknown, R>
readonly has: (item: Value) => Effect.Effect<boolean, unknown, R>
readonly keys: () => Effect.Effect<Iterable<Value>, unknown, R>
}
const loadSetRecord = <R>(
ctx: Interpreter<R>,
source: unknown,
source: Value,
name: string,
): Effect.Effect<SetRecord<R>, unknown, R> => {
if (source instanceof SetObj) {
return Effect.succeed({
size: source.set.size,
has: (item: unknown) => Effect.succeed(source.set.has(item)),
has: (item: Value) => Effect.succeed(source.set.has(item)),
keys: () => Effect.succeed(source.set.values()),
})
}
if (source instanceof MapObj) {
return Effect.succeed({
size: source.map.size,
has: (item: unknown) => Effect.succeed(source.map.has(item)),
has: (item: Value) => Effect.succeed(source.map.has(item)),
keys: () => Effect.succeed(source.map.keys()),
})
}
@@ -247,10 +237,10 @@ const loadSetRecord = <R>(
}
return {
size: Math.max(Math.trunc(size), 0),
has: (item: unknown) => Effect.map(ctx.call(has, source, [item]), Boolean),
has: (item: Value) => Effect.map(ctx.call(has, source, [item]), Boolean),
keys: () =>
Effect.flatMap(ctx.call(keys, source, []), (result): Effect.Effect<Iterable<unknown>> => {
if (result instanceof IteratorObj) return Effect.succeed(result.iterator)
Effect.flatMap(ctx.call(keys, source, []), (result): Effect.Effect<Iterable<Value>> => {
if (result instanceof IteratorObj) return Effect.succeed(result.source)
if (result instanceof Arr) return Effect.succeed(result.items)
throw typeError(`Set.${name} expected 'keys' to return an iterator.`)
}),
@@ -262,8 +252,8 @@ const setOperation = <R>(
ctx: Interpreter<R>,
target: SetObj,
name: string,
source: unknown,
): Effect.Effect<unknown, unknown, R> =>
source: Value,
): Effect.Effect<Value, unknown, R> =>
Effect.gen(function* () {
const other = yield* loadSetRecord(ctx, source, name)
const copy = () => {
@@ -342,8 +332,8 @@ export const setGlobal = <R>(ctx: Interpreter<R>) => {
call: requiresNew("Set"),
construct: (args, newTarget) => constructSet(ctx, args[0], prototypeFrom(newTarget, proto)),
})
const self = (thisValue: unknown, name: string) => receiver(SetObj, thisValue, `Set.prototype.${name}`)
const wrap = (items: Array<unknown>) => new Arr(builtins.Array, items)
const self = (thisValue: Value, name: string) => receiver(SetObj, thisValue, `Set.prototype.${name}`)
const wrap = (items: Array<Value>) => new Arr(builtins.Array, items)
const operation = (name: string): Method => [
name,
1,
+13 -60
View File
@@ -1,22 +1,8 @@
import { type Method, methods } from "../interpreter/native.js"
import {
entries,
get,
Arr,
Bytes,
DateObj,
MapObj,
Obj,
PromiseObj,
RegExpObj,
SetObj,
URLObj,
URLSearchParamsObj,
HeadersObj,
} from "../interpreter/objects.js"
import { containsOpaqueReference, isRuntimeReference } from "../interpreter/references.js"
import { entries, get, Arr, Obj, type Value } from "../interpreter/objects.js"
import { ToolReference } from "../tool-runtime.js"
import { containsOpaqueReference } from "../interpreter/references.js"
import type { Interpreter } from "../interpreter/interpreter.js"
import { coerceToString } from "./value.js"
const consoleMethods = ["log", "info", "debug", "warn", "error", "dir", "table"]
@@ -43,7 +29,7 @@ export const consoleGlobal = <R>(ctx: Interpreter<R>) => {
const MAX_CONSOLE_DEPTH = 32
const formatConsoleMessage = (name: string, args: Array<unknown>): string => {
const formatConsoleMessage = (name: string, args: Array<Value>): string => {
if (name === "dir") return args.length === 0 ? "undefined" : formatValue(args[0])
if (name === "table") return formatConsoleTable(args[0], args[1])
const prefix = name === "warn" ? "[warn] " : name === "error" ? "[error] " : name === "debug" ? "[debug] " : ""
@@ -51,60 +37,27 @@ const formatConsoleMessage = (name: string, args: Array<unknown>): string => {
}
/** One value as `console.log` shows it. */
export const formatValue = (value: unknown): string => {
export const formatValue = (value: Value): string => {
if (value === undefined) return "undefined"
if (typeof value === "string") return value
return formatConsoleValue(value, new Set(), 0)
}
const formatConsoleValue = (value: unknown, seen: Set<object>, depth: number): string => {
const formatConsoleValue = (value: Value, seen: Set<object>, depth: number): string => {
if (value === null || value === undefined) return "null"
if (typeof value === "string") return JSON.stringify(value)
if (typeof value === "number" || typeof value === "boolean") return String(value)
if (typeof value !== "object") return String(value)
if (value instanceof PromiseObj) return "[Promise (await it to get its value)]"
if (value instanceof DateObj) return coerceToString(value)
if (value instanceof RegExpObj) return coerceToString(value)
if (value instanceof URLObj) return coerceToString(value)
if (value instanceof URLSearchParamsObj) return coerceToString(value)
if (value instanceof HeadersObj) return `Headers ${JSON.stringify(Object.fromEntries(value.headers))}`
if (value instanceof Bytes) return `Uint8Array(${value.bytes.length}) [${value.bytes.join(",")}]`
if (!(value instanceof Obj)) return value instanceof ToolReference ? "[opaque reference]" : String(value)
if (depth > MAX_CONSOLE_DEPTH) return "..."
if (seen.has(value)) return "[Circular]"
if (value instanceof MapObj) {
seen.add(value)
try {
const items = Array.from(value.map.entries(), ([key, item]) => `[${formatItems([key, item], seen, depth + 1)}]`)
return `Map(${value.map.size}) [${items.join(",")}]`
} finally {
seen.delete(value)
}
}
if (value instanceof SetObj) {
seen.add(value)
try {
return `Set(${value.set.size}) [${formatItems([...value.set.values()], seen, depth + 1)}]`
} finally {
seen.delete(value)
}
}
if (isRuntimeReference(value)) return "[opaque reference]"
seen.add(value)
try {
if (value instanceof Arr) return `[${formatItems(value.items, seen, depth + 1)}]`
if (!(value instanceof Obj)) return "[object Object]"
return `{${entries(value)
.map(([key, item]) => `${JSON.stringify(key)}:${formatConsoleValue(item, seen, depth + 1)}`)
.join(",")}}`
return value.inspect((item) => formatConsoleValue(item, seen, depth + 1))
} finally {
seen.delete(value)
}
}
const formatItems = (items: Array<unknown>, seen: Set<object>, depth: number): string =>
items.map((item) => formatConsoleValue(item, seen, depth)).join(",")
const formatConsoleTable = (value: unknown, columnsArgument: unknown): string => {
const formatConsoleTable = (value: Value, columnsArgument: Value): string => {
if (value === undefined) return "undefined"
if (containsOpaqueReference(value)) return "[opaque reference]"
const columns = columnsArgument instanceof Arr ? columnsArgument.items.map(String) : undefined
@@ -118,9 +71,9 @@ const formatConsoleTable = (value: unknown, columnsArgument: unknown): string =>
}
const consoleTableRows = (
data: unknown,
data: Value,
columns: ReadonlyArray<string> | undefined,
): Array<{ readonly index: string; readonly values: Record<string, unknown> }> => {
): Array<{ readonly index: string; readonly values: Record<string, Value> }> => {
if (data instanceof Arr) {
return data.items.map((item, index) => ({ index: String(index), values: consoleTableValues(item, columns) }))
}
@@ -130,7 +83,7 @@ const consoleTableRows = (
return [{ index: "0", values: { Value: data } }]
}
const consoleTableValues = (value: unknown, columns: ReadonlyArray<string> | undefined): Record<string, unknown> => {
const consoleTableValues = (value: Value, columns: ReadonlyArray<string> | undefined): Record<string, Value> => {
if (value instanceof Obj && !(value instanceof Arr)) {
if (columns !== undefined) return Object.fromEntries(columns.map((column) => [column, get(value, column)]))
return Object.fromEntries(entries(value))
@@ -138,7 +91,7 @@ const consoleTableValues = (value: unknown, columns: ReadonlyArray<string> | und
return { Value: value }
}
const formatConsoleTableCell = (value: unknown): string => {
const formatConsoleTableCell = (value: Value): string => {
if (value === undefined) return ""
if (typeof value === "string") return value
return formatConsoleValue(value, new Set(), 0)
+3 -4
View File
@@ -1,12 +1,11 @@
import { Effect } from "effect"
import { constructor, type Method, methods, prototypeFrom, receiver } from "../interpreter/native.js"
import { rangeError } from "../interpreter/model.js"
import { DateObj, Obj } from "../interpreter/objects.js"
import { DateObj, Obj, coerceToNumber, coerceToString, type Value } from "../interpreter/objects.js"
import { toPrimitive, toPrimitiveNumber } from "../interpreter/callback.js"
import type { Interpreter } from "../interpreter/interpreter.js"
import { coerceToNumber, coerceToString } from "./value.js"
const constructDate = <R>(ctx: Interpreter<R>, args: Array<unknown>, proto: Obj) => {
const constructDate = <R>(ctx: Interpreter<R>, args: Array<Value>, proto: Obj) => {
if (args.length === 0) return Effect.succeed(new DateObj(proto, Date.now()))
if (args.length === 1) {
const arg = args[0]
@@ -83,7 +82,7 @@ export const dateGlobal = <R>(ctx: Interpreter<R>) => {
["UTC", 7, (_, args) => Date.UTC(...(args.map((arg) => coerceToNumber(arg)) as Parameters<typeof Date.UTC>))],
])
const self = (thisValue: unknown, name: string) => receiver(DateObj, thisValue, `Date.prototype.${name}`)
const self = (thisValue: Value, name: string) => receiver(DateObj, thisValue, `Date.prototype.${name}`)
const iso = (value: DateObj) => {
if (!Number.isFinite(value.time)) throw rangeError("Invalid time value.")
return new Date(value.time).toISOString()
+25 -20
View File
@@ -1,11 +1,21 @@
import { Effect } from "effect"
import { constructor, methods, prototypeFrom, receiver, requiresNew } from "../interpreter/native.js"
import { IteratorSymbol, typeError } from "../interpreter/model.js"
import { define, entries, get, hidden, Arr, HeadersObj, IteratorObj, Obj } from "../interpreter/objects.js"
import {
define,
entries,
get,
hidden,
Arr,
HeadersObj,
IteratorObj,
Obj,
coerceToString,
isRuntimeReference,
type Value,
} from "../interpreter/objects.js"
import { applyCollectionCallback } from "../interpreter/callback.js"
import { isRuntimeReference } from "../interpreter/references.js"
import type { Interpreter } from "../interpreter/interpreter.js"
import { coerceToString } from "./value.js"
import { readPairs } from "./url.js"
// The host validates header names and values and throws its own TypeError; the program gets one of its own.
@@ -17,7 +27,7 @@ const attempt = <T>(run: () => T): T => {
}
}
const constructHeaders = <R>(ctx: Interpreter<R>, init: unknown, proto: Obj): Effect.Effect<HeadersObj, unknown, R> => {
const constructHeaders = <R>(ctx: Interpreter<R>, init: Value, proto: Obj): Effect.Effect<HeadersObj, unknown, R> => {
const wrap = (headers: Headers) => new HeadersObj(proto, headers)
if (init === undefined) return Effect.succeed(wrap(new Headers()))
return Effect.gen(function* () {
@@ -40,10 +50,10 @@ export const headersGlobal = <R>(ctx: Interpreter<R>) => {
call: requiresNew("Headers"),
construct: (args, newTarget) => constructHeaders(ctx, args[0], prototypeFrom(newTarget, proto)),
})
const self = (thisValue: unknown, name: string) => receiver(HeadersObj, thisValue, `Headers.prototype.${name}`)
const wrap = (items: Array<unknown>) => new Arr(builtins.Array, items)
const arg = (args: Array<unknown>, index: number): string => coerceToString(args[index])
const requireArgs = (name: string, args: Array<unknown>, count: number): void => {
const self = (thisValue: Value, name: string) => receiver(HeadersObj, thisValue, `Headers.prototype.${name}`)
const wrap = (items: Array<Value>) => new Arr(builtins.Array, items)
const arg = (args: Array<Value>, index: number): string => coerceToString(args[index])
const requireArgs = (name: string, args: Array<Value>, count: number): void => {
if (args.length < count) throw typeError(`Headers.${name} requires ${count} argument${count === 1 ? "" : "s"}.`)
}
methods(builtins, proto, [
@@ -53,7 +63,8 @@ export const headersGlobal = <R>(ctx: Interpreter<R>) => {
(thisValue, args) => {
requireArgs("append", args, 2)
const target = self(thisValue, "append").headers
return attempt(() => target.append(arg(args, 0), arg(args, 1)))
attempt(() => target.append(arg(args, 0), arg(args, 1)))
return undefined
},
],
[
@@ -62,7 +73,8 @@ export const headersGlobal = <R>(ctx: Interpreter<R>) => {
(thisValue, args) => {
requireArgs("delete", args, 1)
const target = self(thisValue, "delete").headers
return attempt(() => target.delete(arg(args, 0)))
attempt(() => target.delete(arg(args, 0)))
return undefined
},
],
[
@@ -90,7 +102,8 @@ export const headersGlobal = <R>(ctx: Interpreter<R>) => {
(thisValue, args) => {
requireArgs("set", args, 2)
const target = self(thisValue, "set").headers
return attempt(() => target.set(arg(args, 0), arg(args, 1)))
attempt(() => target.set(arg(args, 0), arg(args, 1)))
return undefined
},
],
// Iterator.from because Bun's Headers typings predate iterator helpers; the runtime iterators already have them.
@@ -104,15 +117,7 @@ export const headersGlobal = <R>(ctx: Interpreter<R>) => {
0,
(thisValue) => new IteratorObj(builtins.Iterator, Iterator.from(self(thisValue, "values").headers.values())),
],
[
"entries",
0,
(thisValue) =>
new IteratorObj(
builtins.Iterator,
Iterator.from(self(thisValue, "entries").headers.entries()).map(([key, value]) => wrap([key, value])),
),
],
["entries", 0, (thisValue) => new IteratorObj(builtins.Iterator, self(thisValue, "entries").iterator(builtins))],
[
"forEach",
1,
+1 -1
View File
@@ -11,7 +11,7 @@ export const iteratorGlobals = <R>(ctx: Interpreter<R>): void => {
"next",
0,
(thisValue) => {
const step = receiver(IteratorObj, thisValue, "Iterator.prototype.next").iterator.next()
const step = receiver(IteratorObj, thisValue, "Iterator.prototype.next").source.next()
return record(builtins.Object, { value: step.value, done: Boolean(step.done) })
},
],
+4 -4
View File
@@ -6,7 +6,7 @@ import { checkStringLength } from "../interpreter/limits.js"
import { syntaxError, typeError } from "../interpreter/model.js"
import { typeofValue } from "../interpreter/references.js"
import { fromJson, toJson } from "../data.js"
import { get, keys, Arr, Obj, record, remove, set } from "../interpreter/objects.js"
import { get, keys, Arr, Obj, record, remove, set, type Value } from "../interpreter/objects.js"
export const jsonGlobal = <R>(ctx: Interpreter<R>) => {
const json = new Obj(ctx.builtins.Object)
@@ -17,7 +17,7 @@ export const jsonGlobal = <R>(ctx: Interpreter<R>) => {
return json
}
const parse = <R>(ctx: Interpreter<R>, args: Array<unknown>): Effect.Effect<unknown, unknown, R> => {
const parse = <R>(ctx: Interpreter<R>, args: Array<Value>): Effect.Effect<Value, unknown, R> => {
const text = args[0]
if (typeof text !== "string") throw typeError("JSON.parse expects a string.")
@@ -31,7 +31,7 @@ const parse = <R>(ctx: Interpreter<R>, args: Array<unknown>): Effect.Effect<unkn
if (typeofValue(args[1]) !== "function") return Effect.succeed(parsed)
const apply = applyCollectionCallback(ctx, args[1], "JSON.parse")
const visit = (holder: Obj, key: string): Effect.Effect<unknown, unknown, R> =>
const visit = (holder: Obj, key: string): Effect.Effect<Value, unknown, R> =>
Effect.gen(function* () {
const value = get(holder, key)
if (value instanceof Obj) {
@@ -46,7 +46,7 @@ const parse = <R>(ctx: Interpreter<R>, args: Array<unknown>): Effect.Effect<unkn
return visit(record(ctx.builtins.Object, { "": parsed }), "")
}
const stringify = <R>(ctx: Interpreter<R>, args: Array<unknown>): Effect.Effect<unknown, unknown, R> => {
const stringify = <R>(ctx: Interpreter<R>, args: Array<Value>): Effect.Effect<Value, unknown, R> => {
const space = args[2]
const indent = typeof space === "number" || typeof space === "string" ? space : undefined
const replacer = args[1]
+2 -2
View File
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import { constants, type Method, methods } from "../interpreter/native.js"
import { typeError } from "../interpreter/model.js"
import { Obj } from "../interpreter/objects.js"
import { Obj, type Value } from "../interpreter/objects.js"
import { preserveConsumerError } from "../interpreter/callback.js"
import type { Interpreter } from "../interpreter/interpreter.js"
@@ -14,7 +14,7 @@ declare global {
// Validate only the arguments a method consumes; like JS, extras are ignored
// (so built-ins work as callbacks receiving (element, index, array)).
const number = (name: string, args: Array<unknown>, index: number): number => {
const number = (name: string, args: Array<Value>, index: number): number => {
if (index >= args.length) return Number.NaN
const arg = args[index]
if (typeof arg !== "number") throw typeError(`Math.${name} expects number arguments.`)
+5 -4
View File
@@ -1,7 +1,8 @@
import { constructor, constants, methods } from "../interpreter/native.js"
import { coerceToString, type Value } from "../interpreter/objects.js"
import { rangeError, typeError } from "../interpreter/model.js"
import type { Interpreter } from "../interpreter/interpreter.js"
import { coercion, coerceToString } from "./value.js"
import { coercion } from "./value.js"
export const numberGlobal = <R>(ctx: Interpreter<R>) => {
const builtins = ctx.builtins
@@ -39,11 +40,11 @@ export const numberGlobal = <R>(ctx: Interpreter<R>) => {
["parseFloat", 1, (_, args) => parseFloat(coerceToString(args[0]))],
])
const self = (thisValue: unknown, name: string): number => {
const self = (thisValue: Value, name: string): number => {
if (typeof thisValue === "number") return thisValue
throw typeError(`Number.prototype.${name} requires that 'this' be a Number.`)
}
const optNum = (name: string, arg: unknown): number | undefined => {
const optNum = (name: string, arg: Value): number | undefined => {
if (arg === undefined) return undefined
if (typeof arg !== "number") throw typeError(`Number.${name} expects a number argument.`)
return arg
@@ -89,7 +90,7 @@ export const booleanGlobal = <R>(ctx: Interpreter<R>) => {
length: 1,
call: coercion(ctx, "Boolean").call,
})
const self = (thisValue: unknown, name: string): boolean => {
const self = (thisValue: Value, name: string): boolean => {
if (typeof thisValue === "boolean") return thisValue
throw typeError(`Boolean.prototype.${name} requires that 'this' be a Boolean.`)
}
+9 -18
View File
@@ -9,7 +9,6 @@ import {
typeError,
} from "../interpreter/model.js"
import {
Callable,
define,
entries,
enumerableKeys,
@@ -20,23 +19,20 @@ import {
keys,
own,
Arr,
Bytes,
DateObj,
ErrorObj,
Obj,
PromiseObj,
RegExpObj,
set,
coerceToString,
type Value,
} from "../interpreter/objects.js"
import { containsOpaqueReference, describeValue, rejectCircularInsertion } from "../interpreter/references.js"
import { invoke, preserveConsumerError } from "../interpreter/callback.js"
import type { Interpreter } from "../interpreter/interpreter.js"
import { ToolReference } from "../tool-runtime.js"
import { groupBy } from "./collections.js"
import { coerceToString } from "./value.js"
// ToObject for enumeration.
export const enumerableSource = <R>(ctx: Interpreter<R>, label: string, value: unknown, node?: AstNode): Obj => {
export const enumerableSource = <R>(ctx: Interpreter<R>, label: string, value: Value, node?: AstNode): Obj => {
if (value === null || value === undefined) {
throw typeError(`${label} cannot convert ${describeValue(value)} to an object.`, node)
}
@@ -54,7 +50,7 @@ export const enumerableSource = <R>(ctx: Interpreter<R>, label: string, value: u
return new Obj(ctx.builtins.Object)
}
export const objectAssign = <R>(ctx: Interpreter<R>, args: Array<unknown>): unknown => {
export const objectAssign = <R>(ctx: Interpreter<R>, args: Array<Value>): Value => {
const target = args[0]
// JS would box a primitive target; wrappers and primitives cannot hold fields here.
if (!(target instanceof Obj)) {
@@ -75,7 +71,7 @@ export const objectAssign = <R>(ctx: Interpreter<R>, args: Array<unknown>): unkn
return target
}
const objectFromEntries = <R>(ctx: Interpreter<R>, source: unknown): Effect.Effect<Obj, unknown, R> => {
const objectFromEntries = <R>(ctx: Interpreter<R>, source: Value): Effect.Effect<Obj, unknown, R> => {
const out = new Obj(ctx.builtins.Object)
return Effect.gen(function* () {
const cursor = yield* ctx.iterate(source)
@@ -98,29 +94,24 @@ const objectFromEntries = <R>(ctx: Interpreter<R>, source: unknown): Effect.Effe
})
}
export const classTag = (value: unknown): string => {
const classTag = (value: Value): string => {
if (value === null) return "Null"
if (value === undefined) return "Undefined"
if (value instanceof Arr) return "Array"
if (value instanceof Callable) return "Function"
if (value instanceof ErrorObj) return "Error"
if (value instanceof DateObj) return "Date"
if (value instanceof RegExpObj) return "RegExp"
if (value instanceof Bytes) return "Uint8Array"
if (value instanceof Obj) return value.tag
if (typeof value === "string") return "String"
if (typeof value === "number") return "Number"
if (typeof value === "boolean") return "Boolean"
return "Object"
}
const propertyKey = (value: unknown): PropertyKey =>
const propertyKey = (value: Value): PropertyKey =>
value === AsyncIteratorSymbol || value === IteratorSymbol ? value : coerceToString(value)
// Object constructs identically with or without new, like JS. Only `keys` copies its result into the
// program; `values`, `entries`, `assign`, and `fromEntries` hand back the program's own values.
export const objectGlobal = <R>(ctx: Interpreter<R>) => {
const builtins = ctx.builtins
const construct = (args: Array<unknown>): unknown => {
const construct = (args: Array<Value>): Value => {
const first = args[0]
if (first === null || first === undefined) return new Obj(builtins.Object)
if (first instanceof Obj) return first
+14 -5
View File
@@ -2,9 +2,18 @@ import { Effect } from "effect"
import type { Builtins } from "../interpreter/intrinsics.js"
import { constructor, type Method, methods, prototypeFrom, receiver } from "../interpreter/native.js"
import { syntaxError, typeError } from "../interpreter/model.js"
import { define, defineAccessor, Arr, Obj, RegExpObj, record } from "../interpreter/objects.js"
import {
define,
defineAccessor,
Arr,
Obj,
RegExpObj,
record,
coerceToNumber,
coerceToString,
type Value,
} from "../interpreter/objects.js"
import type { Interpreter } from "../interpreter/interpreter.js"
import { coerceToNumber, coerceToString } from "./value.js"
const flagProperties = [
"hasIndices",
@@ -23,7 +32,7 @@ const regexFailureReason = (error: unknown): string =>
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, extraFlags = ""): RegExp => {
export const toHostRegex = (arg: Value, method: string, extraFlags = ""): RegExp => {
// Native parity: an undefined pattern behaves as an empty pattern.
if (arg === undefined) return new RegExp("", extraFlags)
if (arg instanceof RegExpObj) return arg.regex
@@ -53,7 +62,7 @@ export const matchToValue = (builtins: Builtins, match: RegExpMatchArray): Arr =
return result
}
export const constructRegExp = (builtins: Builtins, args: Array<unknown>, proto: Obj = builtins.RegExp): RegExpObj => {
export const constructRegExp = (builtins: Builtins, args: Array<Value>, proto: Obj = builtins.RegExp): RegExpObj => {
const first = args[0]
const pattern = first instanceof RegExpObj ? first.regex.source : first === undefined ? "" : coerceToString(first)
const flagsArg = args[1]
@@ -96,7 +105,7 @@ export const regexpGlobal = <R>(ctx: Interpreter<R>) => {
],
])
const self = (thisValue: unknown, name: string) => receiver(RegExpObj, thisValue, `RegExp.prototype.${name}`)
const self = (thisValue: Value, name: string) => receiver(RegExpObj, thisValue, `RegExp.prototype.${name}`)
defineAccessor(proto, "source", (thisValue) => self(thisValue, "source").regex.source)
defineAccessor(proto, "flags", (thisValue) => self(thisValue, "flags").regex.flags)
// The host regex holds the only lastIndex, so exec/test and the String methods share one counter.
+37 -28
View File
@@ -1,16 +1,26 @@
import { Effect } from "effect"
import { constructor, fn, type Method, methods } from "../interpreter/native.js"
import { constructor, fn, type Impl, type Method, methods } from "../interpreter/native.js"
import { checkArrayLength, checkStringLength } from "../interpreter/limits.js"
import { invalidData, IteratorSymbol, rangeError, typeError } from "../interpreter/model.js"
import { define, hidden, Arr, IteratorObj, PromiseObj, RegExpObj, record } from "../interpreter/objects.js"
import {
define,
hidden,
Arr,
IteratorObj,
RegExpObj,
record,
coerceToNumber,
coerceToString,
type Value,
} from "../interpreter/objects.js"
import { containsOpaqueReference, typeofValue } from "../interpreter/references.js"
import { applyCollectionCallback, isSupportedCallback } from "../interpreter/callback.js"
import type { Interpreter } from "../interpreter/interpreter.js"
import { matchToValue, toHostRegex } from "./regexp.js"
import { coerceToNumber, coerceToString, coercion } from "./value.js"
import { coercion } from "./value.js"
// console is intercepted by the interpreter before reaching here.
const requireDataArgument = (name: string, index: number, arg: unknown): unknown => {
const requireDataArgument = (name: string, index: number, arg: Value): Value => {
if (containsOpaqueReference(arg)) {
throw invalidData(`String.${name} expects argument ${index + 1} to be a data value.`)
}
@@ -29,21 +39,23 @@ const replaceWithCallback = <R>(
ctx: Interpreter<R>,
value: string,
name: "replace" | "replaceAll",
args: Array<unknown>,
): Effect.Effect<unknown, unknown, R> => {
args: Array<Value>,
): Effect.Effect<Value, unknown, R> => {
const builtins = ctx.builtins
const apply = applyCollectionCallback(ctx, args[1], `String.${name}`)
const matches: Array<{ readonly match: string; readonly offset: number; readonly args: Array<unknown> }> = []
const collect = (...callbackArgs: Array<unknown>): string => {
const matches: Array<{ readonly match: string; readonly offset: number; readonly args: Array<Value> }> = []
// The host calls back with (match, ...captures, offset, string, groups?); only groups is not already a Value.
const collect = (
...callbackArgs: Array<string | number | undefined | Record<string, string | undefined>>
): string => {
const match = callbackArgs[0]
const groups = callbackArgs[callbackArgs.length - 1]
const hasGroups = groups !== null && typeof groups === "object"
const hasGroups = typeof callbackArgs.at(-1) === "object"
const offset = callbackArgs[callbackArgs.length - (hasGroups ? 3 : 2)]
if (typeof match !== "string" || typeof offset !== "number") {
throw typeError(`String.${name} produced an invalid replacement match.`)
}
if (hasGroups) callbackArgs[callbackArgs.length - 1] = record(builtins.Object, groups as Record<string, unknown>)
matches.push({ match, offset, args: callbackArgs })
const args = callbackArgs.map((arg) => (typeof arg === "object" ? record(builtins.Object, arg) : arg))
matches.push({ match, offset, args })
return match
}
@@ -63,10 +75,7 @@ const replaceWithCallback = <R>(
let end = 0
for (const match of matches) {
const replacement = yield* apply(match.args)
output.push(
value.slice(end, match.offset),
replacement instanceof PromiseObj ? "[object Promise]" : coerceToString(replacement),
)
output.push(value.slice(end, match.offset), coerceToString(replacement))
end = match.offset + match.match.length
}
output.push(value.slice(end))
@@ -99,7 +108,7 @@ export const stringGlobal = <R>(ctx: Interpreter<R>) => {
codeUnits("fromCodePoint", String.fromCodePoint),
])
const self = (thisValue: unknown, name: string): string => {
const self = (thisValue: Value, name: string): string => {
if (typeof thisValue === "string") return thisValue
if (thisValue === null || thisValue === undefined) {
throw typeError(`String.prototype.${name} called on null or undefined.`)
@@ -107,26 +116,26 @@ export const stringGlobal = <R>(ctx: Interpreter<R>) => {
return coerceToString(thisValue)
}
// Coerce arguments like native JS; opaque runtime references still reject.
const str = (name: string, args: Array<unknown>, index: number): string =>
const str = (name: string, args: Array<Value>, index: number): string =>
coerceToString(requireDataArgument(name, index, args[index]))
const num = (name: string, args: Array<unknown>, index: number): number =>
const num = (name: string, args: Array<Value>, index: number): number =>
coerceToNumber(requireDataArgument(name, index, args[index]))
const optNum = (name: string, args: Array<unknown>, index: number): number | undefined =>
const optNum = (name: string, args: Array<Value>, index: number): number | undefined =>
args[index] === undefined ? undefined : num(name, args, index)
const optStr = (name: string, args: Array<unknown>, index: number): string | undefined =>
const optStr = (name: string, args: Array<Value>, index: number): string | undefined =>
args[index] === undefined ? undefined : str(name, args, index)
const rejectRegex = (name: string, args: Array<unknown>): void => {
const rejectRegex = (name: string, args: Array<Value>): void => {
if (args[0] instanceof RegExpObj) {
throw typeError(
`String.${name} cannot take a regular expression; use regex.test(string) or String.search instead.`,
)
}
}
const simple = (name: string, length: number, op: (value: string, args: Array<unknown>) => unknown): Method => [
name,
length,
(thisValue, args) => op(self(thisValue, name), args),
]
const simple = (
name: string,
length: number,
op: (value: string, args: Array<Value>) => ReturnType<Impl>,
): Method => [name, length, (thisValue, args) => op(self(thisValue, name), args)]
const replace = (name: "replace" | "replaceAll") =>
simple(name, 2, (value, args) => {
if (isSupportedCallback(args[1])) return replaceWithCallback(ctx, value, name, args)
@@ -218,7 +227,7 @@ export const stringGlobal = <R>(ctx: Interpreter<R>) => {
`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.`,
)
}
const matches: Array<unknown> = []
const matches: Array<Value> = []
for (const match of value.matchAll(pattern)) {
checkArrayLength(matches.length + 1)
matches.push(matchToValue(builtins, match))
+14 -25
View File
@@ -7,17 +7,17 @@ import {
entries,
get,
hidden,
isWrapper,
Arr,
IteratorObj,
Obj,
URLObj,
URLSearchParamsObj,
coerceToString,
isRuntimeReference,
type Value,
} from "../interpreter/objects.js"
import { isRuntimeReference } from "../interpreter/references.js"
import { applyCollectionCallback, preserveConsumerError } from "../interpreter/callback.js"
import type { Interpreter } from "../interpreter/interpreter.js"
import { coerceToString } from "./value.js"
const urlProperties = [
"href",
@@ -52,12 +52,12 @@ export const uriGlobal = <R>(ctx: Interpreter<R>, name: UriFunction) =>
}
})
const urlArgument = (value: unknown): string => (value instanceof URLObj ? value.url.href : coerceToString(value))
const urlArgument = (value: Value): string => (value instanceof URLObj ? value.url.href : coerceToString(value))
export const urlGlobal = <R>(ctx: Interpreter<R>) => {
const builtins = ctx.builtins
const proto = builtins.URL
const construct = (args: Array<unknown>, into: Obj): URLObj => {
const construct = (args: Array<Value>, into: Obj): URLObj => {
if (args.length === 0) {
throw typeError("new URL(...) requires a URL string and an optional base URL.")
}
@@ -92,7 +92,7 @@ export const urlGlobal = <R>(ctx: Interpreter<R>) => {
]
methods(builtins, url, [parse("canParse"), parse("parse")])
const self = (thisValue: unknown, name: string) => receiver(URLObj, thisValue, `URL.prototype.${name}`)
const self = (thisValue: Value, name: string) => receiver(URLObj, thisValue, `URL.prototype.${name}`)
for (const name of urlProperties) {
defineAccessor(
proto,
@@ -119,7 +119,7 @@ export const urlGlobal = <R>(ctx: Interpreter<R>) => {
return url
}
const readPair = <R>(ctx: Interpreter<R>, value: unknown, label: string): Effect.Effect<Array<string>, unknown, R> =>
const readPair = <R>(ctx: Interpreter<R>, value: Value, label: string): Effect.Effect<Array<string>, unknown, R> =>
Effect.gen(function* () {
const cursor = yield* ctx.iterate(value)
if (cursor === undefined) throw typeError(`${label} expects iterable [name, value] pairs.`)
@@ -142,7 +142,7 @@ const readPair = <R>(ctx: Interpreter<R>, value: unknown, label: string): Effect
*/
export const readPairs = <R>(
ctx: Interpreter<R>,
init: unknown,
init: Value,
label: string,
): Effect.Effect<Array<[string, string]> | undefined, unknown, R> =>
Effect.gen(function* () {
@@ -161,7 +161,7 @@ export const readPairs = <R>(
const constructURLSearchParams = <R>(
ctx: Interpreter<R>,
init: unknown,
init: Value,
proto: Obj,
): Effect.Effect<URLSearchParamsObj, unknown, R> => {
const wrap = (params: URLSearchParams) => new URLSearchParamsObj(proto, params)
@@ -177,7 +177,6 @@ const constructURLSearchParams = <R>(
if (isRuntimeReference(init)) {
throw typeError("new URLSearchParams(...) expects a query string, data object, or synchronous iterable pairs.")
}
if (isWrapper(init)) return wrap(new URLSearchParams())
if (!(init instanceof Obj)) {
throw typeError(
"new URLSearchParams(...) expects a query string, data object, iterable pairs, or URLSearchParams.",
@@ -197,11 +196,11 @@ export const urlSearchParamsGlobal = <R>(ctx: Interpreter<R>) => {
call: requiresNew("URLSearchParams"),
construct: (args, newTarget) => constructURLSearchParams(ctx, args[0], prototypeFrom(newTarget, proto)),
})
const self = (thisValue: unknown, name: string) =>
const self = (thisValue: Value, name: string) =>
receiver(URLSearchParamsObj, thisValue, `URLSearchParams.prototype.${name}`)
const wrap = (items: Array<unknown>) => new Arr(builtins.Array, items)
const arg = (args: Array<unknown>, index: number): string => coerceToString(args[index])
const requireArgs = (name: string, args: Array<unknown>, count: number): void => {
const wrap = (items: Array<Value>) => new Arr(builtins.Array, items)
const arg = (args: Array<Value>, index: number): string => coerceToString(args[index])
const requireArgs = (name: string, args: Array<Value>, count: number): void => {
if (args.length < count) {
throw typeError(`URLSearchParams.${name} requires ${count} argument${count === 1 ? "" : "s"}.`)
}
@@ -272,17 +271,7 @@ export const urlSearchParamsGlobal = <R>(ctx: Interpreter<R>) => {
],
["keys", 0, (thisValue) => new IteratorObj(builtins.Iterator, self(thisValue, "keys").params.keys())],
["values", 0, (thisValue) => new IteratorObj(builtins.Iterator, self(thisValue, "values").params.values())],
[
"entries",
0,
(thisValue) =>
new IteratorObj(
builtins.Iterator,
self(thisValue, "entries")
.params.entries()
.map(([key, value]) => wrap([key, value])),
),
],
["entries", 0, (thisValue) => new IteratorObj(builtins.Iterator, self(thisValue, "entries").iterator(builtins))],
["toString", 0, (thisValue) => self(thisValue, "toString").params.toString()],
[
"forEach",
+2 -62
View File
@@ -1,64 +1,13 @@
import { fn } from "../interpreter/native.js"
import { typeError } from "../interpreter/model.js"
import {
get,
isWrapper,
type Native,
Arr,
Bytes,
DateObj,
ErrorObj,
MapObj,
RegExpObj,
SetObj,
URLObj,
URLSearchParamsObj,
HeadersObj,
} from "../interpreter/objects.js"
import { coerceToNumber, coerceToString, type Native, type Value } from "../interpreter/objects.js"
import type { Interpreter } from "../interpreter/interpreter.js"
export const compoundOperators = new Set(["+=", "-=", "*=", "/=", "%=", "**=", "&=", "|=", "^=", "<<=", ">>=", ">>>="])
/** The built-in string form of a value, without consulting program-defined `toString` methods. */
export const coerceToString = (value: unknown): string => {
if (value === null) return "null"
if (value === undefined) return "undefined"
if (value instanceof DateObj) return Number.isFinite(value.time) ? new Date(value.time).toISOString() : "Invalid Date"
if (value instanceof RegExpObj) return `/${value.regex.source}/${value.regex.flags}`
if (value instanceof MapObj) return "[object Map]"
if (value instanceof SetObj) return "[object Set]"
if (value instanceof URLObj) return value.url.href
if (value instanceof URLSearchParamsObj) return value.params.toString()
if (value instanceof HeadersObj) return "[object Headers]"
if (value instanceof Bytes) return value.bytes.join(",")
if (value instanceof ErrorObj) {
// Match Error.prototype.toString: "name: message", or just one when the other is empty.
const name = get(value, "name")
const message = get(value, "message")
const shownName = typeof name === "string" ? name : "Error"
const shownMessage = typeof message === "string" ? message : ""
if (shownMessage === "") return shownName
if (shownName === "") return shownMessage
return `${shownName}: ${shownMessage}`
}
if (value instanceof Arr) {
return value.items.map((item) => (item === null || item === undefined ? "" : coerceToString(item))).join(",")
}
if (typeof value === "object") return "[object Object]"
return String(value)
}
export const coerceToNumber = (value: unknown): number => {
if (value instanceof DateObj) return value.time
if (value instanceof Bytes) return Number(coerceToString(value))
if (isWrapper(value)) return Number.NaN
if (value instanceof Arr) return Number(coerceToString(value))
return value !== null && typeof value === "object" ? Number.NaN : Number(value)
}
export type Coercion = "Number" | "String" | "Boolean" | "parseInt" | "parseFloat" | "isFinite" | "isNaN"
const coerce = <R>(ctx: Interpreter<R>, name: Coercion, args: Array<unknown>): unknown => {
const coerce = <R>(ctx: Interpreter<R>, name: Coercion, args: Array<Value>): Value => {
// Native: Number() is 0 and String() is "", unlike their undefined-argument forms; the
// other coercers match native through the undefined-argument path below.
if (args.length === 0) {
@@ -66,15 +15,6 @@ const coerce = <R>(ctx: Interpreter<R>, name: Coercion, args: Array<unknown>): u
if (name === "String") return ""
}
const raw = args[0]
if (isWrapper(raw)) {
if (name === "Boolean") return true
if (name === "Number") return coerceToNumber(raw)
if (name === "String") return coerceToString(raw)
if (name === "isFinite") return Number.isFinite(coerceToNumber(raw))
if (name === "isNaN") return Number.isNaN(coerceToNumber(raw))
if (name === "parseInt") return parseInt(coerceToString(raw))
return parseFloat(coerceToString(raw))
}
if (name === "Number") return coerceToNumber(raw)
if (name === "Boolean") return Boolean(raw)
if (name === "isFinite") return Number.isFinite(coerceToNumber(raw))
+1 -2
View File
@@ -1,9 +1,8 @@
import { fn, methods } from "../interpreter/native.js"
import { typeError } from "../interpreter/model.js"
import { Bytes, Obj } from "../interpreter/objects.js"
import { Bytes, Obj, coerceToString } from "../interpreter/objects.js"
import { describeValue } from "../interpreter/references.js"
import type { Interpreter } from "../interpreter/interpreter.js"
import { coerceToString } from "./value.js"
// WebIDL DOMString conversion: a missing argument is a TypeError, anything else stringifies. Invalid input is a
// TypeError as well; browsers throw a DOMException named InvalidCharacterError, which CodeMode does not have.
+4
View File
@@ -192,6 +192,8 @@ export type Prepared<R = never> = {
export type SearchEntry = {
readonly description: ToolDescription
/** The path split into words, so `zones` matches `get_zones` as a word rather than as a substring of `timezones`. */
readonly pathWords: ReadonlyArray<string>
readonly searchText: string
}
@@ -247,6 +249,7 @@ const makeSearchTool = (searchIndex: ReadonlyArray<SearchEntry>): Tool => ({
(total, forms) =>
total +
(forms.some((form) => path === form || path.endsWith(`.${form}`)) ? 20 : 0) +
(forms.some((form) => entry.pathWords.includes(form)) ? 12 : 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),
@@ -281,6 +284,7 @@ export const searchSignature = (() => {
const toSearchEntry = <R>(visible: VisibleTool<R>): SearchEntry => ({
description: describeTool(visible),
pathWords: tokenize(visible.path),
searchText: [
visible.path,
visible.tool.description,
+26
View File
@@ -1039,6 +1039,32 @@ describe("CodeMode public contract", () => {
}
})
test("a query term that is a whole word of the path outranks a substring of it", async () => {
const simple = (description: string) =>
Tool.make({
description,
input: Schema.Struct({}),
output: Schema.String,
execute: () => Effect.succeed("ok"),
})
const runtime = CodeMode.make({
tools: {
// Declared so that alphabetical order would put the substring match first.
cloudflare: { get_timezones: simple("List timezones"), get_zones: simple("List zones") },
},
})
const ranked = await Effect.runPromise(runtime.execute(`return search({ query: "zones" })`))
expect(ranked.ok).toBe(true)
if (ranked.ok) {
const value = ranked.value as { items: Array<{ path: string }> }
expect(value.items.map((item) => item.path)).toStrictEqual([
"tools.cloudflare.get_zones",
"tools.cloudflare.get_timezones",
])
}
})
test("a plural query term matches singular-only tool text", async () => {
const simple = (description: string) =>
Tool.make({
@@ -529,6 +529,6 @@ describe("Test262 for-await-of adaptations", () => {
const result = await execute(`for await (const item of { values: [1, 2] }) {}`)
expect(result.ok).toBe(false)
if (result.ok) return
expect(result.error.message).toContain("or custom iterator value")
expect(result.error.message).toContain("requires an iterable value, received a data object")
})
})
@@ -1030,7 +1030,7 @@ describe("confined generators", () => {
const params = new URLSearchParams(entries())
return [events, params.toString()]
`),
).toEqual([["first", "second", "pair close", "outer close"], "%5Bobject+Object%5D=2"])
).toEqual([["first", "second", "pair close", "outer close"], "%5Bobject+Promise%5D=2"])
})
test("validates URLSearchParams pair lengths after converting the outer sequence", async () => {
+26
View File
@@ -1267,6 +1267,32 @@ describe("built-in iterators", () => {
})
})
describe("Object.prototype.toString", () => {
test("reports the built-in kind it is inherited by, as JS does through Symbol.toStringTag", async () => {
expect(
await value(`
return [
new Map().toString(), new Set().toString(), new Headers().toString(), Promise.resolve(1).toString(),
[1].values().toString(), ({}).toString(), String(new Map()), String(Promise.resolve(1)),
\`\${new Set([1])}\`, [new Map()] + "", new Map() == "[object Map]",
]
`),
).toEqual([
"[object Map]",
"[object Set]",
"[object Headers]",
"[object Promise]",
"[object Iterator]",
"[object Object]",
"[object Map]",
"[object Promise]",
"[object Set]",
"[object Map]",
true,
])
})
})
describe("toLocaleString", () => {
test("numbers and dates format as en-US in UTC; everything else falls back to toString", async () => {
expect(
+14 -11
View File
@@ -9,7 +9,7 @@ import type { Interpreter } from "../../src/interpreter/interpreter.js"
import { Throw } from "../../src/interpreter/model.js"
import { createErrorValue } from "../../src/interpreter/intrinsics.js"
import { constructor, fn, methods } from "../../src/interpreter/native.js"
import { Callable, define, get, hidden, Arr, Fn, Obj } from "../../src/interpreter/objects.js"
import { Callable, define, get, hidden, Arr, Fn, Obj, type Value } from "../../src/interpreter/objects.js"
import { ToolRuntime } from "../../src/tool-runtime.js"
export const root = import.meta.dir
@@ -64,10 +64,7 @@ export const run = async (file: string): Promise<Outcome> => {
return { status: "pass" }
}
const harness = <R>(
ctx: Interpreter<R>,
onDone: (error: unknown) => void,
): ReadonlyArray<readonly [string, unknown]> => {
const harness = <R>(ctx: Interpreter<R>, onDone: (error: Value) => void): ReadonlyArray<readonly [string, Value]> => {
const builtins = ctx.builtins
const test262Prototype = new Obj(builtins.Object)
define(test262Prototype, "name", "Test262Error", hidden)
@@ -90,7 +87,7 @@ const harness = <R>(
methods(builtins, compareArray, [["format", 1, (_, args) => show(args[0])]])
const assert = fn<R>(builtins, "assert", 2, (_, args) =>
args[0] === true
? Effect.void
? Effect.undefined
: fail(args[1] === undefined ? `Expected true but got ${show(args[0])}` : String(args[1])),
)
methods(builtins, assert, [
@@ -99,7 +96,7 @@ const harness = <R>(
3,
(_, args) =>
Object.is(args[0], args[1])
? Effect.void
? Effect.undefined
: fail(`${prefix(args[2])}Expected SameValue(«${show(args[0])}», «${show(args[1])}») to be true`),
],
[
@@ -108,14 +105,14 @@ const harness = <R>(
(_, args) =>
Object.is(args[0], args[1])
? fail(`${prefix(args[2])}Expected SameValue(«${show(args[0])}», «${show(args[1])}») to be false`)
: Effect.void,
: Effect.undefined,
],
[
"compareArray",
3,
(_, args) =>
compare(args[0], args[1])
? Effect.void
? Effect.undefined
: fail(
`Actual ${show(args[0])} and expected ${show(args[1])} should have the same contents. ${prefix(args[2])}`,
),
@@ -132,7 +129,7 @@ const harness = <R>(
const thrown = materialize(ctx, Cause.squash(cause))
if (!(thrown instanceof Obj)) return fail(`${prefix(args[2])}Thrown value was not an object!`)
const actual = get(thrown, "constructor")
if (actual === args[0]) return Effect.void
if (actual === args[0]) return Effect.undefined
return fail(`${prefix(args[2])}Expected a ${expected} but got a ${show(actual)}`)
},
onSuccess: () =>
@@ -146,7 +143,13 @@ const harness = <R>(
["assert", assert],
["compareArray", compareArray],
["Test262Error", test262Error],
["$DONE", fn<R>(builtins, "$DONE", 1, (_, args) => onDone(args[0]))],
[
"$DONE",
fn<R>(builtins, "$DONE", 1, (_, args) => {
onDone(args[0])
return undefined
}),
],
[
"$DONOTEVALUATE",
fn<R>(builtins, "$DONOTEVALUATE", 0, () =>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-app",
"version": "2.0.11",
"version": "2.0.12",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/console-core",
"version": "2.0.11",
"version": "2.0.12",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-function",
"version": "2.0.11",
"version": "2.0.12",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-mail",
"version": "2.0.11",
"version": "2.0.12",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-support",
"version": "2.0.11",
"version": "2.0.12",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.11",
"version": "2.0.12",
"name": "@opencode/core",
"type": "module",
"license": "MIT",
+4 -6
View File
@@ -6,15 +6,13 @@ import { Instructions } from "../instructions/index.js"
import { CodeModeCatalog } from "./catalog.js"
// prettier-ignore
const prompt = (hasMoreTools: boolean) => `The Code Mode tool catalog below is ${hasMoreTools ? "partial" : "complete"}.
const prompt = (hasMoreTools: boolean) => `# Code Mode
${hasMoreTools ? "The Code Mode catalog and `search` results are" : "This catalog is"} the complete set of tools callable inside \`execute\`. It does not affect tools exposed directly outside Code Mode.${hasMoreTools ? `
Use the \`execute\` tool to call the tools listed below. They cannot be called directly${hasMoreTools ? ", and neither can \`search\`. Both" : ". They"} only work inside code you pass to \`execute\`.
## Search
${hasMoreTools ? `The catalog is partial. Inside \`execute\`, use \`search(...)\` to find a tool, then call it by the \`path\` in the result. Do not guess tool names.
Call \`search(...)\` to discover exact paths and signatures for additional tools:
- ${searchSignature}` : ""}
- ${searchSignature}` : "The catalog is complete. Do not guess tool names."}
## Available tools`
+30 -14
View File
@@ -4,6 +4,7 @@ import { define } from "@opencode/plugin/effect/plugin"
import { Document } from "@opencode/schema/config"
import { Effect } from "effect"
import { Config } from "../../config.js"
import { ManagedPolicy } from "../../managed-policy.js"
import { Wildcard } from "../../util/wildcard.js"
import { ConfigEntryObserver } from "./entry-observer.js"
@@ -11,16 +12,30 @@ export const Plugin = define({
id: "opencode.config.policy",
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const managed = yield* ManagedPolicy.Service
const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, ctx.provider.reload())
const policies = () =>
loaded.entries
.filter((entry): entry is Document => entry.type === "document")
.toReversed()
.flatMap((entry) => entry.info.experimental?.policies ?? [])
// Authored documents reverse so user-global policy outranks repository policy; organization statements
// from the connected Console follow every authored one and have the final say.
const policies = () => {
const organization = managed.current()
return [
...loaded.entries
.filter((entry): entry is Document => entry.type === "document")
.toReversed()
.flatMap((entry) => entry.info.experimental?.policies ?? [])
.map((policy) => ({ ...policy, message: "Blocked by configuration policy" })),
...organization.statements.map((policy) => ({
...policy,
message: organization.organization
? `Blocked by ${organization.organization}'s policy`
: "Blocked by your organization's policy",
})),
]
}
yield* ctx.provider.transform((providers) => {
// User-global policy takes priority over policy authored by a repository.
const current = policies()
for (const record of providers.list()) {
const policy = policies().findLast(
const policy = current.findLast(
(policy) => policy.action === "provider.use" && Wildcard.match(record.provider.id, policy.resource),
)
if (policy?.effect === "deny") providers.remove(record.provider.id)
@@ -29,16 +44,17 @@ export const Plugin = define({
yield* ctx.permission.hook("evaluate", (event) =>
Effect.sync(() => {
const current = policies()
const denied = event.resources.some((resource) => {
const policy = current.findLast(
(policy) =>
policy.action === "permission" && Wildcard.match(`${event.action}:${resource}`, policy.resource),
const denied = event.resources
.map((resource) =>
current.findLast(
(policy) =>
policy.action === "permission" && Wildcard.match(`${event.action}:${resource}`, policy.resource),
),
)
return policy?.effect === "deny"
})
.find((policy) => policy?.effect === "deny")
if (!denied) return
event.effect = "deny"
event.message = "Blocked by configuration policy"
event.message = denied.message
}),
)
}),
-2
View File
@@ -46,7 +46,6 @@ import m43 from "./migration/20260812213948_worktree.js"
import m44 from "./migration/20260819222447_session_viewed_state.js"
import m45 from "./migration/20260823191254_nullable_workspace_binding.js"
import m46 from "./migration/20260910120000_clear_v1_session_permission.js"
import m47 from "./migration/20260920120000_message_native.js"
export const migrations = [
m00,
@@ -96,5 +95,4 @@ export const migrations = [
m44,
m45,
m46,
m47,
] satisfies DatabaseMigration.Migration[]
@@ -1,33 +0,0 @@
import { Effect, Predicate, Schema } from "effect"
import { SessionMessage } from "../../session/message.js"
import type { DatabaseMigration } from "../migration.js"
const migration: DatabaseMigration.Migration = {
id: "20260920120000_message_native",
up(tx) {
return Effect.gen(function* () {
const rows = yield* tx.all<{ id: string; type: string; data: string }>(
`SELECT id, type, data FROM session_message WHERE type IN ('assistant', 'compaction')`,
)
yield* Effect.forEach(rows, (row) =>
Effect.gen(function* () {
const stored = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown))(row.data)
if (!Predicate.isObject(stored)) return
const next = SessionMessage.persisted({ ...stored, type: row.type })
if (!Predicate.isObject(next)) return
const data = Object.fromEntries(Object.entries(next).filter(([key]) => key !== "type"))
if (JSON.stringify(data) === JSON.stringify(stored)) return
yield* tx.run(
`UPDATE session_message SET data = '${quote(JSON.stringify(data))}' WHERE id = '${quote(row.id)}'`,
)
}),
)
})
},
}
function quote(value: string) {
return value.replaceAll("'", "''")
}
export default migration
+34
View File
@@ -0,0 +1,34 @@
export * as ManagedPolicy from "./managed-policy.js"
import { ConfigPolicy } from "@opencode/schema/config/policy"
import { Context, Effect, Layer } from "effect"
import { makeGlobalNode } from "@opencode/util/effect/app-node"
/** Policy statements the connected OpenCode Console compiled for whoever it authenticated. */
export interface State {
readonly statements: ReadonlyArray<ConfigPolicy.Info>
/** Organization name for denial messages, when the connection knows it. */
readonly organization?: string
}
export interface Interface {
/** Synchronous so catalog transforms can consult the statements while they run. */
readonly current: () => State
/** Replaces the whole state; statements never merge across connections. */
readonly set: (state: State) => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/ManagedPolicy") {}
const layer = Layer.sync(Service, () => {
const state: { current: State } = { current: { statements: [] } }
return Service.of({
current: () => state.current,
set: (next) =>
Effect.sync(() => {
state.current = next
}),
})
})
export const node = makeGlobalNode({ service: Service, layer, deps: [] })
File diff suppressed because one or more lines are too long
+8
View File
@@ -50,6 +50,7 @@ import { Integration } from "../integration.js"
import { Job } from "../job.js"
import { KV } from "../kv.js"
import { Location } from "../location.js"
import { ManagedPolicy } from "../managed-policy.js"
import { ModelsDev } from "../models-dev.js"
import { Mcp } from "../mcp/index.js"
import { Npm } from "@opencode/util/npm"
@@ -92,6 +93,7 @@ import { PlanPlugin } from "./plan.js"
import { ModelsDevPlugin } from "./models-dev.js"
import { McpCodeModeExclusionPlugin } from "./mcp-codemode-exclusion.js"
import { ProviderPlugins } from "./provider.js"
import { OpencodePlugin } from "./provider/opencode.js"
import { WebSearchPlugins } from "./websearch/index.js"
import { SkillPlugin } from "./skill.js"
import { VcsHgPlugin } from "./vcs/hg.js"
@@ -126,6 +128,7 @@ const services = [
KV.Service,
LLMClient.Service,
Location.Service,
ManagedPolicy.Service,
ModelsDev.Service,
Mcp.Service,
Npm.Service,
@@ -178,6 +181,7 @@ export const requirements = LayerNode.group([
KV.node,
llmClient,
Location.node,
ManagedPolicy.node,
ModelsDev.node,
Mcp.node,
Npm.node,
@@ -260,6 +264,10 @@ const post = [
ConfigPolicyPlugin.Plugin,
] as const satisfies readonly InternalPlugin[]
// Repository config must not switch off policy enforcement or the Console connection that delivers
// organization statements, so plugin remove operations skip these IDs.
export const guarded: ReadonlySet<string> = new Set([OpencodePlugin.id, ConfigPolicyPlugin.Plugin.id])
export const list = Effect.fn("PluginInternal.list")(function* () {
// Capture only services; activation supplies the child Scope and batching context.
const context = Context.pick(...services)(yield* Effect.context<Requirements>())
+38 -18
View File
@@ -7,8 +7,10 @@ import { Bus } from "../../bus.js"
import { Credential } from "../../credential.js"
import { Integration } from "../../integration.js"
import { IntegrationConnection } from "../../integration/connection.js"
import { ManagedPolicy } from "../../managed-policy.js"
import { Provider } from "../../provider.js"
import { WebSearch } from "../../websearch.js"
import { ConfigPolicy } from "@opencode/schema/config/policy"
import { ConfigProvider } from "@opencode/schema/config/provider"
import { Money } from "@opencode/schema/money"
@@ -20,6 +22,10 @@ const RemoteResponse = Schema.Struct({
websearch: Schema.Struct({
providerID: WebSearch.ID,
}).pipe(Schema.optional),
// Organization policy compiled for the authenticated caller; omitted when there is none.
experimental: Schema.Struct({
policies: Schema.Array(ConfigPolicy.Info).pipe(Schema.optional),
}).pipe(Schema.optional),
})
const Device = Schema.Struct({
device_code: Schema.String,
@@ -112,38 +118,50 @@ function oauth(http: HttpClient.HttpClient) {
} satisfies IntegrationOAuthMethodRegistration
}
export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope.Scope>({
export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | ManagedPolicy.Service | Scope.Scope>({
id: "opencode.provider.opencode",
effect: Effect.fn(function* (ctx) {
const bus = yield* Bus.Service
const http = yield* HttpClient.HttpClient
const managed = yield* ManagedPolicy.Service
const loading = Semaphore.makeUnsafe(1)
type ActiveConnection = Effect.Success<ReturnType<typeof ctx.integration.connection.active>>
let snapshot: {
config: typeof RemoteResponse.Type | undefined
connection: ActiveConnection
} = { config: undefined, connection: undefined }
organization: string | undefined
} = { config: undefined, connection: undefined, organization: undefined }
const load = Effect.fn("OpencodePlugin.load")(function* () {
const connection = yield* ctx.integration.connection.active("opencode")
const credential = connection
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.orElseSucceed(() => undefined))
: undefined
const config = credential
? yield* fetchConfig(http, credential).pipe(
Effect.catch((cause) =>
Effect.logWarning("failed to load OpenCode provider config", { cause }).pipe(
Effect.as(
IntegrationConnection.key(snapshot.connection) === IntegrationConnection.key(connection)
? snapshot.config
: undefined,
),
),
),
if (!connection) return { config: undefined, connection, organization: undefined }
return yield* ctx.integration.connection.resolve(connection).pipe(
Effect.flatMap((credential) => {
if (!credential) return Effect.succeed({ config: undefined, connection, organization: undefined })
return fetchConfig(http, credential).pipe(
Effect.map((config) => ({
config,
connection,
organization: typeof credential.metadata?.orgName === "string" ? credential.metadata.orgName : undefined,
})),
)
: undefined
return { config, connection }
}),
Effect.catch((cause) =>
Effect.logWarning("failed to load OpenCode provider config", { cause }).pipe(
// A load that fails for the connection already in place keeps its last config: dropping it
// would lift organization policy while personal credentials keep working.
Effect.as(
IntegrationConnection.key(connection) === IntegrationConnection.key(snapshot.connection)
? { config: snapshot.config, connection, organization: snapshot.organization }
: { config: undefined, connection, organization: undefined },
),
),
),
)
})
// Statements ride on the snapshot, so a credential switch, disconnect, or 404 replaces them too.
const publish = (next: typeof snapshot) =>
managed.set({ statements: next.config?.experimental?.policies ?? [], organization: next.organization })
yield* ctx.integration.transform((editor) => {
editor.update("opencode", (integration) => {
@@ -154,6 +172,7 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
})
snapshot = yield* load()
yield* publish(snapshot)
yield* ctx.provider.transform((providers) => {
for (const [providerID, item] of Object.entries(snapshot.config?.providers ?? {})) {
const source = providers.get(item.canonical ?? providerID)
@@ -316,6 +335,7 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
const apply = Effect.fn("OpencodePlugin.apply")(function* (next: typeof snapshot) {
snapshot = next
yield* publish(next)
yield* Effect.all([ctx.provider.reload(), ctx.websearch.reload()], { concurrency: 2, discard: true })
})
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(apply)))
+1 -1
View File
@@ -39,7 +39,7 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
if (operation.type === "remove") {
if (operation.target === "*") failures.clear()
plugins()
.filter((plugin) => matches(operation.target, plugin.id))
.filter((plugin) => matches(operation.target, plugin.id) && !PluginInternal.guarded.has(plugin.id))
.forEach((plugin) => enabled.delete(plugin.id))
continue
}
+1 -1
View File
@@ -60,7 +60,7 @@ export const latestCompaction = Effect.fnUntraced(function* (
})
export const decodeMessageRow = (row: typeof SessionMessageTable.$inferSelect) =>
decode(SessionMessage.persisted({ ...row.data, id: row.id, type: row.type })).pipe(
decode({ ...row.data, id: row.id, type: row.type }).pipe(
Effect.tap((message) =>
SessionProviderContext.isCheckpoint(message)
? SessionProviderContext.validate(message.providerContext)
+1 -1
View File
@@ -125,7 +125,7 @@ const promotedFromMessage = Effect.fn("SessionInbox.promotedFromMessage")(functi
if (row === undefined) return undefined
if (row.session_id !== sessionID || (row.type !== "user" && row.type !== "synthetic"))
return yield* new LifecycleConflict({ id })
const message = decodeMessage(SessionMessage.persisted({ ...row.data, id: row.id, type: row.type }))
const message = decodeMessage({ ...row.data, id: row.id, type: row.type })
const base = { id, sessionID, time: { created: message.time.created }, delivery }
if (message.type === "user")
return User.make({
+8 -8
View File
@@ -222,7 +222,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
draft.error = undefined
draft.finish = undefined
draft.rawFinish = undefined
draft.native = undefined
draft.providerState = undefined
draft.time.created = DateTime.makeUnsafe(event.data.started)
draft.time.streamed = undefined
draft.time.completed = undefined
@@ -263,7 +263,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
draft.time.completed = created
draft.finish = event.data.finish
draft.rawFinish = event.data.rawFinish
draft.native = castDraft(event.data.providerState)
draft.providerState = castDraft(event.data.providerState)
draft.cost = event.data.cost
draft.tokens = event.data.tokens
projectTerminalSnapshot(draft, event)
@@ -274,7 +274,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
draft.time.completed = created
draft.finish = event.data.finish ?? "error"
draft.rawFinish = event.data.rawFinish
draft.native = castDraft(event.data.providerState)
draft.providerState = castDraft(event.data.providerState)
draft.error = castDraft(event.data.error)
draft.retry = undefined
if (event.data.cost !== undefined && event.data.tokens !== undefined) {
@@ -294,7 +294,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
const match = latestText(draft)
if (match) {
match.text = event.data.text
match.native = castDraft(event.data.state)
match.state = castDraft(event.data.state)
}
})
},
@@ -382,7 +382,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
SessionMessage.AssistantReasoning.make({
type: "reasoning",
text: "",
native: event.data.state,
state: event.data.state,
time: { created },
}),
),
@@ -395,7 +395,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
if (match) {
match.text = event.data.text
match.time = { created: match.time?.created ?? created, completed: created }
if (event.data.state !== undefined) match.native = event.data.state
if (event.data.state !== undefined) match.state = event.data.state
}
})
},
@@ -431,7 +431,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
metadata: event.metadata ? { ...current.metadata, ...event.metadata } : current.metadata,
reason: event.data.reason,
model: event.data.model,
native: event.data.providerState,
providerState: event.data.providerState,
summary: event.data.text,
providerContext: event.data.providerContext,
recent: event.data.recent,
@@ -448,7 +448,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
metadata: event.metadata,
reason: event.data.reason,
model: event.data.model,
native: event.data.providerState,
providerState: event.data.providerState,
summary: event.data.text,
providerContext: event.data.providerContext,
recent: event.data.recent,
+1 -1
View File
@@ -228,7 +228,7 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
function run(db: DatabaseService, event: MessageEvent) {
return Effect.gen(function* () {
const decodeRow = (row: typeof SessionMessageTable.$inferSelect) =>
decodeMessage(SessionMessage.persisted({ ...row.data, id: row.id, type: row.type }))
decodeMessage({ ...row.data, id: row.id, type: row.type })
const updateMessage = (message: SessionMessage.Info) => {
const encoded = encodeMessage(message)
const { id, type, ...data } = encoded
+1 -3
View File
@@ -107,9 +107,7 @@ const plan = Effect.fn("SessionRevert.plan")(function* (db: Database.Interface["
const decode = Schema.decodeUnknownEffect(SessionMessage.Info)
const files = new Map<RelativePath, Snapshot.ID>()
for (const row of rows) {
const message = yield* decode(SessionMessage.persisted({ ...row.data, id: row.id, type: row.type })).pipe(
Effect.orDie,
)
const message = yield* decode({ ...row.data, id: row.id, type: row.type }).pipe(Effect.orDie)
if (message.type !== "assistant" || !message.snapshot?.start) continue
for (const file of message.snapshot.files ?? [])
if (!files.has(file)) files.set(file, Snapshot.ID.make(message.snapshot.start))
@@ -162,7 +162,7 @@ const assistant = (message: SessionMessage.Assistant, model: Model.Ref, provider
text: item.text,
// Text can carry provider-bound state (e.g. Gemini thought signatures),
// which is only replayable against the model that produced it.
providerMetadata: reuseProviderMetadata ? providerMetadata(providerMetadataKey, item.native) : undefined,
providerMetadata: reuseProviderMetadata ? providerMetadata(providerMetadataKey, item.state) : undefined,
},
]
// Let the destination adapter handle readable reasoning after a model/provider switch.
@@ -172,7 +172,7 @@ const assistant = (message: SessionMessage.Assistant, model: Model.Ref, provider
{
type: "reasoning",
text: item.text,
providerMetadata: providerMetadata(providerMetadataKey, item.native),
providerMetadata: providerMetadata(providerMetadataKey, item.state),
},
]
: item.text.length > 0

Some files were not shown because too many files have changed in this diff Show More