Merge remote-tracking branch 'upstream/main' into pr-898

# Conflicts:
#	packages/ai/CHANGELOG.md
This commit is contained in:
Ogrodev
2026-05-01 12:25:53 -03:00
39 changed files with 873 additions and 648 deletions
+1
View File
@@ -65,3 +65,4 @@ scripts/session-stats/edit-analysis.csv
# parallel-agent worktrees
.wt/
CPU*.md
Generated
+1 -1
View File
@@ -2162,7 +2162,7 @@ dependencies = [
[[package]]
name = "pi-natives"
version = "14.5.12"
version = "14.5.13"
dependencies = [
"anyhow",
"arboard",
+1 -1
View File
@@ -4,7 +4,7 @@ exclude = ["crates/brush-core-vendored", "crates/brush-builtins-vendored"]
resolver = "3"
[workspace.package]
version = "14.5.12"
version = "14.5.13"
edition = "2024"
license = "MIT"
authors = ["Can Boluk"]
+25 -23
View File
@@ -16,7 +16,7 @@
},
"packages/agent": {
"name": "@oh-my-pi/pi-agent-core",
"version": "14.5.12",
"version": "14.5.13",
"dependencies": {
"@oh-my-pi/pi-ai": "catalog:",
"@oh-my-pi/pi-natives": "catalog:",
@@ -29,7 +29,7 @@
},
"packages/ai": {
"name": "@oh-my-pi/pi-ai",
"version": "14.5.12",
"version": "14.5.13",
"bin": {
"pi-ai": "./src/cli.ts",
},
@@ -56,7 +56,7 @@
},
"packages/coding-agent": {
"name": "@oh-my-pi/pi-coding-agent",
"version": "14.5.12",
"version": "14.5.13",
"bin": {
"omp": "src/cli.ts",
},
@@ -92,7 +92,7 @@
},
"packages/natives": {
"name": "@oh-my-pi/pi-natives",
"version": "14.5.12",
"version": "14.5.13",
"devDependencies": {
"@napi-rs/cli": "catalog:",
"@types/bun": "catalog:",
@@ -100,7 +100,7 @@
},
"packages/stats": {
"name": "@oh-my-pi/omp-stats",
"version": "14.5.12",
"version": "14.5.13",
"bin": {
"omp-stats": "./src/index.ts",
},
@@ -125,7 +125,7 @@
},
"packages/swarm-extension": {
"name": "@oh-my-pi/swarm-extension",
"version": "14.5.12",
"version": "14.5.13",
"bin": {
"omp-swarm": "src/cli.ts",
},
@@ -141,7 +141,7 @@
},
"packages/tui": {
"name": "@oh-my-pi/pi-tui",
"version": "14.5.12",
"version": "14.5.13",
"dependencies": {
"@oh-my-pi/pi-natives": "catalog:",
"@oh-my-pi/pi-utils": "catalog:",
@@ -181,7 +181,7 @@
},
"packages/utils": {
"name": "@oh-my-pi/pi-utils",
"version": "14.5.12",
"version": "14.5.13",
"dependencies": {
"beautiful-mermaid": "catalog:",
"handlebars": "catalog:",
@@ -212,13 +212,13 @@
"@google/genai": "^1.50.1",
"@mozilla/readability": "^0.6.0",
"@napi-rs/cli": "3.6.2",
"@oh-my-pi/omp-stats": "14.5.12",
"@oh-my-pi/pi-agent-core": "14.5.12",
"@oh-my-pi/pi-ai": "14.5.12",
"@oh-my-pi/pi-coding-agent": "14.5.12",
"@oh-my-pi/pi-natives": "14.5.12",
"@oh-my-pi/pi-tui": "14.5.12",
"@oh-my-pi/pi-utils": "14.5.12",
"@oh-my-pi/omp-stats": "14.5.13",
"@oh-my-pi/pi-agent-core": "14.5.13",
"@oh-my-pi/pi-ai": "14.5.13",
"@oh-my-pi/pi-coding-agent": "14.5.13",
"@oh-my-pi/pi-natives": "14.5.13",
"@oh-my-pi/pi-tui": "14.5.13",
"@oh-my-pi/pi-utils": "14.5.13",
"@puppeteer/browsers": "^2.13.0",
"@sinclair/typebox": "^0.34.49",
"@smithy/node-http-handler": "^4.6.1",
@@ -279,7 +279,7 @@
"@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="],
"@aws-sdk/client-bedrock-runtime": ["@aws-sdk/client-bedrock-runtime@3.1039.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.7", "@aws-sdk/credential-provider-node": "^3.972.38", "@aws-sdk/eventstream-handler-node": "^3.972.14", "@aws-sdk/middleware-eventstream": "^3.972.10", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", "@aws-sdk/middleware-user-agent": "^3.972.37", "@aws-sdk/middleware-websocket": "^3.972.16", "@aws-sdk/region-config-resolver": "^3.972.13", "@aws-sdk/token-providers": "3.1039.0", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-endpoints": "^3.996.8", "@aws-sdk/util-user-agent-browser": "^3.972.10", "@aws-sdk/util-user-agent-node": "^3.973.23", "@smithy/config-resolver": "^4.4.17", "@smithy/core": "^3.23.17", "@smithy/eventstream-serde-browser": "^4.2.14", "@smithy/eventstream-serde-config-resolver": "^4.3.14", "@smithy/eventstream-serde-node": "^4.2.14", "@smithy/fetch-http-handler": "^5.3.17", "@smithy/hash-node": "^4.2.14", "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", "@smithy/middleware-endpoint": "^4.4.32", "@smithy/middleware-retry": "^4.5.7", "@smithy/middleware-serde": "^4.2.20", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", "@smithy/node-http-handler": "^4.6.1", "@smithy/protocol-http": "^5.3.14", "@smithy/smithy-client": "^4.12.13", "@smithy/types": "^4.14.1", "@smithy/url-parser": "^4.2.14", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.49", "@smithy/util-defaults-mode-node": "^4.2.54", "@smithy/util-endpoints": "^3.4.2", "@smithy/util-middleware": "^4.2.14", "@smithy/util-retry": "^4.3.6", "@smithy/util-stream": "^4.5.25", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-rpm9rGcv95ulprNIu/ruhreG4bSKq7oFrErM1Nkp9Cq/zzo/11Hw1/ffYKLM/PAcMGZ+5/zAHOCWBDQ3W1lIBw=="],
"@aws-sdk/client-bedrock-runtime": ["@aws-sdk/client-bedrock-runtime@3.1040.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.7", "@aws-sdk/credential-provider-node": "^3.972.38", "@aws-sdk/eventstream-handler-node": "^3.972.14", "@aws-sdk/middleware-eventstream": "^3.972.10", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", "@aws-sdk/middleware-user-agent": "^3.972.37", "@aws-sdk/middleware-websocket": "^3.972.16", "@aws-sdk/region-config-resolver": "^3.972.13", "@aws-sdk/token-providers": "3.1040.0", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-endpoints": "^3.996.8", "@aws-sdk/util-user-agent-browser": "^3.972.10", "@aws-sdk/util-user-agent-node": "^3.973.23", "@smithy/config-resolver": "^4.4.17", "@smithy/core": "^3.23.17", "@smithy/eventstream-serde-browser": "^4.2.14", "@smithy/eventstream-serde-config-resolver": "^4.3.14", "@smithy/eventstream-serde-node": "^4.2.14", "@smithy/fetch-http-handler": "^5.3.17", "@smithy/hash-node": "^4.2.14", "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", "@smithy/middleware-endpoint": "^4.4.32", "@smithy/middleware-retry": "^4.5.7", "@smithy/middleware-serde": "^4.2.20", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", "@smithy/node-http-handler": "^4.6.1", "@smithy/protocol-http": "^5.3.14", "@smithy/smithy-client": "^4.12.13", "@smithy/types": "^4.14.1", "@smithy/url-parser": "^4.2.14", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.49", "@smithy/util-defaults-mode-node": "^4.2.54", "@smithy/util-endpoints": "^3.4.2", "@smithy/util-middleware": "^4.2.14", "@smithy/util-retry": "^4.3.6", "@smithy/util-stream": "^4.5.25", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-tFCqtci1gVGIRwgK3tmv2DV2EawXjBIQgwM/7KaeL4wHUMhNMUA+POUw6vGowtQb51ZaSDjK3KzI3MaQskOyuw=="],
"@aws-sdk/core": ["@aws-sdk/core@3.974.7", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@aws-sdk/xml-builder": "^3.972.22", "@smithy/core": "^3.23.17", "@smithy/node-config-provider": "^4.3.14", "@smithy/property-provider": "^4.2.14", "@smithy/protocol-http": "^5.3.14", "@smithy/signature-v4": "^5.3.14", "@smithy/smithy-client": "^4.12.13", "@smithy/types": "^4.14.1", "@smithy/util-base64": "^4.3.2", "@smithy/util-middleware": "^4.2.14", "@smithy/util-retry": "^4.3.6", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-YhRC90ofz5oolTJZlA8voU/oUrCB2azi8Usx51k8hhB5LpWbYQMMXKUqSqkoL0Cru+RQJgWTHpAfEDDIwfUhJw=="],
@@ -321,7 +321,7 @@
"@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.24", "", { "dependencies": { "@aws-sdk/middleware-sdk-s3": "^3.972.36", "@aws-sdk/types": "^3.973.8", "@smithy/protocol-http": "^5.3.14", "@smithy/signature-v4": "^5.3.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-amP7tLikppN940wbBFISYqiuzVmpzMS9U3mcgtmVLjX4fdWI/SNCvrXv6ZxfVzTT4cT0rPKOLhFah2xLwzREWw=="],
"@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1039.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.7", "@aws-sdk/nested-clients": "^3.997.5", "@aws-sdk/types": "^3.973.8", "@smithy/property-provider": "^4.2.14", "@smithy/shared-ini-file-loader": "^4.4.9", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-NMSFL2HwkAOoCeLCQiqoOq5pT3vVbSjww2QZTuYgYknVwhhv125PSDzZIcL5EYnlxuPWjEOdauZK+FspkZDVdw=="],
"@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1040.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.7", "@aws-sdk/nested-clients": "^3.997.5", "@aws-sdk/types": "^3.973.8", "@smithy/property-provider": "^4.2.14", "@smithy/shared-ini-file-loader": "^4.4.9", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-0KTpz2KqASQwzLOywV1bS2TX6Su0bARkATgpSu236BDM/D/6cMQ2EPiFwoRYwwvXsWSDn8KkKp9NV2ZWWA53Xw=="],
"@aws-sdk/types": ["@aws-sdk/types@3.973.8", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw=="],
@@ -351,7 +351,7 @@
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
"@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="],
"@babel/parser": ["@babel/parser@7.29.3", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA=="],
"@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="],
@@ -779,13 +779,13 @@
"async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="],
"b4a": ["b4a@1.8.0", "", { "peerDependencies": { "react-native-b4a": "*" }, "optionalPeers": ["react-native-b4a"] }, "sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg=="],
"b4a": ["b4a@1.8.1", "", { "peerDependencies": { "react-native-b4a": "*" }, "optionalPeers": ["react-native-b4a"] }, "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw=="],
"bare-events": ["bare-events@2.8.2", "", { "peerDependencies": { "bare-abort-controller": "*" }, "optionalPeers": ["bare-abort-controller"] }, "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ=="],
"bare-fs": ["bare-fs@4.7.1", "", { "dependencies": { "bare-events": "^2.5.4", "bare-path": "^3.0.0", "bare-stream": "^2.6.4", "bare-url": "^2.2.2", "fast-fifo": "^1.3.2" }, "peerDependencies": { "bare-buffer": "*" }, "optionalPeers": ["bare-buffer"] }, "sha512-WDRsyVN52eAx/lBamKD6uyw8H4228h/x0sGGGegOamM2cd7Pag88GfMQalobXI+HaEUxpCkbKQUDOQqt9wawRw=="],
"bare-os": ["bare-os@3.9.0", "", {}, "sha512-JTjuZyNIDpw+GytMO4a6TK1VXdVKKJr6DRxEHasyuYyShV2deuiHJK/ahGZlebc+SG0/wJCB9XK8gprBGDFi/Q=="],
"bare-os": ["bare-os@3.9.1", "", {}, "sha512-6M5XjcnsygQNPMCMPXSK379xrJFiZ/AEMNBmFEmQW8d/789VQATvriyi5r0HYTL9TkQ26rn3kgdTG3aisbrXkQ=="],
"bare-path": ["bare-path@3.0.0", "", { "dependencies": { "bare-os": "^3.0.1" } }, "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw=="],
@@ -993,7 +993,7 @@
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"ip-address": ["ip-address@10.1.1", "", {}, "sha512-1FMu8/N15Ck1BL551Jf42NYIoin2unWjLQ2Fze/DXryJRl5twqtwNHlO39qERGbIOcKYWHdgRryhOC+NG4eaLw=="],
"ip-address": ["ip-address@10.2.0", "", {}, "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA=="],
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
@@ -1095,7 +1095,7 @@
"mute-stream": ["mute-stream@3.0.0", "", {}, "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="],
"neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="],
@@ -1141,7 +1141,7 @@
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
"postcss": ["postcss@8.5.12", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA=="],
"postcss": ["postcss@8.5.13", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag=="],
"prettier": ["prettier@3.8.3", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw=="],
@@ -1317,6 +1317,8 @@
"@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
"@aws-sdk/credential-provider-sso/@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1039.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.7", "@aws-sdk/nested-clients": "^3.997.5", "@aws-sdk/types": "^3.973.8", "@smithy/property-provider": "^4.2.14", "@smithy/shared-ini-file-loader": "^4.4.9", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-NMSFL2HwkAOoCeLCQiqoOq5pT3vVbSjww2QZTuYgYknVwhhv125PSDzZIcL5EYnlxuPWjEOdauZK+FspkZDVdw=="],
"@bufbuild/protoplugin/typescript": ["typescript@5.4.5", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ=="],
"@puppeteer/browsers/proxy-agent": ["proxy-agent@6.5.0", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "http-proxy-agent": "^7.0.1", "https-proxy-agent": "^7.0.6", "lru-cache": "^7.14.1", "pac-proxy-agent": "^7.1.0", "proxy-from-env": "^1.1.0", "socks-proxy-agent": "^8.0.5" } }, "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A=="],
+7 -7
View File
@@ -22,13 +22,13 @@
"@google/genai": "^1.50.1",
"@mozilla/readability": "^0.6.0",
"@napi-rs/cli": "3.6.2",
"@oh-my-pi/omp-stats": "14.5.12",
"@oh-my-pi/pi-agent-core": "14.5.12",
"@oh-my-pi/pi-ai": "14.5.12",
"@oh-my-pi/pi-coding-agent": "14.5.12",
"@oh-my-pi/pi-natives": "14.5.12",
"@oh-my-pi/pi-tui": "14.5.12",
"@oh-my-pi/pi-utils": "14.5.12",
"@oh-my-pi/omp-stats": "14.5.13",
"@oh-my-pi/pi-agent-core": "14.5.13",
"@oh-my-pi/pi-ai": "14.5.13",
"@oh-my-pi/pi-coding-agent": "14.5.13",
"@oh-my-pi/pi-natives": "14.5.13",
"@oh-my-pi/pi-tui": "14.5.13",
"@oh-my-pi/pi-utils": "14.5.13",
"@puppeteer/browsers": "^2.13.0",
"@sinclair/typebox": "^0.34.49",
"@smithy/node-http-handler": "^4.6.1",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"type": "module",
"name": "@oh-my-pi/pi-agent-core",
"version": "14.5.12",
"version": "14.5.13",
"description": "General-purpose agent with transport abstraction, state management, and attachment support",
"homepage": "https://github.com/can1357/oh-my-pi",
"author": "Can Boluk",
+6 -4
View File
@@ -2,14 +2,16 @@
## [Unreleased]
### Breaking Changes
- Removed `utils/oauth` re-exports from the package entrypoint, so OAuth helper imports from the root module must be updated
### Fixed
- Fixed Vercel AI Gateway dynamic model discovery to use the versioned `/v1/models` catalog endpoint, restoring newly published models.
## [14.5.13] - 2026-05-01
### Breaking Changes
- Removed `utils/oauth` re-exports from the package entrypoint, so OAuth helper imports from the root module must be updated
## [14.5.10] - 2026-04-30
### Added
+1 -1
View File
@@ -1,7 +1,7 @@
{
"type": "module",
"name": "@oh-my-pi/pi-ai",
"version": "14.5.12",
"version": "14.5.13",
"description": "Unified LLM API with automatic model discovery and provider configuration",
"homepage": "https://github.com/can1357/oh-my-pi",
"author": "Can Boluk",
+489 -6
View File
@@ -16058,6 +16058,25 @@
"contextWindow": 222222,
"maxTokens": 8888
},
"x-ai/grok-4.3": {
"id": "x-ai/grok-4.3",
"name": "xAI: Grok 4.3",
"api": "openai-completions",
"provider": "kilo",
"baseUrl": "https://api.kilo.ai/api/gateway",
"reasoning": false,
"input": [
"text"
],
"cost": {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0
},
"contextWindow": 222222,
"maxTokens": 8888
},
"x-ai/grok-code-fast-1": {
"id": "x-ai/grok-code-fast-1",
"name": "Grok Code Fast 1",
@@ -24027,6 +24046,25 @@
"contextWindow": 222222,
"maxTokens": 8888
},
"ibm-granite/granite-4.1-8b": {
"id": "ibm-granite/granite-4.1-8b",
"name": "ibm-granite/granite-4.1-8b",
"api": "openai-completions",
"provider": "nanogpt",
"baseUrl": "https://nano-gpt.com/api/v1",
"reasoning": false,
"input": [
"text"
],
"cost": {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0
},
"contextWindow": 222222,
"maxTokens": 8888
},
"inclusionai/ling-2.6-1t": {
"id": "inclusionai/ling-2.6-1t",
"name": "inclusionai/ling-2.6-1t",
@@ -25827,7 +25865,7 @@
"api": "openai-completions",
"provider": "nanogpt",
"baseUrl": "https://nano-gpt.com/api/v1",
"reasoning": false,
"reasoning": true,
"input": [
"text"
],
@@ -25838,7 +25876,12 @@
"cacheWrite": 0
},
"contextWindow": 222222,
"maxTokens": 8888
"maxTokens": 8888,
"thinking": {
"mode": "effort",
"minLevel": "minimal",
"maxLevel": "xhigh"
}
},
"mistral/mistral-vibe-cli-latest": {
"id": "mistral/mistral-vibe-cli-latest",
@@ -27948,6 +27991,25 @@
"maxLevel": "xhigh"
}
},
"owl": {
"id": "owl",
"name": "owl",
"api": "openai-completions",
"provider": "nanogpt",
"baseUrl": "https://nano-gpt.com/api/v1",
"reasoning": false,
"input": [
"text"
],
"cost": {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0
},
"contextWindow": 222222,
"maxTokens": 8888
},
"pamanseau/OpenReasoning-Nemotron-32B": {
"id": "pamanseau/OpenReasoning-Nemotron-32B",
"name": "pamanseau/OpenReasoning-Nemotron-32B",
@@ -28885,6 +28947,25 @@
"maxLevel": "high"
}
},
"Qwen3.5-27B-Anko": {
"id": "Qwen3.5-27B-Anko",
"name": "Qwen3.5-27B-Anko",
"api": "openai-completions",
"provider": "nanogpt",
"baseUrl": "https://nano-gpt.com/api/v1",
"reasoning": false,
"input": [
"text"
],
"cost": {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0
},
"contextWindow": 222222,
"maxTokens": 8888
},
"Qwen3.5-27B-BlueStar-Derestricted": {
"id": "Qwen3.5-27B-BlueStar-Derestricted",
"name": "Qwen3.5-27B-BlueStar-Derestricted",
@@ -28961,6 +29042,44 @@
"contextWindow": 222222,
"maxTokens": 8888
},
"Qwen3.5-27B-BlueStar-v3-Derestricted": {
"id": "Qwen3.5-27B-BlueStar-v3-Derestricted",
"name": "Qwen3.5-27B-BlueStar-v3-Derestricted",
"api": "openai-completions",
"provider": "nanogpt",
"baseUrl": "https://nano-gpt.com/api/v1",
"reasoning": false,
"input": [
"text"
],
"cost": {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0
},
"contextWindow": 222222,
"maxTokens": 8888
},
"Qwen3.5-27B-BlueStar-v3-Derestricted-Lite": {
"id": "Qwen3.5-27B-BlueStar-v3-Derestricted-Lite",
"name": "Qwen3.5-27B-BlueStar-v3-Derestricted-Lite",
"api": "openai-completions",
"provider": "nanogpt",
"baseUrl": "https://nano-gpt.com/api/v1",
"reasoning": false,
"input": [
"text"
],
"cost": {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0
},
"contextWindow": 222222,
"maxTokens": 8888
},
"Qwen3.5-27B-Derestricted": {
"id": "Qwen3.5-27B-Derestricted",
"name": "Qwen3.5-27B-Derestricted",
@@ -28980,6 +29099,139 @@
"contextWindow": 222222,
"maxTokens": 8888
},
"Qwen3.5-27B-earica-Derestricted": {
"id": "Qwen3.5-27B-earica-Derestricted",
"name": "Qwen3.5-27B-earica-Derestricted",
"api": "openai-completions",
"provider": "nanogpt",
"baseUrl": "https://nano-gpt.com/api/v1",
"reasoning": false,
"input": [
"text"
],
"cost": {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0
},
"contextWindow": 222222,
"maxTokens": 8888
},
"Qwen3.5-27B-earica-Derestricted-Lite": {
"id": "Qwen3.5-27B-earica-Derestricted-Lite",
"name": "Qwen3.5-27B-earica-Derestricted-Lite",
"api": "openai-completions",
"provider": "nanogpt",
"baseUrl": "https://nano-gpt.com/api/v1",
"reasoning": false,
"input": [
"text"
],
"cost": {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0
},
"contextWindow": 222222,
"maxTokens": 8888
},
"Qwen3.5-27B-Infracelestial": {
"id": "Qwen3.5-27B-Infracelestial",
"name": "Qwen3.5-27B-Infracelestial",
"api": "openai-completions",
"provider": "nanogpt",
"baseUrl": "https://nano-gpt.com/api/v1",
"reasoning": false,
"input": [
"text"
],
"cost": {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0
},
"contextWindow": 222222,
"maxTokens": 8888
},
"Qwen3.5-27B-Marvin-DPO-V2-Derestricted": {
"id": "Qwen3.5-27B-Marvin-DPO-V2-Derestricted",
"name": "Qwen3.5-27B-Marvin-DPO-V2-Derestricted",
"api": "openai-completions",
"provider": "nanogpt",
"baseUrl": "https://nano-gpt.com/api/v1",
"reasoning": false,
"input": [
"text"
],
"cost": {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0
},
"contextWindow": 222222,
"maxTokens": 8888
},
"Qwen3.5-27B-Marvin-DPO-V2-Derestricted-Lite": {
"id": "Qwen3.5-27B-Marvin-DPO-V2-Derestricted-Lite",
"name": "Qwen3.5-27B-Marvin-DPO-V2-Derestricted-Lite",
"api": "openai-completions",
"provider": "nanogpt",
"baseUrl": "https://nano-gpt.com/api/v1",
"reasoning": false,
"input": [
"text"
],
"cost": {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0
},
"contextWindow": 222222,
"maxTokens": 8888
},
"Qwen3.5-27B-Marvin-V2-Derestricted": {
"id": "Qwen3.5-27B-Marvin-V2-Derestricted",
"name": "Qwen3.5-27B-Marvin-V2-Derestricted",
"api": "openai-completions",
"provider": "nanogpt",
"baseUrl": "https://nano-gpt.com/api/v1",
"reasoning": false,
"input": [
"text"
],
"cost": {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0
},
"contextWindow": 222222,
"maxTokens": 8888
},
"Qwen3.5-27B-Marvin-V2-Derestricted-Lite": {
"id": "Qwen3.5-27B-Marvin-V2-Derestricted-Lite",
"name": "Qwen3.5-27B-Marvin-V2-Derestricted-Lite",
"api": "openai-completions",
"provider": "nanogpt",
"baseUrl": "https://nano-gpt.com/api/v1",
"reasoning": false,
"input": [
"text"
],
"cost": {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0
},
"contextWindow": 222222,
"maxTokens": 8888
},
"Qwen3.5-27B-Musica-v1": {
"id": "Qwen3.5-27B-Musica-v1",
"name": "Qwen3.5-27B-Musica-v1",
@@ -28999,6 +29251,44 @@
"contextWindow": 222222,
"maxTokens": 8888
},
"Qwen3.5-27B-NaNovel-Derestricted": {
"id": "Qwen3.5-27B-NaNovel-Derestricted",
"name": "Qwen3.5-27B-NaNovel-Derestricted",
"api": "openai-completions",
"provider": "nanogpt",
"baseUrl": "https://nano-gpt.com/api/v1",
"reasoning": false,
"input": [
"text"
],
"cost": {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0
},
"contextWindow": 222222,
"maxTokens": 8888
},
"Qwen3.5-27B-NaNovel-Derestricted-Lite": {
"id": "Qwen3.5-27B-NaNovel-Derestricted-Lite",
"name": "Qwen3.5-27B-NaNovel-Derestricted-Lite",
"api": "openai-completions",
"provider": "nanogpt",
"baseUrl": "https://nano-gpt.com/api/v1",
"reasoning": false,
"input": [
"text"
],
"cost": {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0
},
"contextWindow": 222222,
"maxTokens": 8888
},
"Qwen3.5-27B-Omega-Evolution-v2.0-Derestricted": {
"id": "Qwen3.5-27B-Omega-Evolution-v2.0-Derestricted",
"name": "Qwen3.5-27B-Omega-Evolution-v2.0-Derestricted",
@@ -29037,6 +29327,63 @@
"contextWindow": 222222,
"maxTokens": 8888
},
"Qwen3.5-27B-Queen-Derestricted": {
"id": "Qwen3.5-27B-Queen-Derestricted",
"name": "Qwen3.5-27B-Queen-Derestricted",
"api": "openai-completions",
"provider": "nanogpt",
"baseUrl": "https://nano-gpt.com/api/v1",
"reasoning": false,
"input": [
"text"
],
"cost": {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0
},
"contextWindow": 222222,
"maxTokens": 8888
},
"Qwen3.5-27B-Queen-Derestricted-Lite": {
"id": "Qwen3.5-27B-Queen-Derestricted-Lite",
"name": "Qwen3.5-27B-Queen-Derestricted-Lite",
"api": "openai-completions",
"provider": "nanogpt",
"baseUrl": "https://nano-gpt.com/api/v1",
"reasoning": false,
"input": [
"text"
],
"cost": {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0
},
"contextWindow": 222222,
"maxTokens": 8888
},
"Qwen3.5-27B-RpRMax-v1": {
"id": "Qwen3.5-27B-RpRMax-v1",
"name": "Qwen3.5-27B-RpRMax-v1",
"api": "openai-completions",
"provider": "nanogpt",
"baseUrl": "https://nano-gpt.com/api/v1",
"reasoning": false,
"input": [
"text"
],
"cost": {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0
},
"contextWindow": 222222,
"maxTokens": 8888
},
"Qwen3.5-27B-Vivid-Durian": {
"id": "Qwen3.5-27B-Vivid-Durian",
"name": "Qwen3.5-27B-Vivid-Durian",
@@ -31292,6 +31639,25 @@
"contextWindow": 222222,
"maxTokens": 8888
},
"x-ai/grok-4.3": {
"id": "x-ai/grok-4.3",
"name": "x-ai/grok-4.3",
"api": "openai-completions",
"provider": "nanogpt",
"baseUrl": "https://nano-gpt.com/api/v1",
"reasoning": false,
"input": [
"text"
],
"cost": {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0
},
"contextWindow": 222222,
"maxTokens": 8888
},
"x-ai/grok-code-fast-1": {
"id": "x-ai/grok-code-fast-1",
"name": "Grok Code Fast 1",
@@ -34863,6 +35229,7 @@
},
"contextWindow": 272000,
"maxTokens": 128000,
"preferWebsockets": true,
"priority": 6,
"thinking": {
"mode": "effort",
@@ -42087,9 +42454,9 @@
"text"
],
"cost": {
"input": 0.14,
"input": 0.12,
"output": 0.7999999999999999,
"cacheRead": 0.09,
"cacheRead": 0.07,
"cacheWrite": 0
},
"contextWindow": 262144,
@@ -43265,8 +43632,8 @@
"image"
],
"cost": {
"input": 2,
"output": 6,
"input": 1.25,
"output": 2.5,
"cacheRead": 0.19999999999999998,
"cacheWrite": 0
},
@@ -43303,6 +43670,31 @@
"maxLevel": "high"
}
},
"x-ai/grok-4.3": {
"id": "x-ai/grok-4.3",
"name": "xAI: Grok 4.3",
"api": "openai-completions",
"provider": "openrouter",
"baseUrl": "https://openrouter.ai/api/v1",
"reasoning": true,
"input": [
"text",
"image"
],
"cost": {
"input": 1.25,
"output": 2.5,
"cacheRead": 0.19999999999999998,
"cacheWrite": 0
},
"contextWindow": 1000000,
"maxTokens": 8888,
"thinking": {
"mode": "effort",
"minLevel": "minimal",
"maxLevel": "high"
}
},
"x-ai/grok-code-fast-1": {
"id": "x-ai/grok-code-fast-1",
"name": "Grok Code Fast 1",
@@ -44033,6 +44425,25 @@
"contextWindow": 262144,
"maxTokens": 8192
},
"hf:moonshotai/Kimi-K2.6": {
"id": "hf:moonshotai/Kimi-K2.6",
"name": "moonshotai/Kimi-K2.6",
"api": "openai-completions",
"provider": "synthetic",
"baseUrl": "https://api.synthetic.new/openai/v1",
"reasoning": false,
"input": [
"text"
],
"cost": {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0
},
"contextWindow": 262144,
"maxTokens": 8192
},
"hf:nvidia/Kimi-K2.5-NVFP4": {
"id": "hf:nvidia/Kimi-K2.5-NVFP4",
"name": "nvidia/Kimi-K2.5-NVFP4",
@@ -45320,6 +45731,28 @@
"supportsUsageInStreaming": false
}
},
"grok-4-3": {
"id": "grok-4-3",
"name": "grok-4-3",
"api": "openai-completions",
"provider": "venice",
"baseUrl": "https://api.venice.ai/api/v1",
"reasoning": false,
"input": [
"text"
],
"cost": {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0
},
"contextWindow": 1000000,
"maxTokens": 8888,
"compat": {
"supportsUsageInStreaming": false
}
},
"grok-41-fast": {
"id": "grok-41-fast",
"name": "Grok 4.1 Fast",
@@ -50028,6 +50461,31 @@
"maxLevel": "xhigh"
}
},
"xai/grok-4.3": {
"id": "xai/grok-4.3",
"name": "Grok 4.3",
"api": "anthropic-messages",
"provider": "vercel-ai-gateway",
"baseUrl": "https://ai-gateway.vercel.sh",
"reasoning": true,
"input": [
"text",
"image"
],
"cost": {
"input": 1.25,
"output": 2.5,
"cacheRead": 0.19999999999999998,
"cacheWrite": 0
},
"contextWindow": 1000000,
"maxTokens": 1000000,
"thinking": {
"mode": "budget",
"minLevel": "minimal",
"maxLevel": "xhigh"
}
},
"xai/grok-code-fast-1": {
"id": "xai/grok-code-fast-1",
"name": "Grok Code Fast 1",
@@ -50986,6 +51444,31 @@
"maxLevel": "high"
}
},
"grok-4.3": {
"id": "grok-4.3",
"name": "Grok 4.3",
"api": "openai-completions",
"provider": "xai",
"baseUrl": "https://api.x.ai/v1",
"reasoning": true,
"input": [
"text",
"image"
],
"cost": {
"input": 1.25,
"output": 2.5,
"cacheRead": 0.2,
"cacheWrite": 0
},
"contextWindow": 1000000,
"maxTokens": 30000,
"thinking": {
"mode": "effort",
"minLevel": "minimal",
"maxLevel": "high"
}
},
"grok-beta": {
"id": "grok-beta",
"name": "Grok Beta",
+10
View File
@@ -1,6 +1,9 @@
# Changelog
## [Unreleased]
## [14.5.13] - 2026-05-01
### Breaking Changes
- Removed the built-in `python` tool in favor of `eval`, so tool allowlists and tool-call handlers referencing `python` need to migrate
@@ -19,11 +22,18 @@
### Changed
- Changed AGENTS.md discovery to respect `.gitignore` files during project context collection so ignored context files are no longer loaded
- Changed eval tool initialization to skip Python kernel preflight when the JavaScript backend is enabled, avoiding unnecessary startup checks
- Changed model registry refresh flow to defer rebuilding the canonical model index until refresh operations complete, reducing refresh churn
- Changed execution/tool discovery flow so `exec` maps to `eval` when any `eval` backend is enabled, while `bash` stays independently available
- Changed `eval` dispatch to automatically fall back to JavaScript when Python is unavailable and JavaScript backend is enabled
- Parallelized plugin root preloading with other startup initialization in `runRootCommand` to reduce startup latency
- Parallelized session bootstrap work in `createAgentSession`, including AGENTS.md scanning, context discovery, prompt template loading, slash command loading, and skill discovery, to reduce time to first available session
### Fixed
- Fixed eval startup messaging to report `eval` as unavailable when Python is unreachable and JavaScript backend is disabled
## [14.5.12] - 2026-04-30
### Breaking Changes
+2 -2
View File
@@ -391,7 +391,7 @@ A `ToolFactory` is `(session: ToolSession) => Tool | null | Promise<Tool | null>
1. Normalizes requested tool names (`toolNames`) and always injects `exit_plan_mode`.
2. Resolves eval backend allowance via `PI_PY` override (`getEvalBackendsFromEnv()`) or `eval.py` / `eval.js` settings.
3. Performs Python kernel preflight/warmup when applicable (`checkPythonKernelAvailability`, `warmPythonEnvironment`).
3. Performs Python kernel preflight when applicable (`checkPythonKernelAvailability`).
4. Computes effective gating (`isToolAllowed`) from settings and runtime state:
- feature toggles (`find.enabled`, `grep.enabled`, etc.)
- recursion guard for `task` (`task.maxRecursionDepth` vs `session.taskDepth`)
@@ -777,7 +777,7 @@ This subsystem is split into two layers:
- Entry points:
- `executePython(code, options)`
- `executePythonWithKernel(kernel, code, options)`
- warmup/session utilities (`warmPythonEnvironment`, `disposeAllKernelSessions`).
- session utilities (`disposeAllKernelSessions`, `disposeKernelSessionsByOwner`).
- Manages kernel session lifecycle in `kernelSessions: Map<string, KernelSession>` with:
- bounded session count (`MAX_KERNEL_SESSIONS`), LRU eviction (`evictOldestSession`)
- idle cleanup timer (`cleanupIdleSessions`)
+1 -1
View File
@@ -1,7 +1,7 @@
{
"type": "module",
"name": "@oh-my-pi/pi-coding-agent",
"version": "14.5.12",
"version": "14.5.13",
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
"homepage": "https://github.com/can1357/oh-my-pi",
"author": "Can Boluk",
+9
View File
@@ -175,6 +175,15 @@ export class ConfigFile<T> implements IConfigFile<T> {
return result;
}
getMtimeMs(): number | null {
try {
return fs.statSync(this.path()).mtimeMs;
} catch (err) {
if (isEnoent(err)) return null;
throw err;
}
}
withValidation(name: string, validate: (value: T) => void): this {
const prev = this.#auxValidate;
this.#auxValidate = (value: T) => {
@@ -61,7 +61,25 @@ const TRAILING_CANONICAL_MARKERS = [
"int8",
"int4",
] as const;
const TRAILING_MARKER_SUFFIXES: readonly string[] = (() => {
const suffixes: string[] = [];
for (const marker of TRAILING_CANONICAL_MARKERS) {
const lower = marker.toLowerCase();
suffixes.push(`-${lower}`, `:${lower}`);
}
return suffixes;
})();
const WRAPPER_PREFIXES = ["duo-chat-"] as const;
let __referenceDataCache: CanonicalReferenceData | undefined;
const EMPTY_COMPILED_EQUIVALENCE: CompiledEquivalenceConfig = {
overrides: new Map<string, string>(),
exclude: new Set<string>(),
};
const __resolutionCache: WeakMap<
CompiledEquivalenceConfig,
WeakMap<Model<Api>, ResolvedCanonicalModel>
> = new WeakMap();
const FAMILY_EXTRACTION_PATTERNS = [
/(?:^|[/:._-])((?:claude|gemini|gpt|grok|glm|qwen|minimax|kimi|deepseek|llama|gemma|nova|mistral|ministral|pixtral|codestral|devstral|magistral|ernie|doubao|seed|aion|olmo|molmo|nemotron|palmyra|command|codex|coder|o[1345])[-a-z0-9.]+)(?::|$)/i,
/(?:^|[/:._-])((?:claude|gemini|gpt|grok|glm|qwen|minimax|kimi|deepseek|llama|gemma|nova|mistral|ministral|pixtral|codestral|devstral|magistral|ernie|doubao|seed|aion|olmo|molmo|nemotron|palmyra|command|codex|coder|o[1345])[-a-z0-9.]+(?:[-_/][a-z0-9.]+)*)(?::|$)/i,
@@ -79,6 +97,9 @@ function shouldReplaceReference(existing: Model<Api> | undefined, candidate: Mod
}
function createCanonicalReferenceData(): CanonicalReferenceData {
if (__referenceDataCache) {
return __referenceDataCache;
}
const references = new Map<string, Model<Api>>();
for (const provider of getBundledProviders()) {
for (const model of getBundledModels(provider as Parameters<typeof getBundledModels>[0])) {
@@ -89,10 +110,12 @@ function createCanonicalReferenceData(): CanonicalReferenceData {
}
}
}
return {
references,
officialIds: new Set(references.keys()),
const officialIds = new Set(references.keys());
__referenceDataCache = {
references: Object.freeze(references) as Map<string, Model<Api>>,
officialIds: Object.freeze(officialIds) as Set<string>,
};
return __referenceDataCache;
}
function normalizeSelectorKey(selector: string): string {
@@ -135,10 +158,12 @@ function buildExclusionSet(exclusions: readonly string[] | undefined): Set<strin
}
function compileEquivalenceConfig(config: ModelEquivalenceConfig | undefined): CompiledEquivalenceConfig {
return {
overrides: buildOverrideMap(config?.overrides),
exclude: buildExclusionSet(config?.exclude),
};
const overrides = buildOverrideMap(config?.overrides);
const exclude = buildExclusionSet(config?.exclude);
if (overrides.size === 0 && exclude.size === 0) {
return EMPTY_COMPILED_EQUIVALENCE;
}
return { overrides, exclude };
}
function addCanonicalCandidate(candidates: Set<string>, candidate: string): void {
@@ -149,12 +174,10 @@ function addCanonicalCandidate(candidates: Set<string>, candidate: string): void
}
function stripTrailingMarker(candidate: string): string | undefined {
for (const marker of TRAILING_CANONICAL_MARKERS) {
for (const separator of ["-", ":"] as const) {
const suffix = `${separator}${marker}`;
if (candidate.toLowerCase().endsWith(suffix)) {
return candidate.slice(0, -suffix.length);
}
const lower = candidate.toLowerCase();
for (const suffix of TRAILING_MARKER_SUFFIXES) {
if (lower.endsWith(suffix)) {
return candidate.slice(0, -suffix.length);
}
}
return undefined;
@@ -450,8 +473,8 @@ function getHeuristicCanonicalCandidates(modelId: string): string[] {
const queue = [modelId];
const visited = new Set<string>();
while (queue.length > 0) {
const candidate = queue.shift();
for (let qi = 0; qi < queue.length; qi += 1) {
const candidate = queue[qi];
if (!candidate) {
continue;
}
@@ -644,8 +667,18 @@ export function buildCanonicalModelIndex(
const byId = new Map<string, CanonicalModelRecord>();
const bySelector = new Map<string, string>();
let modelCache = __resolutionCache.get(compiledEquivalence);
if (!modelCache) {
modelCache = new WeakMap<Model<Api>, ResolvedCanonicalModel>();
__resolutionCache.set(compiledEquivalence, modelCache);
}
for (const model of models) {
const canonical = resolveCanonicalIdForModel(model, compiledEquivalence, referenceData);
let canonical = modelCache.get(model);
if (!canonical) {
canonical = resolveCanonicalIdForModel(model, compiledEquivalence, referenceData);
modelCache.set(model, canonical);
}
const selector = formatCanonicalVariantSelector(model);
const variant: CanonicalModelVariant = {
canonicalId: canonical.id,
@@ -797,6 +797,7 @@ export class ModelRegistry {
#equivalenceConfig: ModelEquivalenceConfig | undefined;
#configError: ConfigError | undefined = undefined;
#modelsConfigFile: ConfigFile<ModelsConfig>;
#lastStaticLoadMtime: number | null = null;
#registeredProviderSources: Set<string> = new Set();
#providerDiscoveryStates: Map<string, ProviderDiscoveryState> = new Map();
#cacheDbPath?: string;
@@ -810,6 +811,8 @@ export class ModelRegistry {
#runtimeProviderOverrides: Map<string, ProviderOverride> = new Map();
#runtimeProvidersBySource: Map<string, Set<string>> = new Map();
#runtimeProviderSourceByName: Map<string, string> = new Map();
#rebuildPending: boolean = false;
#rebuildSuspended: number = 0;
/**
* @param authStorage - Auth storage for API key resolution
@@ -836,9 +839,14 @@ export class ModelRegistry {
* Reload models from disk (built-in + custom from models.json).
*/
async refresh(strategy: ModelRefreshStrategy = "online-if-uncached"): Promise<void> {
this.#reloadStaticModels();
this.#suppressedSelectors.clear();
await this.#refreshRuntimeDiscoveries(strategy);
this.#suspendRebuild();
try {
this.#reloadStaticModels();
this.#suppressedSelectors.clear();
await this.#refreshRuntimeDiscoveries(strategy);
} finally {
this.#resumeRebuild();
}
}
refreshInBackground(strategy: ModelRefreshStrategy = "online-if-uncached"): void {
@@ -860,16 +868,26 @@ export class ModelRegistry {
}
async refreshProvider(providerId: string, strategy: ModelRefreshStrategy = "online"): Promise<void> {
this.#reloadStaticModels();
for (const selector of this.#suppressedSelectors.keys()) {
if (selector.startsWith(`${providerId}/`)) {
this.#suppressedSelectors.delete(selector);
this.#suspendRebuild();
try {
this.#reloadStaticModels();
for (const selector of this.#suppressedSelectors.keys()) {
if (selector.startsWith(`${providerId}/`)) {
this.#suppressedSelectors.delete(selector);
}
}
await this.#refreshRuntimeDiscoveries(strategy, new Set([providerId]));
} finally {
this.#resumeRebuild();
}
await this.#refreshRuntimeDiscoveries(strategy, new Set([providerId]));
}
#reloadStaticModels(): void {
const currentMtime = this.#modelsConfigFile.getMtimeMs();
if (currentMtime !== null && currentMtime === this.#lastStaticLoadMtime) {
// models.json unchanged since last load; reload + canonical rebuild would be redundant.
return;
}
this.#modelsConfigFile.invalidate();
this.#customProviderApiKeys.clear();
this.#keylessProviders.clear();
@@ -924,6 +942,7 @@ export class ModelRegistry {
const withModelOverrides = this.#applyModelOverrides(combined, this.#modelOverrides);
this.#models = this.#applyRuntimeProviderOverrides(withModelOverrides);
this.#rebuildCanonicalIndex();
this.#lastStaticLoadMtime = this.#modelsConfigFile.getMtimeMs();
}
/** Load built-in models, applying provider-level overrides only.
@@ -947,14 +966,19 @@ export class ModelRegistry {
#mergeResolvedModels(baseModels: Model<Api>[], replacementModels: Model<Api>[]): Model<Api>[] {
const merged = [...baseModels];
const indexByKey = new Map<string, number>();
for (let i = 0; i < merged.length; i += 1) {
const m = merged[i];
indexByKey.set(`${m.provider}\u0000${m.id}`, i);
}
for (const replacementModel of replacementModels) {
const existingIndex = merged.findIndex(
m => m.provider === replacementModel.provider && m.id === replacementModel.id,
);
if (existingIndex >= 0) {
const key = `${replacementModel.provider}\u0000${replacementModel.id}`;
const existingIndex = indexByKey.get(key);
if (existingIndex !== undefined) {
merged[existingIndex] = replacementModel;
} else {
merged.push(replacementModel);
indexByKey.set(key, merged.length - 1);
}
}
return merged;
@@ -963,9 +987,15 @@ export class ModelRegistry {
/** Merge custom models with built-in, replacing by provider+id match */
#mergeCustomModels(builtInModels: Model<Api>[], customModels: CustomModelOverlay[]): Model<Api>[] {
const merged = [...builtInModels];
const indexByKey = new Map<string, number>();
for (let i = 0; i < merged.length; i += 1) {
const m = merged[i];
indexByKey.set(`${m.provider}\u0000${m.id}`, i);
}
for (const customModel of customModels) {
const existingIndex = merged.findIndex(m => m.provider === customModel.provider && m.id === customModel.id);
if (existingIndex >= 0) {
const key = `${customModel.provider}\u0000${customModel.id}`;
const existingIndex = indexByKey.get(key);
if (existingIndex !== undefined) {
const existingModel = merged[existingIndex];
merged[existingIndex] = enrichModelThinking({
...existingModel,
@@ -990,6 +1020,7 @@ export class ModelRegistry {
} as Model<Api>);
} else {
merged.push(finalizeCustomModel(customModel, { useDefaults: true }));
indexByKey.set(key, merged.length - 1);
}
}
return merged;
@@ -1756,7 +1787,26 @@ export class ModelRegistry {
}
#rebuildCanonicalIndex(): void {
if (this.#rebuildSuspended > 0) {
this.#rebuildPending = true;
return;
}
this.#canonicalIndex = buildCanonicalModelIndex(this.#models, this.#equivalenceConfig);
this.#rebuildPending = false;
}
#suspendRebuild(): void {
this.#rebuildSuspended += 1;
}
#resumeRebuild(): void {
if (this.#rebuildSuspended > 0) {
this.#rebuildSuspended -= 1;
}
if (this.#rebuildSuspended === 0 && this.#rebuildPending) {
this.#rebuildPending = false;
this.#canonicalIndex = buildCanonicalModelIndex(this.#models, this.#equivalenceConfig);
}
}
#parseModels(config: ModelsConfig): CustomModelOverlay[] {
@@ -2016,6 +2066,7 @@ export class ModelRegistry {
this.#runtimeProviderSourceByName.delete(providerName);
this.#clearRuntimeProviderState(providerName);
}
this.#lastStaticLoadMtime = null;
this.#reloadStaticModels();
this.#rebuildCanonicalIndex();
}
@@ -2094,6 +2145,7 @@ export class ModelRegistry {
this.#runtimeProviderSourceByName.set(providerName, sourceId);
}
if (sourceHandoff) {
this.#lastStaticLoadMtime = null;
this.#reloadStaticModels();
}
@@ -116,6 +116,24 @@ function cloneModelWithRequestedId(model: Model<Api>, requestedId: string): Mode
};
}
const providerModelIndexCache = new WeakMap<readonly Model<Api>[], Map<string, Model<Api> | null>>();
function getProviderModelIndex(availableModels: readonly Model<Api>[]): Map<string, Model<Api> | null> {
let index = providerModelIndexCache.get(availableModels);
if (index) return index;
index = new Map<string, Model<Api> | null>();
for (const m of availableModels) {
const key = `${m.provider.toLowerCase()}\u0000${m.id.toLowerCase()}`;
if (index.has(key)) {
index.set(key, null); // ambiguous sentinel; do not overwrite back
} else {
index.set(key, m);
}
}
providerModelIndexCache.set(availableModels, index);
return index;
}
export function resolveProviderModelReference(
provider: string,
modelId: string,
@@ -127,14 +145,13 @@ export function resolveProviderModelReference(
return undefined;
}
const exactMatches = availableModels.filter(
model => model.provider.toLowerCase() === normalizedProvider && model.id.toLowerCase() === normalizedModelId,
);
if (exactMatches.length === 1) {
return exactMatches[0];
const index = getProviderModelIndex(availableModels);
const exact = index.get(`${normalizedProvider}\u0000${normalizedModelId}`);
if (exact === null) {
return undefined; // ambiguous
}
if (exactMatches.length > 1) {
return undefined;
if (exact !== undefined) {
return exact;
}
if (normalizedProvider !== "openrouter") {
@@ -142,16 +159,13 @@ export function resolveProviderModelReference(
}
for (const fallbackId of getOpenRouterFallbackModelIds(modelId).slice(1)) {
const baseMatches = availableModels.filter(
model =>
model.provider.toLowerCase() === normalizedProvider && model.id.toLowerCase() === fallbackId.toLowerCase(),
);
if (baseMatches.length === 1) {
return cloneModelWithRequestedId(baseMatches[0], modelId);
}
if (baseMatches.length > 1) {
const fallback = index.get(`${normalizedProvider}\u0000${fallbackId.toLowerCase()}`);
if (fallback === null) {
return undefined;
}
if (fallback !== undefined) {
return cloneModelWithRequestedId(fallback, modelId);
}
}
return undefined;
+9 -8
View File
@@ -398,21 +398,22 @@ export class Settings {
// ─────────────────────────────────────────────────────────────────────────
async #load(): Promise<Settings> {
// Project settings load (loadCapability scans cwd) is independent of the
// persist chain (storage open → legacy migration → global config.yml read),
// so kick it off first and await after the persist chain completes. The
// persist steps remain sequential: migration may write config.yml, which
// #loadYaml then reads; migration's db fallback needs #storage opened.
const projectPromise = this.#loadProjectSettings();
if (this.#persist) {
// Open storage
this.#storage = await AgentStorage.open(getAgentDbPath(this.#agentDir));
// Migrate from legacy formats if needed
await this.#migrateFromLegacy();
// Load global settings from config.yml
this.#global = await this.#loadYaml(this.#configPath!);
}
// Load project settings
this.#project = await this.#loadProjectSettings();
this.#project = await projectPromise;
// Build merged view
// Build merged view (global → project → overrides; project wins over global)
this.#rebuildMerged();
this.#fireAllHooks();
return this;
+3 -9
View File
@@ -1,18 +1,14 @@
import type { ToolSession } from "../tools";
import type { EvalDisplayOutput, EvalLanguage } from "./types";
/** Per-call options shared across warm and execute. */
export interface ExecutorBackendCallOptions {
/** Per-cell execute() options. */
export interface ExecutorBackendExecOptions {
cwd: string;
sessionId: string;
sessionFile: string | undefined;
kernelOwnerId: string | undefined;
signal?: AbortSignal;
session: ToolSession;
}
/** Per-cell execute() options. */
export interface ExecutorBackendExecOptions extends ExecutorBackendCallOptions {
deadlineMs: number;
reset: boolean;
artifactPath: string | undefined;
@@ -40,10 +36,8 @@ export interface ExecutorBackend {
readonly label: string;
/** Source language identifier passed to the syntax highlighter (e.g. "python", "javascript"). */
readonly highlightLang: string;
/** Cheap availability check (no full warmup). Used by fallback resolution. */
/** Cheap availability check. Used by fallback resolution. */
isAvailable(session: ToolSession): Promise<boolean>;
/** Optional pre-warm performed once per session (no-op on backends that don't need it). */
warm?(opts: ExecutorBackendCallOptions): Promise<{ ok: boolean; reason?: string }>;
/** Execute one cell. Caller invokes once per cell and aggregates results. */
execute(code: string, opts: ExecutorBackendExecOptions): Promise<ExecutorBackendResult>;
}
@@ -453,25 +453,6 @@ async function ensureKernelAvailable(
}
}
export async function warmPythonEnvironment(
cwd: string,
sessionId?: string,
_useSharedGateway?: boolean,
_sessionFile?: string,
kernelOwnerId?: string,
signal?: AbortSignal,
): Promise<{ ok: boolean; reason?: string }> {
const resolvedSessionId = sessionId ?? `session:${cwd}`;
try {
await logger.time("warmPython:ensureKernelAvailable", ensureKernelAvailable, cwd, { signal });
} catch (err: unknown) {
const reason = err instanceof Error ? err.message : String(err);
return { ok: false, reason };
}
attachKernelOwner(resolvedSessionId, kernelOwnerId);
return { ok: true };
}
function isResourceExhaustionError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return (
+2 -20
View File
@@ -1,11 +1,6 @@
import type { ToolSession } from "../../tools";
import type {
ExecutorBackend,
ExecutorBackendCallOptions,
ExecutorBackendExecOptions,
ExecutorBackendResult,
} from "../backend";
import { executePython, type PythonExecutorOptions, warmPythonEnvironment } from "./executor";
import type { ExecutorBackend, ExecutorBackendExecOptions, ExecutorBackendResult } from "../backend";
import { executePython, type PythonExecutorOptions } from "./executor";
import { checkPythonKernelAvailability } from "./kernel";
const PYTHON_SESSION_PREFIX = "python:";
@@ -29,19 +24,6 @@ export default {
return availability.ok;
},
async warm(opts: ExecutorBackendCallOptions): Promise<{ ok: boolean; reason?: string }> {
const useSharedGateway = readSetting<boolean>(opts.session, "python.sharedGateway");
const result = await warmPythonEnvironment(
opts.cwd,
namespaceSessionId(opts.sessionId),
useSharedGateway,
opts.sessionFile,
opts.kernelOwnerId,
opts.signal,
);
return { ok: result.ok, reason: result.reason };
},
async execute(code: string, opts: ExecutorBackendExecOptions): Promise<ExecutorBackendResult> {
const useSharedGateway = readSetting<boolean>(opts.session, "python.sharedGateway");
const kernelMode = readSetting<PythonExecutorOptions["kernelMode"]>(opts.session, "python.kernelMode");
+11 -2
View File
@@ -242,6 +242,11 @@ async function getChangelogForDisplay(parsed: Args): Promise<string | undefined>
}
const lastVersion = settings.get("lastChangelogVersion");
if (lastVersion === VERSION) {
// Steady state: user already saw the current version's changelog. Skip the file read + parse.
return undefined;
}
const changelogPath = getChangelogPath();
const entries = await parseChangelog(changelogPath);
@@ -627,7 +632,7 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<v
pluginPreloadPromise.catch(() => {});
const cwd = getProjectDir();
await logger.time("settings:init", Settings.init, { cwd });
const settingsInstance = await logger.time("settings:init", Settings.init, { cwd });
if (parsedArgs.mode === "rpc") {
applyRpcDefaultSettingOverrides();
}
@@ -659,7 +664,6 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<v
// Initialize discovery system with settings for provider persistence
logger.time("initializeWithSettings", initializeWithSettings, settings);
modelRegistry.refreshInBackground();
// Apply model role overrides from CLI args or env vars (ephemeral, not persisted)
const smolModel = parsedArgs.smol ?? $env.PI_SMOL_MODEL;
@@ -763,6 +767,7 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<v
sessionOptions.authStorage = authStorage;
sessionOptions.modelRegistry = modelRegistry;
sessionOptions.hasUI = isInteractive;
sessionOptions.settings = settingsInstance;
// Handle CLI --api-key as runtime override (not persisted)
if (parsedArgs.apiKey) {
@@ -782,6 +787,10 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<v
createAgentSession,
sessionOptions,
);
// Kick off background model discovery only after createAgentSession finishes its parallel
// discovery arms; running these concurrently contends for the event loop and stretches
// every parallel arm by ~30ms.
modelRegistry.refreshInBackground();
if (parsedArgs.apiKey && !sessionOptions.model && session.model) {
authStorage.setRuntimeApiKey(session.model.provider, parsedArgs.apiKey);
}
-4
View File
@@ -206,9 +206,6 @@ export interface CreateAgentSessionOptions {
enableLsp?: boolean;
/** Skip Python kernel availability check and prelude warmup */
skipPythonPreflight?: boolean;
/** Force Python prelude warmup even when test env would normally skip it */
forcePythonWarmup?: boolean;
/** Tool names explicitly requested (enables disabled-by-default tools) */
toolNames?: string[];
@@ -956,7 +953,6 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
return !requestedToolNames || requestedToolNames.includes("edit");
},
skipPythonPreflight: options.skipPythonPreflight,
forcePythonWarmup: options.forcePythonWarmup,
contextFiles,
skills,
eventBus,
+24 -62
View File
@@ -2,10 +2,9 @@
* System prompt construction and project context loading
*/
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import type { AgentTool } from "@oh-my-pi/pi-agent-core";
import { FileType, glob } from "@oh-my-pi/pi-natives";
import { $env, getGpuCachePath, getProjectDir, hasFsCode, isEnoent, logger, prompt } from "@oh-my-pi/pi-utils";
import { $ } from "bun";
import { contextFileCapability } from "./capability/context-file";
@@ -96,68 +95,31 @@ export interface AgentsMdSearch {
files: string[];
}
function normalizePath(value: string): string {
return value.replace(/\\/g, "/");
}
function shouldSkipAgentsDir(name: string): boolean {
if (AGENTS_MD_EXCLUDED_DIRS.has(name)) return true;
return name.startsWith(".");
}
async function collectAgentsMdFiles(
root: string,
dir: string,
depth: number,
limit: number,
discovered: Set<string>,
): Promise<void> {
if (depth > AGENTS_MD_MAX_DEPTH || discovered.size >= limit) {
return;
}
let entries: fs.Dirent[];
try {
entries = await fs.promises.readdir(dir, { withFileTypes: true });
} catch {
return;
}
if (depth >= AGENTS_MD_MIN_DEPTH) {
const hasAgentsMd = entries.some(entry => entry.isFile() && entry.name === "AGENTS.md");
if (hasAgentsMd) {
const relPath = normalizePath(path.relative(root, path.join(dir, "AGENTS.md")));
if (relPath.length > 0) {
discovered.add(relPath);
}
if (discovered.size >= limit) {
return;
}
}
}
if (depth === AGENTS_MD_MAX_DEPTH) {
return;
}
const childDirs = entries
.filter(entry => entry.isDirectory() && !shouldSkipAgentsDir(entry.name))
.map(entry => entry.name)
.sort();
await Promise.all(
childDirs.map(async child => {
if (discovered.size >= limit) return;
await collectAgentsMdFiles(root, path.join(dir, child), depth + 1, limit, discovered);
}),
);
}
async function listAgentsMdFiles(root: string, limit: number): Promise<string[]> {
try {
const discovered = new Set<string>();
await collectAgentsMdFiles(root, root, 0, limit, discovered);
return Array.from(discovered).sort().slice(0, limit);
const result = await glob({
pattern: "**/AGENTS.md",
path: root,
fileType: FileType.File,
recursive: true,
hidden: false,
gitignore: true,
maxResults: limit * 4,
cache: true,
});
const files: string[] = [];
for (const m of result.matches) {
const rel = m.path.replace(/\\/g, "/");
if (!rel?.endsWith("AGENTS.md")) continue;
const segments = rel.split("/");
const depth = segments.length - 1;
if (depth < AGENTS_MD_MIN_DEPTH || depth > AGENTS_MD_MAX_DEPTH) continue;
const dirSegments = segments.slice(0, -1);
if (dirSegments.some(seg => AGENTS_MD_EXCLUDED_DIRS.has(seg) || seg.startsWith("."))) continue;
files.push(rel);
if (files.length >= limit) break;
}
return Array.from(new Set(files)).sort().slice(0, limit);
} catch {
return [];
}
-19
View File
@@ -348,8 +348,6 @@ export class EvalTool implements AgentTool<typeof evalSchema> {
});
const sessionId = sessionFile ? `session:${sessionFile}:cwd:${session.cwd}` : `cwd:${session.cwd}`;
const warmedBackends = new Set<EvalLanguage>();
for (let i = 0; i < cells.length; i++) {
const cell = cells[i];
const backend = cell.resolved.backend;
@@ -360,23 +358,6 @@ export class EvalTool implements AgentTool<typeof evalSchema> {
? AbortSignal.any([signal, timeoutSignal, sessionAbortController.signal])
: AbortSignal.any([timeoutSignal, sessionAbortController.signal]);
if (!warmedBackends.has(backend.id) && backend.warm) {
const warmup = await backend.warm({
cwd: session.cwd,
sessionId,
sessionFile: sessionFile ?? undefined,
kernelOwnerId,
signal: combinedSignal,
session,
});
if (!warmup.ok) {
if (combinedSignal.aborted) throw new ToolAbortError();
throw new ToolError(warmup.reason ?? `${backend.label} prelude helpers unavailable`);
}
session.assertEvalExecutionAllowed?.();
}
warmedBackends.add(backend.id);
const cellResult = cellResults[i];
cellResult.status = "running";
cellResult.output = "";
+12 -46
View File
@@ -1,11 +1,10 @@
import type { AgentTool } from "@oh-my-pi/pi-agent-core";
import type { ToolChoice } from "@oh-my-pi/pi-ai";
import { $env, $flag, isBunTestRuntime, logger } from "@oh-my-pi/pi-utils";
import { $env, $flag, logger } from "@oh-my-pi/pi-utils";
import type { AsyncJobManager } from "../async";
import type { PromptTemplate } from "../config/prompt-templates";
import type { Settings } from "../config/settings";
import { EditTool } from "../edit";
import { warmPythonEnvironment } from "../eval/py/executor";
import { checkPythonKernelAvailability } from "../eval/py/kernel";
import type { Skill } from "../extensibility/skills";
import type { InternalUrlRouter } from "../internal-urls";
@@ -108,8 +107,6 @@ export interface ToolSession {
hasUI: boolean;
/** Skip Python kernel availability check and warmup */
skipPythonPreflight?: boolean;
/** Force Python prelude warmup even when test env would normally skip it */
forcePythonWarmup?: boolean;
/** Pre-loaded context files (AGENTS.md, etc) */
contextFiles?: ContextFileEntry[];
/** Pre-loaded skills */
@@ -297,54 +294,23 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
const allowPython = backends.python;
const allowJs = backends.js;
const skipPythonPreflight = session.skipPythonPreflight === true;
// Eval tool is enabled if EITHER backend is reachable. We only need to know
// whether python is reachable when JS is disabled — otherwise allowEval is
// already true and the python-availability check can be deferred to first
// invocation of the python backend (already handled inside the executor).
let pythonAvailable = true;
const shouldCheckPython =
!skipPythonPreflight && allowPython && (requestedTools === undefined || requestedTools.includes("eval"));
const isTestEnv = isBunTestRuntime();
const forcePythonWarmup = session.forcePythonWarmup === true;
const skipPythonWarm = (isTestEnv && !forcePythonWarmup) || $flag("PI_PYTHON_SKIP_CHECK");
if (shouldCheckPython) {
if (
!skipPythonPreflight &&
allowPython &&
!allowJs &&
(requestedTools === undefined || requestedTools.includes("eval"))
) {
const availability = await logger.time("createTools:pythonCheck", checkPythonKernelAvailability, session.cwd);
pythonAvailable = availability.ok;
if (!availability.ok) {
logger.warn("Python kernel unavailable; eval will dispatch to JavaScript backend", {
logger.warn("Python kernel unavailable and JS backend disabled; eval will be unavailable", {
reason: availability.reason,
});
} else if (!skipPythonWarm) {
const sessionFile = session.getSessionFile?.() ?? undefined;
const kernelOwnerId = session.getEvalKernelOwnerId?.() ?? undefined;
const warmSessionId = sessionFile ? `session:${sessionFile}:cwd:${session.cwd}` : `cwd:${session.cwd}`;
const warmupAbortController = new AbortController();
try {
session.assertEvalExecutionAllowed?.();
const warmupExecution = session.trackEvalExecution
? logger.time(
"createTools:warmPython",
warmPythonEnvironment,
session.cwd,
warmSessionId,
session.settings.get("python.sharedGateway"),
sessionFile,
kernelOwnerId,
warmupAbortController.signal,
)
: logger.time(
"createTools:warmPython",
warmPythonEnvironment,
session.cwd,
warmSessionId,
session.settings.get("python.sharedGateway"),
sessionFile,
kernelOwnerId,
);
await (session.trackEvalExecution?.(warmupExecution, warmupAbortController) ?? warmupExecution);
session.assertEvalExecutionAllowed?.();
} catch (err) {
logger.warn("Failed to warm Python environment", {
error: err instanceof Error ? err.message : String(err),
});
}
}
}
@@ -10,21 +10,19 @@ interface PackageJsonInfo {
}
async function resolvePackageRunner(cwd: string): Promise<string> {
if ((await isFile(path.join(cwd, "bun.lock"))) || (await isFile(path.join(cwd, "bun.lockb")))) {
return "bun run";
}
if (await isFile(path.join(cwd, "pnpm-lock.yaml"))) {
return "pnpm run";
}
if (await isFile(path.join(cwd, "yarn.lock"))) {
return "yarn";
}
if ((await isFile(path.join(cwd, "package-lock.json"))) || (await isFile(path.join(cwd, "npm-shrinkwrap.json")))) {
return "npm run";
}
if ($which("bun")) {
return "bun run";
}
const [bunLock, bunLockb, pnpmLock, yarnLock, npmLock, npmShrink] = await Promise.all([
isFile(path.join(cwd, "bun.lock")),
isFile(path.join(cwd, "bun.lockb")),
isFile(path.join(cwd, "pnpm-lock.yaml")),
isFile(path.join(cwd, "yarn.lock")),
isFile(path.join(cwd, "package-lock.json")),
isFile(path.join(cwd, "npm-shrinkwrap.json")),
]);
if (bunLock || bunLockb) return "bun run";
if (pnpmLock) return "pnpm run";
if (yarnLock) return "yarn";
if (npmLock || npmShrink) return "npm run";
if ($which("bun")) return "bun run";
return "npm run";
}
@@ -86,18 +84,23 @@ async function readPackageJson(filePath: string): Promise<PackageJsonInfo | null
async function findWorkspacePackageJsons(cwd: string, patterns: string[]): Promise<string[]> {
const includePatterns = patterns.filter(pattern => !pattern.startsWith("!")).map(normalizeWorkspacePattern);
const excludePatterns = patterns.filter(pattern => pattern.startsWith("!")).map(normalizeWorkspacePattern);
const excluded = new Set<string>();
for (const pattern of excludePatterns) {
for await (const entry of new Bun.Glob(pattern.slice(1)).scan({ cwd, onlyFiles: true })) {
excluded.add(path.normalize(String(entry)));
}
}
const files = new Set<string>();
for (const pattern of includePatterns) {
const collect = async (pattern: string): Promise<string[]> => {
const out: string[] = [];
for await (const entry of new Bun.Glob(pattern).scan({ cwd, onlyFiles: true })) {
const normalized = path.normalize(String(entry));
if (normalized !== "package.json" && !excluded.has(normalized)) files.add(normalized);
out.push(path.normalize(String(entry)));
}
return out;
};
const [excludedLists, includedLists] = await Promise.all([
Promise.all(excludePatterns.map(pattern => collect(pattern.slice(1)))),
Promise.all(includePatterns.map(pattern => collect(pattern))),
]);
const excluded = new Set<string>(excludedLists.flat());
const files = new Set<string>();
for (const entry of includedLists.flat()) {
if (entry !== "package.json" && !excluded.has(entry)) files.add(entry);
}
return [...files].sort((left, right) => left.localeCompare(right));
}
@@ -132,10 +135,10 @@ async function readPackageTasks(cwd: string): Promise<RunnerTask[] | null> {
);
}
for (const packageJsonPath of workspacePackageJsons) {
const pkg = await readPackageJson(path.join(cwd, packageJsonPath));
if (!pkg || pkg.scripts.length === 0) continue;
const packageDir = path.dirname(packageJsonPath);
const pkgs = await Promise.all(workspacePackageJsons.map(p => readPackageJson(path.join(cwd, p))));
pkgs.forEach((pkg, index) => {
if (!pkg || pkg.scripts.length === 0) return;
const packageDir = path.dirname(workspacePackageJsons[index]);
tasks.push(
...tasksForPackage({
pkg,
@@ -143,7 +146,7 @@ async function readPackageTasks(cwd: string): Promise<RunnerTask[] | null> {
namespaced: true,
}),
);
}
});
return tasks.length > 0 ? tasks : null;
}
@@ -153,8 +156,7 @@ export const pkgRunner: TaskRunner = {
label: "Pkg",
async detect(cwd: string): Promise<DetectedRunner | null> {
try {
const commandPrefix = await resolvePackageRunner(cwd);
const tasks = await readPackageTasks(cwd);
const [commandPrefix, tasks] = await Promise.all([resolvePackageRunner(cwd), readPackageTasks(cwd)]);
if (!tasks || tasks.length === 0) return null;
return { id: "pkg", label: "Pkg", commandPrefix, tasks };
} catch (err) {
+15 -7
View File
@@ -1,6 +1,5 @@
import { untilAborted } from "@oh-my-pi/pi-utils";
import type { StreamInfo } from "markit-ai";
import { Markit } from "markit-ai";
import type { Markit, StreamInfo } from "markit-ai";
import { ToolAbortError } from "../tools/tool-errors";
export interface MarkitConversionResult {
@@ -9,7 +8,15 @@ export interface MarkitConversionResult {
error?: string;
}
const markit = new Markit();
let markit: () => Markit | Promise<Markit> = async () => {
const promise = import("markit-ai").then(({ Markit }) => {
const instance = new Markit();
markit = () => instance;
return instance;
});
markit = () => promise;
return promise;
};
function normalizeExtension(extension: string): string {
const trimmed = extension.trim().toLowerCase();
@@ -24,9 +31,10 @@ function normalizeError(error: unknown): string {
return "Conversion failed";
}
async function runMarkitConversion<T>(task: () => Promise<T>, signal?: AbortSignal): Promise<T> {
async function runMarkitConversion<T>(task: (markit: Markit) => Promise<T>, signal?: AbortSignal): Promise<T> {
try {
return signal ? await untilAborted(signal, task) : await task();
const instance = await markit();
return signal ? await untilAborted(signal, () => task(instance)) : await task(instance);
} catch (error) {
if (error instanceof ToolAbortError) {
throw error;
@@ -48,7 +56,7 @@ function finalizeConversion(markdown?: string): MarkitConversionResult {
export async function convertFileWithMarkit(filePath: string, signal?: AbortSignal): Promise<MarkitConversionResult> {
try {
const result = await runMarkitConversion(() => markit.convertFile(filePath), signal);
const result = await runMarkitConversion(markit => markit.convertFile(filePath), signal);
return finalizeConversion(result.markdown);
} catch (error) {
if (error instanceof ToolAbortError) {
@@ -70,7 +78,7 @@ export async function convertBufferWithMarkit(
};
try {
const result = await runMarkitConversion(() => markit.convert(Buffer.from(buffer), streamInfo), signal);
const result = await runMarkitConversion(markit => markit.convert(Buffer.from(buffer), streamInfo), signal);
return finalizeConversion(result.markdown);
} catch (error) {
if (error instanceof ToolAbortError) {
@@ -95,18 +95,15 @@ const createSession = async (
slashCommands: [],
enableMCP: false,
enableLsp: false,
forcePythonWarmup: true,
toolNames: ["eval"],
})
).session;
const stubPythonWarmup = () => vi.spyOn(pythonExecutor, "warmPythonEnvironment").mockResolvedValue({ ok: true });
const createWarmupKernel = () => {
const createMockKernel = () => {
let alive = true;
return {
execute: vi.fn(async () => {
if (!alive) throw new Error("Expected warmup kernel to be restarted after shutdown");
if (!alive) throw new Error("Expected mock kernel to be restarted after shutdown");
return OK_EXECUTION;
}),
ping: vi.fn(async () => alive),
@@ -132,7 +129,7 @@ describe("AgentSession python cleanup", () => {
it("does not dispose unrelated Python owners when createAgentSession fails before session construction", async () => {
const { tempDir, cwd } = createTempProject();
tempDirs.push(tempDir);
const unrelatedKernel = createWarmupKernel();
const unrelatedKernel = createMockKernel();
const unrelatedCwd = path.join(tempDir, "unrelated-before");
const throwingExtension: ExtensionFactory = () => {
throw new Error("Extension init failed");
@@ -164,7 +161,6 @@ describe("AgentSession python cleanup", () => {
slashCommands: [],
enableMCP: false,
enableLsp: false,
forcePythonWarmup: true,
toolNames: ["eval"],
}),
).rejects.toThrow("Extension init failed");
@@ -172,7 +168,7 @@ describe("AgentSession python cleanup", () => {
expect(startSpy).toHaveBeenCalledTimes(1);
expect(unrelatedKernel.shutdown).not.toHaveBeenCalled();
const replacementKernel = createWarmupKernel();
const replacementKernel = createMockKernel();
startSpy.mockResolvedValueOnce(replacementKernel as unknown as PythonKernelInstance);
await pythonExecutor.executePython("print('fresh warmup before')", {
cwd,
@@ -198,7 +194,7 @@ describe("AgentSession python cleanup", () => {
it("does not dispose unrelated Python owners when createAgentSession fails after session construction", async () => {
const { tempDir, cwd } = createTempProject();
tempDirs.push(tempDir);
const unrelatedKernel = createWarmupKernel();
const unrelatedKernel = createMockKernel();
const unrelatedCwd = path.join(tempDir, "unrelated-after");
vi.spyOn(pythonKernel, "checkPythonKernelAvailability").mockResolvedValue({ ok: true });
const startSpy = vi
@@ -229,7 +225,6 @@ describe("AgentSession python cleanup", () => {
slashCommands: [],
enableMCP: false,
enableLsp: false,
forcePythonWarmup: true,
toolNames: ["eval"],
}),
).rejects.toThrow("Memory startup failed");
@@ -237,7 +232,7 @@ describe("AgentSession python cleanup", () => {
expect(startSpy).toHaveBeenCalledTimes(1);
expect(unrelatedKernel.shutdown).not.toHaveBeenCalled();
const replacementKernel = createWarmupKernel();
const replacementKernel = createMockKernel();
startSpy.mockResolvedValueOnce(replacementKernel as unknown as PythonKernelInstance);
await pythonExecutor.executePython("print('fresh warmup after')", {
cwd,
@@ -263,8 +258,6 @@ describe("AgentSession python cleanup", () => {
it("waits for active SDK session Python work before releasing a shared retained kernel", async () => {
const { tempDir, cwd } = createTempProject();
tempDirs.push(tempDir);
stubPythonWarmup();
const kernel = new FakeKernel();
const blockedExecution = Promise.withResolvers<typeof OK_EXECUTION>();
const blockedExecutionStarted = Promise.withResolvers<void>();
@@ -336,128 +329,9 @@ describe("AgentSession python cleanup", () => {
expect(kernel.shutdownCalls).toBe(1);
});
it("aborts tracked eval warmup during session dispose before executePython starts", async () => {
const { tempDir, cwd } = createTempProject();
tempDirs.push(tempDir);
const blockedWarmupStarted = Promise.withResolvers<void>();
const executeSpy = vi.spyOn(pythonExecutor, "executePython").mockResolvedValue({
output: "tool ok",
exitCode: 0,
cancelled: false,
truncated: false,
totalLines: 1,
totalBytes: 7,
outputLines: 1,
outputBytes: 7,
displayOutputs: [],
stdinRequested: false,
});
let warmupCallCount = 0;
const warmupSpy = vi
.spyOn(pythonExecutor, "warmPythonEnvironment")
.mockImplementation(async (_cwd, _sessionId, _useSharedGateway, _sessionFile, _kernelOwnerId, signal) => {
warmupCallCount += 1;
if (warmupCallCount === 1) {
return { ok: true };
}
blockedWarmupStarted.resolve();
return await new Promise<{ ok: boolean; reason?: string }>(resolve => {
const onAbort = () => resolve({ ok: false, reason: "Warmup aborted" });
if (signal?.aborted) {
onAbort();
return;
}
signal?.addEventListener("abort", onAbort, { once: true });
});
});
vi.spyOn(pythonKernel, "checkPythonKernelAvailability").mockResolvedValue({ ok: true });
const session = await createSession(tempDir, cwd);
const EvalTool = session.getToolByName("eval");
expect(EvalTool).toBeDefined();
let toolExecutionSettled = false;
const toolExecution = EvalTool!
.execute("call-id", { input: "```py\nprint('tool')\n```" }, undefined, undefined, undefined)
.finally(() => {
toolExecutionSettled = true;
});
await blockedWarmupStarted.promise;
let disposed = false;
const disposeSession = session.dispose().then(() => {
disposed = true;
});
await Bun.sleep(0);
expect(disposed).toBe(false);
expect(toolExecutionSettled).toBe(false);
expect(warmupSpy).toHaveBeenCalledTimes(2);
expect(executeSpy).not.toHaveBeenCalled();
await expect(toolExecution).rejects.toThrow("Operation aborted");
await disposeSession;
expect(disposed).toBe(true);
expect(toolExecutionSettled).toBe(true);
expect(executeSpy).not.toHaveBeenCalled();
});
it("rejects eval starts when warmup finishes after dispose begins", async () => {
const { tempDir, cwd } = createTempProject();
tempDirs.push(tempDir);
const blockedWarmupStarted = Promise.withResolvers<void>();
const releaseWarmup = Promise.withResolvers<void>();
const executeSpy = vi.spyOn(pythonExecutor, "executePython").mockResolvedValue({
output: "late",
exitCode: 0,
cancelled: false,
truncated: false,
totalLines: 1,
totalBytes: 4,
outputLines: 1,
outputBytes: 4,
displayOutputs: [],
stdinRequested: false,
});
let warmupCallCount = 0;
const warmupSpy = vi.spyOn(pythonExecutor, "warmPythonEnvironment").mockImplementation(async () => {
warmupCallCount += 1;
if (warmupCallCount === 1) {
return { ok: true };
}
blockedWarmupStarted.resolve();
await releaseWarmup.promise;
return { ok: true };
});
vi.spyOn(pythonKernel, "checkPythonKernelAvailability").mockResolvedValue({ ok: true });
const session = await createSession(tempDir, cwd);
const EvalTool = session.getToolByName("eval");
expect(EvalTool).toBeDefined();
const toolExecution = EvalTool!.execute(
"call-id",
{ input: "```py\nprint('tool after warmup')\n```" },
undefined,
undefined,
undefined,
);
await blockedWarmupStarted.promise;
const disposeSession = session.dispose();
releaseWarmup.resolve();
await expect(toolExecution).rejects.toThrow(
"Python execution is unavailable while session disposal is in progress",
);
await disposeSession;
expect(warmupSpy).toHaveBeenCalledTimes(2);
expect(executeSpy).not.toHaveBeenCalled();
});
it("aborts tracked eval execution during session dispose after warmup completes", async () => {
const { tempDir, cwd } = createTempProject();
tempDirs.push(tempDir);
const warmupSpy = vi.spyOn(pythonExecutor, "warmPythonEnvironment").mockImplementation(async () => {
return { ok: true };
});
const blockedExecuteStarted = Promise.withResolvers<void>();
const executeSpy = vi.spyOn(pythonExecutor, "executePython").mockImplementation(async (_code, options) => {
const signal = options?.signal;
@@ -507,14 +381,12 @@ describe("AgentSession python cleanup", () => {
expect(disposed).toBe(false);
expect(toolExecutionSettled).toBe(false);
expect(warmupSpy).toHaveBeenCalledTimes(2);
expect(executeSpy).toHaveBeenCalledTimes(1);
const [toolResult] = await Promise.all([toolExecution, disposeSession]);
expect(disposed).toBe(true);
expect(toolExecutionSettled).toBe(true);
expect(warmupSpy).toHaveBeenCalledTimes(2);
expect(executeSpy).toHaveBeenCalledTimes(1);
expect(toolResult.details?.isError).toBe(true);
expect(toolResult.content).toContainEqual(
@@ -525,8 +397,6 @@ describe("AgentSession python cleanup", () => {
it("detaches retained kernel ownership even when dispose times out waiting for Python work", async () => {
const { tempDir, cwd } = createTempProject();
tempDirs.push(tempDir);
stubPythonWarmup();
const kernel = new FakeKernel();
const blockedExecution = Promise.withResolvers<typeof OK_EXECUTION>();
const blockedExecutionStarted = Promise.withResolvers<void>();
@@ -583,7 +453,6 @@ describe("AgentSession python cleanup", () => {
it("rejects direct session Python starts once dispose begins", async () => {
const { tempDir, cwd } = createTempProject();
tempDirs.push(tempDir);
stubPythonWarmup();
const executeSpy = vi.spyOn(pythonExecutor, "executePython").mockResolvedValue({
output: "late",
exitCode: 0,
@@ -705,7 +574,6 @@ describe("AgentSession python cleanup", () => {
it("rejects eval starts once dispose begins", async () => {
const { tempDir, cwd } = createTempProject();
tempDirs.push(tempDir);
const warmupSpy = stubPythonWarmup();
const executeSpy = vi.spyOn(pythonExecutor, "executePython").mockResolvedValue({
output: "late",
exitCode: 0,
@@ -724,23 +592,15 @@ describe("AgentSession python cleanup", () => {
expect(EvalTool).toBeDefined();
const disposeSession = session.dispose();
await expect(
EvalTool!.execute(
"call-id",
{ input: "```py\nprint('late')\n```" },
undefined,
undefined,
undefined,
),
EvalTool!.execute("call-id", { input: "```py\nprint('late')\n```" }, undefined, undefined, undefined),
).rejects.toThrow("Python execution is unavailable while session disposal is in progress");
await disposeSession;
expect(warmupSpy).toHaveBeenCalledTimes(1);
expect(executeSpy).not.toHaveBeenCalled();
});
it("rejects eval starts that reach async preflight after dispose begins", async () => {
const { tempDir, cwd } = createTempProject();
tempDirs.push(tempDir);
const warmupSpy = stubPythonWarmup();
const executeSpy = vi.spyOn(pythonExecutor, "executePython").mockResolvedValue({
output: "late",
exitCode: 0,
@@ -777,15 +637,12 @@ describe("AgentSession python cleanup", () => {
releaseArtifact.resolve();
await expect(execution).rejects.toThrow("Python execution is unavailable while session disposal is in progress");
await disposeSession;
expect(warmupSpy).toHaveBeenCalledTimes(1);
expect(executeSpy).not.toHaveBeenCalled();
});
it("aborts every active Python execution owned by the session during dispose", async () => {
const { tempDir, cwd } = createTempProject();
tempDirs.push(tempDir);
stubPythonWarmup();
const kernel = new FakeKernel();
const blockedExecution = Promise.withResolvers<typeof OK_EXECUTION>();
const blockedExecutionStarted = Promise.withResolvers<void>();
@@ -120,13 +120,13 @@ describe("executeJs", () => {
"const uuid = crypto.randomUUID();",
"const digest = await webcrypto.subtle.digest('SHA-256', new TextEncoder().encode('ok'));",
"const base = process.cwd();",
"fs.mkdirSync(`${base}/nested`, { recursive: true });",
"fs.writeFileSync(`${base}/nested/value.txt`, 'hello');",
"await fs.promises.copyFile(`${base}/nested/value.txt`, `${base}/nested/copy.txt`);",
"const text = fs.readFileSync(`${base}/nested/copy.txt`, 'utf8');",
"const bytes = await fs.promises.readFile(`${base}/nested/copy.txt`);",
"const stat = fs.statSync(`${base}/nested/copy.txt`);",
"const entries = fs.readdirSync(`${base}/nested`);",
"fs.mkdirSync(base + '/nested', { recursive: true });",
"fs.writeFileSync(base + '/nested/value.txt', 'hello');",
"await fs.promises.copyFile(base + '/nested/value.txt', base + '/nested/copy.txt');",
"const text = fs.readFileSync(base + '/nested/copy.txt', 'utf8');",
"const bytes = await fs.promises.readFile(base + '/nested/copy.txt');",
"const stat = fs.statSync(base + '/nested/copy.txt');",
"const entries = fs.readdirSync(base + '/nested');",
"const start = performance.now();",
"return {",
" uuid: typeof uuid,",
@@ -198,77 +198,6 @@ describe("executeJs", () => {
expect(directoryResult.output).toContain("Directory paths are not supported");
});
it("supports parse/stringify helpers and row utilities", async () => {
const result = await executeJs(
[
'const csv = \'name,role,note\\nalice,admin,"hello, world"\\nbob,user,"line1\\nline2"\';',
"const parsed = await parse.csv(csv);",
"const tsv = stringify.tsv(rows.pick(parsed, ['name', 'role']));",
"const roundtrip = await parse.tsv(tsv);",
"const grouped = rows.groupBy(parsed, 'role');",
"const counted = rows.countBy(parsed, 'role');",
"const sorted = rows.sortBy(parsed, 'name', { reverse: true });",
"return { parsed, roundtrip, groupedSizes: Object.fromEntries(Object.entries(grouped).map(([key, value]) => [key, value.length])), counted, sorted: rows.column(sorted, 'name') };",
].join("\n"),
{
sessionId,
session,
sessionFile,
},
);
expect(result.exitCode).toBe(0);
expect(result.displayOutputs).toEqual([
{
type: "json",
data: {
parsed: [
{ name: "alice", role: "admin", note: "hello, world" },
{ name: "bob", role: "user", note: "line1\nline2" },
],
roundtrip: [
{ name: "alice", role: "admin" },
{ name: "bob", role: "user" },
],
groupedSizes: { admin: 1, user: 1 },
counted: { admin: 1, user: 1 },
sorted: ["bob", "alice"],
},
},
]);
});
it("round-trips text and binary writes", async () => {
const result = await executeJs(
[
"await write('plain.txt', 'hello');",
"await write('bin.dat', new Uint8Array([0, 1, 2, 255]));",
"await write.json('state.json', { ok: true });",
"await write.csv('table.csv', [{ name: 'alice', role: 'admin' }]);",
"return {",
" text: await read.text('plain.txt'),",
" bytes: Array.from(await read.bytes('bin.dat')),",
" json: await read.json('state.json'),",
" csv: await read.csv('table.csv'),",
"};",
].join("\n"),
{
sessionId,
session,
sessionFile,
},
);
expect(result.exitCode).toBe(0);
expect(getStatusEvents(result)).toHaveLength(8);
expect(getJsonData(result)).toEqual({
text: "hello",
bytes: [0, 1, 2, 255],
json: { ok: true },
csv: [{ name: "alice", role: "admin" }],
});
});
it("routes output() through tool.read and keeps tool.* results normalized", async () => {
const execute = vi.fn(async (_toolCallId: string, args: unknown): Promise<AgentToolResult> => {
const record = args as { path: string };
@@ -3,7 +3,6 @@ import {
disposeAllKernelSessions,
disposeKernelSessionsByOwner,
executePython,
warmPythonEnvironment,
} from "@oh-my-pi/pi-coding-agent/eval/py/executor";
import * as gatewayCoordinator from "@oh-my-pi/pi-coding-agent/eval/py/gateway-coordinator";
import type {
@@ -13,7 +12,6 @@ import type {
} from "@oh-my-pi/pi-coding-agent/eval/py/kernel";
import * as pythonKernel from "@oh-my-pi/pi-coding-agent/eval/py/kernel";
import { PythonKernel } from "@oh-my-pi/pi-coding-agent/eval/py/kernel";
import { TempDir } from "@oh-my-pi/pi-utils";
const OK_RESULT: KernelExecuteResult = {
status: "ok",
@@ -775,80 +773,6 @@ describe("python executor owner cleanup", () => {
expect(globalKernel.shutdown).toHaveBeenCalledTimes(1);
await globalCleanup;
});
it("attaches cached warmup sessions to newly provided owners", async () => {
using tempDir = TempDir.createSync("@python-owner-warmup-");
const kernel = {
execute: vi.fn(async () => OK_RESULT),
ping: vi.fn(async () => true),
isAlive: () => true,
shutdown: vi.fn(async (): Promise<KernelShutdownResult> => ({ confirmed: true })),
};
vi.spyOn(pythonKernel, "checkPythonKernelAvailability").mockResolvedValue({ ok: true });
const startSpy = vi.spyOn(PythonKernel, "start").mockResolvedValue(kernel as unknown as PythonKernelInstance);
const firstWarmup = await warmPythonEnvironment(tempDir.path(), "warm-session", true, undefined, "owner-a");
expect(firstWarmup.ok).toBe(true);
const cachedWarmup = await warmPythonEnvironment(tempDir.path(), "warm-session", true, undefined, "owner-b");
expect(cachedWarmup.ok).toBe(true);
expect(startSpy).toHaveBeenCalledTimes(1);
await disposeKernelSessionsByOwner("owner-a");
expect(kernel.shutdown).not.toHaveBeenCalled();
await executePython("1 + 1", {
cwd: tempDir.path(),
sessionId: "warm-session",
kernelMode: "session",
kernelOwnerId: "owner-b",
});
expect(startSpy).toHaveBeenCalledTimes(1);
expect(kernel.execute).toHaveBeenCalledTimes(1);
await disposeKernelSessionsByOwner("owner-b");
expect(kernel.shutdown).toHaveBeenCalledTimes(1);
});
it("keeps cache-hit ownerless warmups provisional until an explicit owner takes over", async () => {
using tempDir = TempDir.createSync("@python-owner-fallback-");
const kernel = {
execute: vi.fn(async () => OK_RESULT),
ping: vi.fn(async () => true),
isAlive: () => true,
shutdown: vi.fn(async (): Promise<KernelShutdownResult> => ({ confirmed: true })),
};
vi.spyOn(pythonKernel, "checkPythonKernelAvailability").mockResolvedValue({ ok: true });
const startSpy = vi.spyOn(PythonKernel, "start").mockResolvedValue(kernel as unknown as PythonKernelInstance);
const firstWarmup = await warmPythonEnvironment(tempDir.path(), "warm-fallback-session", true);
expect(firstWarmup.ok).toBe(true);
const cachedWarmup = await warmPythonEnvironment(tempDir.path(), "warm-fallback-session", true);
expect(cachedWarmup.ok).toBe(true);
expect(startSpy).toHaveBeenCalledTimes(1);
await executePython("1 + 1", {
cwd: tempDir.path(),
sessionId: "warm-fallback-session",
kernelMode: "session",
kernelOwnerId: "owner-a",
});
expect(kernel.execute).toHaveBeenCalledTimes(1);
const postTakeoverWarmup = await warmPythonEnvironment(tempDir.path(), "warm-fallback-session", true);
expect(postTakeoverWarmup.ok).toBe(true);
expect(startSpy).toHaveBeenCalledTimes(1);
await disposeKernelSessionsByOwner("owner-a");
expect(kernel.shutdown).toHaveBeenCalledTimes(1);
await disposeKernelSessionsByOwner("warm-fallback-session");
expect(kernel.shutdown).toHaveBeenCalledTimes(1);
});
it("leaves per-call kernels out of owner-scoped retained cleanup and keeps global cleanup intact", async () => {
const perCallKernel = new FakeKernel();
const retainedKernel = new FakeKernel();
@@ -2,8 +2,7 @@ import { describe, expect, it } from "bun:test";
import * as fs from "node:fs";
import * as path from "node:path";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { warmPythonEnvironment } from "@oh-my-pi/pi-coding-agent/eval/py/executor";
import { EvalTool, getEvalToolDescription } from "@oh-my-pi/pi-coding-agent/tools/eval";
import { EvalTool } from "@oh-my-pi/pi-coding-agent/tools/eval";
import { $which, getProjectDir } from "@oh-my-pi/pi-utils";
const resolvePythonPath = (): string | null => {
@@ -40,7 +39,22 @@ const shouldRun = Boolean(pythonPath) && hasKernelDeps;
describe.skipIf(!shouldRun)("PYTHON_PRELUDE integration", () => {
it("exposes prelude helpers via eval python backend", async () => {
const helpers = ["env", "read", "write", "append", "find", "glob", "grep", "rgrep", "sed", "tree", "stat", "diff", "run", "output"];
const helpers = [
"env",
"read",
"write",
"append",
"find",
"glob",
"grep",
"rgrep",
"sed",
"tree",
"stat",
"diff",
"run",
"output",
];
const session = {
cwd: getProjectDir(),
@@ -74,12 +88,4 @@ ${code}
expect(tool.description).toContain("read");
expect(tool.description).not.toContain("Documentation unavailable");
});
it("renders prelude docs in eval tool description", async () => {
const result = await warmPythonEnvironment(getProjectDir());
expect(result.ok).toBe(true);
const description = getEvalToolDescription();
expect(description).toContain("read");
expect(description).not.toContain("Documentation unavailable");
});
});
+2
View File
@@ -1,6 +1,8 @@
# Changelog
## [Unreleased]
## [14.5.13] - 2026-05-01
### Changed
- Stopped overriding `CARGO_TARGET_DIR` with an internal `target/napi-build/...` directory during native builds, so Cargo now uses the default or caller-provided target directory
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@oh-my-pi/pi-natives",
"version": "14.5.12",
"version": "14.5.13",
"description": "Native Rust bindings for grep, clipboard, image processing, syntax highlighting, PTY, and shell operations via N-API",
"homepage": "https://github.com/can1357/oh-my-pi",
"author": "Can Boluk",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"type": "module",
"name": "@oh-my-pi/omp-stats",
"version": "14.5.12",
"version": "14.5.13",
"description": "Local observability dashboard for pi AI usage statistics",
"homepage": "https://github.com/can1357/oh-my-pi",
"author": "Can Boluk",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"type": "module",
"name": "@oh-my-pi/swarm-extension",
"version": "14.5.12",
"version": "14.5.13",
"description": "Swarm orchestration extension for omp",
"homepage": "https://github.com/can1357/oh-my-pi",
"author": "Derek Rynd",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"type": "module",
"name": "@oh-my-pi/pi-tui",
"version": "14.5.12",
"version": "14.5.13",
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
"homepage": "https://github.com/can1357/oh-my-pi",
"author": "Can Boluk",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"type": "module",
"name": "@oh-my-pi/pi-utils",
"version": "14.5.12",
"version": "14.5.13",
"description": "Shared utilities for pi packages",
"homepage": "https://github.com/can1357/oh-my-pi",
"author": "Can Boluk",
+17 -7
View File
@@ -8,23 +8,33 @@ function stripHtmlComments(content: string): string {
/** Convert kebab-case to camelCase (e.g. "thinking-level" -> "thinkingLevel") */
function kebabToCamel(key: string): string {
if (!key.includes("-")) return key;
return key.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
}
/** Recursively normalize object keys from kebab-case to camelCase */
function normalizeKeys<T>(obj: T): T {
if (obj === null || typeof obj !== "object") {
return obj;
}
if (obj === null || typeof obj !== "object") return obj;
if (Array.isArray(obj)) {
return obj.map(normalizeKeys) as T;
let changed = false;
const out: unknown[] = new Array(obj.length);
for (let i = 0; i < obj.length; i++) {
const v = obj[i];
const nv = normalizeKeys(v);
out[i] = nv;
if (nv !== v) changed = true;
}
return (changed ? (out as unknown) : obj) as T;
}
let changed = false;
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
const normalizedKey = kebabToCamel(key);
result[normalizedKey] = normalizeKeys(value);
const nk = key.includes("-") ? kebabToCamel(key) : key;
const nv = normalizeKeys(value);
result[nk] = nv;
if (nk !== key || nv !== value) changed = true;
}
return result as T;
return (changed ? result : obj) as T;
}
export class FrontmatterError extends Error {
+10 -1
View File
@@ -418,8 +418,17 @@ function disambiguateClosingBraces(template: string): string {
return template.replace(/\}\}(\}+)/g, "}}{{!---}}$1");
}
const compiledTemplateCache = new Map<string, (context: TemplateContext) => string>();
export function compile(template: string): (context: TemplateContext) => string {
return handlebars.compile(disambiguateClosingBraces(template), { noEscape: true, strict: false });
const disambiguated = disambiguateClosingBraces(template);
const cached = compiledTemplateCache.get(disambiguated);
if (cached) return cached;
const compiled = handlebars.compile(disambiguated, { noEscape: true, strict: false }) as (
context: TemplateContext,
) => string;
compiledTemplateCache.set(disambiguated, compiled);
return compiled;
}
export function render(template: string, context: TemplateContext = {}): string {