diff --git a/bun.lock b/bun.lock index 09b8b70..0e72833 100644 --- a/bun.lock +++ b/bun.lock @@ -17,6 +17,7 @@ "ai": "^6.0.39", "cli-highlight": "^2.1.11", "dedent": "^1.7.1", + "diff": "^8.0.3", "effect": "^3.19.14", "htmlrewriter": "^0.0.13", "ignore": "^7.0.5", @@ -24,6 +25,7 @@ "marked-terminal": "^7.3.0", "react": "^19.2.3", "turndown": "^7.2.2", + "unpdf": "^1.4.0", }, "devDependencies": { "@biomejs/biome": "2.3.11", @@ -35,6 +37,7 @@ "@total-typescript/tsconfig": "^1.0.4", "@tsconfig/bun": "^1.0.10", "@types/bun": "1.3.6", + "@types/diff": "^8.0.0", "@types/json-schema": "^7.0.15", "@types/react": "^19.2.8", "react-devtools-core": "^7.0.1", @@ -81,8 +84,6 @@ "@effect/cluster": ["@effect/cluster@0.56.1", "", { "dependencies": { "kubernetes-types": "^1.30.0" }, "peerDependencies": { "@effect/platform": "^0.94.1", "@effect/rpc": "^0.73.0", "@effect/sql": "^0.49.0", "@effect/workflow": "^0.16.0", "effect": "^3.19.14" } }, "sha512-gnrsH6kfrUjn+82j/bw1IR4yFqJqV8tc7xZvrbJPRgzANycc6K1hu3LMg548uYbUkTzD8YYyqrSatMO1mkQpzw=="], - "@effect/experimental": ["@effect/experimental@0.58.0", "", { "dependencies": { "uuid": "^11.0.3" }, "peerDependencies": { "@effect/platform": "^0.94.0", "effect": "^3.19.13", "ioredis": "^5", "lmdb": "^3" }, "optionalPeers": ["ioredis", "lmdb"] }, "sha512-IEP9sapjF6rFy5TkoqDPc86st/fnqUfjT7Xa3pWJrFGr1hzaMXHo+mWsYOZS9LAOVKnpHuVziDK97EP5qsCHVA=="], - "@effect/language-service": ["@effect/language-service@0.66.1", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-zrH1EQvp+SrDGk245UcVP+jQgsbZmmKmeZEM2EkAkJhldlwucu9EAHufQZWg5cvAwyMBh+9j7p//1wEDdXWwrA=="], "@effect/platform": ["@effect/platform@0.94.1", "", { "dependencies": { "find-my-way-ts": "^0.1.6", "msgpackr": "^1.11.4", "multipasta": "^0.2.7" }, "peerDependencies": { "effect": "^3.19.14" } }, "sha512-SlL8OMTogHmMNnFLnPAHHo3ua1yrB1LNQOVQMiZsqYu9g3216xjr0gn5WoDgCxUyOdZcseegMjWJ7dhm/2vnfg=="], @@ -99,10 +100,6 @@ "@effect/sql": ["@effect/sql@0.49.0", "", { "dependencies": { "uuid": "^11.0.3" }, "peerDependencies": { "@effect/experimental": "^0.58.0", "@effect/platform": "^0.94.0", "effect": "^3.19.13" } }, "sha512-9UEKR+z+MrI/qMAmSvb/RiD9KlgIazjZUCDSpwNgm0lEK9/Q6ExEyfziiYFVCPiptp52cBw8uBHRic8hHnwqXA=="], - "@effect/typeclass": ["@effect/typeclass@0.38.0", "", { "peerDependencies": { "effect": "^3.19.0" } }, "sha512-lMUcJTRtG8KXhXoczapZDxbLK5os7M6rn0zkvOgncJW++A0UyelZfMVMKdT5R+fgpZcsAU/1diaqw3uqLJwGxA=="], - - "@effect/workflow": ["@effect/workflow@0.16.0", "", { "peerDependencies": { "@effect/experimental": "^0.58.0", "@effect/platform": "^0.94.0", "@effect/rpc": "^0.73.0", "effect": "^3.19.13" } }, "sha512-MiAdlxx3TixkgHdbw+Yf1Z3tHAAE0rOQga12kIydJqj05Fnod+W/I+kQGRMY/XWRg+QUsVxhmh1qTr7Ype6lrw=="], - "@jimp/core": ["@jimp/core@1.6.0", "", { "dependencies": { "@jimp/file-ops": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "await-to-js": "^3.0.0", "exif-parser": "^0.1.12", "file-type": "^16.0.0", "mime": "3" } }, "sha512-EQQlKU3s9QfdJqiSrZWNTxBs3rKXgO2W+GxNXDtwchF3a4IqxDheFX1ti+Env9hdJXDiYLp2jTRjlxhPthsk8w=="], "@jimp/diff": ["@jimp/diff@1.6.0", "", { "dependencies": { "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "pixelmatch": "^5.3.0" } }, "sha512-+yUAQ5gvRC5D1WHYxjBHZI7JBRusGGSLf8AmPRPCenTzh4PA+wZ1xv2+cYqQwTfQHU5tXYOhA0xDytfHUf1Zyw=="], @@ -181,49 +178,49 @@ "@opentui-ui/dialog": ["@opentui-ui/dialog@0.1.2", "", { "peerDependencies": { "@opentui/core": "^0.1.69", "@opentui/react": "^0.1.69", "@opentui/solid": "^0.1.69" }, "optionalPeers": ["@opentui/react", "@opentui/solid"] }, "sha512-EZ4FG5u5sxU75+6pcsJsLzsD5JqO05So/1ceZUKUu7nxZ9IF7gcZEi+MU4HnYC9cb2Q7w6hM9y3/iW+jE1C53w=="], - "@opentui/core": ["@opentui/core@0.1.73", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.73", "@opentui/core-darwin-x64": "0.1.73", "@opentui/core-linux-arm64": "0.1.73", "@opentui/core-linux-x64": "0.1.73", "@opentui/core-win32-arm64": "0.1.73", "@opentui/core-win32-x64": "0.1.73", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-1OqLlArzUh3QjrYXGro5WKNgoCcacGJaaFvwOHg5lAOoSigFQRiqEUEEJLbSo3pyV8u7XEdC3M0rOP6K+oThzw=="], + "@opentui/core": ["@opentui/core@0.1.74", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.74", "@opentui/core-darwin-x64": "0.1.74", "@opentui/core-linux-arm64": "0.1.74", "@opentui/core-linux-x64": "0.1.74", "@opentui/core-win32-arm64": "0.1.74", "@opentui/core-win32-x64": "0.1.74", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-g4W16ymv12JdgZ+9B4t7mpIICvzWy2+eHERfmDf80ALduOQCUedKQdULcBFhVCYUXIkDRtIy6CID5thMAah3FA=="], "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.73", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Xnc8S6kGIVcdwqqTq6jk50UVe1QtOXp+B0v4iH85iNW1Ljf198OoA7RcVA+edFb6o01PVwnhIIPtpkB/A4710w=="], - "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.73", "", { "os": "darwin", "cpu": "x64" }, "sha512-RlgxQxu+kxsCZzeXRnpYrqbrpxbG8M/lnDf4sTPWmhXUiuDvY5BdB4YiBY5bv8eNdJ1j9HiMLtx6ZxElEviidA=="], + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.74", "", { "os": "darwin", "cpu": "x64" }, "sha512-WAD8orsDV0ZdW/5GwjOOB4FY96772xbkz+rcV7WRzEFUVaqoBaC04IuqYzS9d5s+cjkbT5Cpj47hrVYkkVQKng=="], - "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.73", "", { "os": "linux", "cpu": "arm64" }, "sha512-9I88BdZMB3qtDPtDzFTg1EEt6sAGFSpOEmIIMB3MhqZqoq9+WSEyJZxM0/kff5vt4RJnqG7vz4fKMVRwNrUPGA=="], + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.74", "", { "os": "linux", "cpu": "arm64" }, "sha512-lgmHzrzLy4e+rgBS+lhtsMLLgIMLbtLNMm6EzVPyYVDlLDGjM7+ulXMem7AtpaRrWrUUl4REiG9BoQUsCFDwYA=="], "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.73", "", { "os": "linux", "cpu": "x64" }, "sha512-50cGZkCh/i3nzijsjUnkmtWJtnJ6l9WpdIwSJsO2Id7nZdzupT1b6AkgGZdOgNl23MHXpAitmb+MhEAjAimCRA=="], - "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.73", "", { "os": "win32", "cpu": "arm64" }, "sha512-mFiEeoiim5cmi6qu8CDfeecl9ivuMilfby/GnqTsr9G8e52qfT6nWF2m9Nevh9ebhXK+D/VnVhJIbObc0WIchA=="], + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.74", "", { "os": "win32", "cpu": "arm64" }, "sha512-dvYUXz03avnI6ZluyLp00HPmR0UT/IE/6QS97XBsgJlUTtpnbKkBtB5jD1NHwWkElaRj1Qv2QP36ngFoJqbl9g=="], "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.73", "", { "os": "win32", "cpu": "x64" }, "sha512-vzWHUi2vgwImuyxl+hlmK0aeCbnwozeuicIcHJE0orPOwp2PAKyR9WO330szAvfIO5ZPbNkjWfh6xIYnASM0lQ=="], - "@opentui/react": ["@opentui/react@0.1.73", "", { "dependencies": { "@opentui/core": "0.1.73", "react-reconciler": "^0.32.0" }, "peerDependencies": { "react": ">=19.0.0", "react-devtools-core": "^7.0.1", "ws": "^8.18.0" } }, "sha512-DdtsJqlhLOjGRpD0vOv7n1ec8nT+HT/e4ozHYR1EgGZtA/kddfLZ/kpCQEY+RJr6jMJ7UdsBxgAo5x4k2C7slA=="], + "@opentui/react": ["@opentui/react@0.1.74", "", { "dependencies": { "@opentui/core": "0.1.74", "react-reconciler": "^0.32.0" }, "peerDependencies": { "react": ">=19.0.0", "react-devtools-core": "^7.0.1", "ws": "^8.18.0" } }, "sha512-2wiTVtBcbjNuWJjVDaSNdfVM9x9Cs7U+wCRPMmzVrYYCbWGjYQcA0Ump+XSKJpN+swzZRDBYHIw9xBlgUUnoLw=="], - "@parcel/watcher": ["@parcel/watcher@2.5.1", "", { "dependencies": { "detect-libc": "^1.0.3", "is-glob": "^4.0.3", "micromatch": "^4.0.5", "node-addon-api": "^7.0.0" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.1", "@parcel/watcher-darwin-arm64": "2.5.1", "@parcel/watcher-darwin-x64": "2.5.1", "@parcel/watcher-freebsd-x64": "2.5.1", "@parcel/watcher-linux-arm-glibc": "2.5.1", "@parcel/watcher-linux-arm-musl": "2.5.1", "@parcel/watcher-linux-arm64-glibc": "2.5.1", "@parcel/watcher-linux-arm64-musl": "2.5.1", "@parcel/watcher-linux-x64-glibc": "2.5.1", "@parcel/watcher-linux-x64-musl": "2.5.1", "@parcel/watcher-win32-arm64": "2.5.1", "@parcel/watcher-win32-ia32": "2.5.1", "@parcel/watcher-win32-x64": "2.5.1" } }, "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg=="], + "@parcel/watcher": ["@parcel/watcher@2.5.4", "", { "dependencies": { "detect-libc": "^2.0.3", "is-glob": "^4.0.3", "node-addon-api": "^7.0.0", "picomatch": "^4.0.3" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.4", "@parcel/watcher-darwin-arm64": "2.5.4", "@parcel/watcher-darwin-x64": "2.5.4", "@parcel/watcher-freebsd-x64": "2.5.4", "@parcel/watcher-linux-arm-glibc": "2.5.4", "@parcel/watcher-linux-arm-musl": "2.5.4", "@parcel/watcher-linux-arm64-glibc": "2.5.4", "@parcel/watcher-linux-arm64-musl": "2.5.4", "@parcel/watcher-linux-x64-glibc": "2.5.4", "@parcel/watcher-linux-x64-musl": "2.5.4", "@parcel/watcher-win32-arm64": "2.5.4", "@parcel/watcher-win32-ia32": "2.5.4", "@parcel/watcher-win32-x64": "2.5.4" } }, "sha512-WYa2tUVV5HiArWPB3ydlOc4R2ivq0IDrlqhMi3l7mVsFEXNcTfxYFPIHXHXIh/ca/y/V5N4E1zecyxdIBjYnkQ=="], - "@parcel/watcher-android-arm64": ["@parcel/watcher-android-arm64@2.5.1", "", { "os": "android", "cpu": "arm64" }, "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA=="], + "@parcel/watcher-android-arm64": ["@parcel/watcher-android-arm64@2.5.4", "", { "os": "android", "cpu": "arm64" }, "sha512-hoh0vx4v+b3BNI7Cjoy2/B0ARqcwVNrzN/n7DLq9ZB4I3lrsvhrkCViJyfTj/Qi5xM9YFiH4AmHGK6pgH1ss7g=="], - "@parcel/watcher-darwin-arm64": ["@parcel/watcher-darwin-arm64@2.5.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw=="], + "@parcel/watcher-darwin-arm64": ["@parcel/watcher-darwin-arm64@2.5.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-kphKy377pZiWpAOyTgQYPE5/XEKVMaj6VUjKT5VkNyUJlr2qZAn8gIc7CPzx+kbhvqHDT9d7EqdOqRXT6vk0zw=="], - "@parcel/watcher-darwin-x64": ["@parcel/watcher-darwin-x64@2.5.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg=="], + "@parcel/watcher-darwin-x64": ["@parcel/watcher-darwin-x64@2.5.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-UKaQFhCtNJW1A9YyVz3Ju7ydf6QgrpNQfRZ35wNKUhTQ3dxJ/3MULXN5JN/0Z80V/KUBDGa3RZaKq1EQT2a2gg=="], - "@parcel/watcher-freebsd-x64": ["@parcel/watcher-freebsd-x64@2.5.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ=="], + "@parcel/watcher-freebsd-x64": ["@parcel/watcher-freebsd-x64@2.5.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Dib0Wv3Ow/m2/ttvLdeI2DBXloO7t3Z0oCp4bAb2aqyqOjKPPGrg10pMJJAQ7tt8P4V2rwYwywkDhUia/FgS+Q=="], - "@parcel/watcher-linux-arm-glibc": ["@parcel/watcher-linux-arm-glibc@2.5.1", "", { "os": "linux", "cpu": "arm" }, "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA=="], + "@parcel/watcher-linux-arm-glibc": ["@parcel/watcher-linux-arm-glibc@2.5.4", "", { "os": "linux", "cpu": "arm" }, "sha512-I5Vb769pdf7Q7Sf4KNy8Pogl/URRCKu9ImMmnVKYayhynuyGYMzuI4UOWnegQNa2sGpsPSbzDsqbHNMyeyPCgw=="], - "@parcel/watcher-linux-arm-musl": ["@parcel/watcher-linux-arm-musl@2.5.1", "", { "os": "linux", "cpu": "arm" }, "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q=="], + "@parcel/watcher-linux-arm-musl": ["@parcel/watcher-linux-arm-musl@2.5.4", "", { "os": "linux", "cpu": "arm" }, "sha512-kGO8RPvVrcAotV4QcWh8kZuHr9bXi9a3bSZw7kFarYR0+fGliU7hd/zevhjw8fnvIKG3J9EO5G6sXNGCSNMYPQ=="], - "@parcel/watcher-linux-arm64-glibc": ["@parcel/watcher-linux-arm64-glibc@2.5.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w=="], + "@parcel/watcher-linux-arm64-glibc": ["@parcel/watcher-linux-arm64-glibc@2.5.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-KU75aooXhqGFY2W5/p8DYYHt4hrjHZod8AhcGAmhzPn/etTa+lYCDB2b1sJy3sWJ8ahFVTdy+EbqSBvMx3iFlw=="], - "@parcel/watcher-linux-arm64-musl": ["@parcel/watcher-linux-arm64-musl@2.5.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg=="], + "@parcel/watcher-linux-arm64-musl": ["@parcel/watcher-linux-arm64-musl@2.5.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qx8uNiIekVutnzbVdrgSanM+cbpDD3boB1f8vMtnuG5Zau4/bdDbXyKwIn0ToqFhIuob73bcxV9NwRm04/hzHQ=="], - "@parcel/watcher-linux-x64-glibc": ["@parcel/watcher-linux-x64-glibc@2.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A=="], + "@parcel/watcher-linux-x64-glibc": ["@parcel/watcher-linux-x64-glibc@2.5.4", "", { "os": "linux", "cpu": "x64" }, "sha512-UYBQvhYmgAv61LNUn24qGQdjtycFBKSK3EXr72DbJqX9aaLbtCOO8+1SkKhD/GNiJ97ExgcHBrukcYhVjrnogA=="], - "@parcel/watcher-linux-x64-musl": ["@parcel/watcher-linux-x64-musl@2.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg=="], + "@parcel/watcher-linux-x64-musl": ["@parcel/watcher-linux-x64-musl@2.5.4", "", { "os": "linux", "cpu": "x64" }, "sha512-YoRWCVgxv8akZrMhdyVi6/TyoeeMkQ0PGGOf2E4omODrvd1wxniXP+DBynKoHryStks7l+fDAMUBRzqNHrVOpg=="], - "@parcel/watcher-win32-arm64": ["@parcel/watcher-win32-arm64@2.5.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw=="], + "@parcel/watcher-win32-arm64": ["@parcel/watcher-win32-arm64@2.5.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-iby+D/YNXWkiQNYcIhg8P5hSjzXEHaQrk2SLrWOUD7VeC4Ohu0WQvmV+HDJokZVJ2UjJ4AGXW3bx7Lls9Ln4TQ=="], - "@parcel/watcher-win32-ia32": ["@parcel/watcher-win32-ia32@2.5.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ=="], + "@parcel/watcher-win32-ia32": ["@parcel/watcher-win32-ia32@2.5.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-vQN+KIReG0a2ZDpVv8cgddlf67J8hk1WfZMMP7sMeZmJRSmEax5xNDNWKdgqSe2brOKTQQAs3aCCUal2qBHAyg=="], - "@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.1", "", { "os": "win32", "cpu": "x64" }, "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA=="], + "@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.4", "", { "os": "win32", "cpu": "x64" }, "sha512-3A6efb6BOKwyw7yk9ro2vus2YTt2nvcd56AuzxdMiVOxL9umDyN5PKkKfZ/gZ9row41SjVmTVQNWQhaRRGpOKw=="], "@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="], @@ -239,9 +236,11 @@ "@types/bun": ["@types/bun@1.3.6", "", { "dependencies": { "bun-types": "1.3.6" } }, "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA=="], + "@types/diff": ["@types/diff@8.0.0", "", { "dependencies": { "diff": "*" } }, "sha512-o7jqJM04gfaYrdCecCVMbZhNdG6T1MHg/oQoRFdERLV+4d+V7FijhiEAbFu0Usww84Yijk9yH58U4Jk4HbtzZw=="], + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], - "@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="], + "@types/node": ["@types/node@25.0.9", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw=="], "@types/react": ["@types/react@19.2.8", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg=="], @@ -249,7 +248,7 @@ "@vercel/oidc": ["@vercel/oidc@3.1.0", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="], - "@webgpu/types": ["@webgpu/types@0.1.68", "", {}, "sha512-3ab1B59Ojb6RwjOspYLsTpCzbNB3ZaamIAxBMmvnNkiDoLTZUOBXZ9p5nAYVEkQlDdf6qAZWi1pqj9+ypiqznA=="], + "@webgpu/types": ["@webgpu/types@0.1.69", "", {}, "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ=="], "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], @@ -271,8 +270,6 @@ "bmp-ts": ["bmp-ts@1.0.9", "", {}, "sha512-cTEHk2jLrPyi+12M3dhpEbnnPOsaZuq7C45ylbbQIiWgDFZq4UVYPEY5mlqjvsj/6gJv9qX5sa+ebDzLXT28Vw=="], - "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], - "buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], "bun-ffi-structs": ["bun-ffi-structs@0.1.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-Lh1oQAYHDcnesJauieA4UNkWGXY9hYck7OA5IaRwE3Bp6K2F2pJSNYqq+hIy7P3uOvo3km3oxS8304g5gDMl/w=="], @@ -307,9 +304,9 @@ "dedent": ["dedent@1.7.1", "", { "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg=="], - "detect-libc": ["detect-libc@1.0.3", "", { "bin": { "detect-libc": "./bin/detect-libc.js" } }, "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg=="], + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], - "diff": ["diff@8.0.2", "", {}, "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg=="], + "diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="], "effect": ["effect@3.19.14", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-3vwdq0zlvQOxXzXNKRIPKTqZNMyGCdaFUBfMPqpsyzZDre67kgC1EEHDV4EoQTovJ4w5fmJW756f86kkuz7WFA=="], @@ -333,8 +330,6 @@ "file-type": ["file-type@16.5.4", "", { "dependencies": { "readable-web-to-node-stream": "^3.0.0", "strtok3": "^6.2.4", "token-types": "^4.1.1" } }, "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw=="], - "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], - "find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="], "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], @@ -361,8 +356,6 @@ "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], - "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], - "jimp": ["jimp@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/diff": "1.6.0", "@jimp/js-bmp": "1.6.0", "@jimp/js-gif": "1.6.0", "@jimp/js-jpeg": "1.6.0", "@jimp/js-png": "1.6.0", "@jimp/js-tiff": "1.6.0", "@jimp/plugin-blit": "1.6.0", "@jimp/plugin-blur": "1.6.0", "@jimp/plugin-circle": "1.6.0", "@jimp/plugin-color": "1.6.0", "@jimp/plugin-contain": "1.6.0", "@jimp/plugin-cover": "1.6.0", "@jimp/plugin-crop": "1.6.0", "@jimp/plugin-displace": "1.6.0", "@jimp/plugin-dither": "1.6.0", "@jimp/plugin-fisheye": "1.6.0", "@jimp/plugin-flip": "1.6.0", "@jimp/plugin-hash": "1.6.0", "@jimp/plugin-mask": "1.6.0", "@jimp/plugin-print": "1.6.0", "@jimp/plugin-quantize": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/plugin-rotate": "1.6.0", "@jimp/plugin-threshold": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0" } }, "sha512-YcwCHw1kiqEeI5xRpDlPPBGL2EOpBKLwO4yIBJcXWHPj5PnA5urGq0jbyhM5KoNpypQ6VboSoxc9D8HyfvngSg=="], "jpeg-js": ["jpeg-js@0.4.4", "", {}, "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg=="], @@ -375,8 +368,6 @@ "marked-terminal": ["marked-terminal@7.3.0", "", { "dependencies": { "ansi-escapes": "^7.0.0", "ansi-regex": "^6.1.0", "chalk": "^5.4.1", "cli-highlight": "^2.1.11", "cli-table3": "^0.6.5", "node-emoji": "^2.2.0", "supports-hyperlinks": "^3.1.0" }, "peerDependencies": { "marked": ">=1 <16" } }, "sha512-t4rBvPsHc57uE/2nJOLmMbZCQ4tgAccAED3ngXQqW6g+TxA488JzJ+FK3lQkzBQOI1mRV/r/Kq+1ZlJ4D0owQw=="], - "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], - "mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], "msgpackr": ["msgpackr@1.11.8", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.2" } }, "sha512-bC4UGzHhVvgDNS7kn9tV8fAucIYUBuGojcaLiz7v+P63Lmtm0Xeji8B/8tYKddALXxJLpwIeBmUN3u64C4YkRA=="], @@ -413,7 +404,7 @@ "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], - "picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], "pixelmatch": ["pixelmatch@5.3.0", "", { "dependencies": { "pngjs": "^6.0.0" }, "bin": { "pixelmatch": "bin/pixelmatch" } }, "sha512-o8mkY4E/+LNUf6LzX96ht6k6CEDi65k9G2rjMtBe9Oo+VPKSvl+0GKHuH/AlG+GA5LPG/i5hrekkxUc3s2HU+Q=="], @@ -439,7 +430,7 @@ "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], - "sax": ["sax@1.4.3", "", {}, "sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ=="], + "sax": ["sax@1.4.4", "", {}, "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw=="], "scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="], @@ -473,8 +464,6 @@ "tinycolor2": ["tinycolor2@1.6.0", "", {}, "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw=="], - "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], - "token-types": ["token-types@4.2.1", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ=="], "toml": ["toml@3.0.0", "", {}, "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w=="], @@ -487,6 +476,8 @@ "unicode-emoji-modifier-base": ["unicode-emoji-modifier-base@1.0.0", "", {}, "sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g=="], + "unpdf": ["unpdf@1.4.0", "", { "peerDependencies": { "@napi-rs/canvas": "^0.1.69" }, "optionalPeers": ["@napi-rs/canvas"] }, "sha512-TahIk0xdH/4jh/MxfclzU79g40OyxtP00VnEUZdEkJoYtXAHWLiir6t3FC6z3vDqQTzc2ZHcla6uEiVTNjejuA=="], + "utif2": ["utif2@4.1.0", "", { "dependencies": { "pako": "^1.0.11" } }, "sha512-+oknB9FHrJ7oW7A2WZYajOcv4FcDR4CfoGB0dPNfxbi4GO05RRnFmt5oa23+9w32EanrYcSJWspUiJkLMs+37w=="], "uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="], @@ -549,14 +540,20 @@ "@jimp/types/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@opentui/core/@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.74", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rfmlDLtm/u17CnuhJgCxPeYMvOST+A2MOdVOk46IurtHO849bdYqK6iudKNlFRs1FOrymgSKF9GlWBHAOKeRjg=="], + + "@opentui/core/@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.74", "", { "os": "linux", "cpu": "x64" }, "sha512-8Mn2WbdBQ29xCThuPZezjDhd1N3+fXwKkGvCBOdTI0le6h2A/vCNbfUVjwfr/EGZSRXxCG+Yapol34BAULGpOA=="], + + "@opentui/core/@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.74", "", { "os": "win32", "cpu": "x64" }, "sha512-3wfWXaAKOIlDQz6ZZIESf2M+YGZ7uFHijjTEM8w/STRlLw8Y6+QyGYi1myHSM4d6RSO+/s2EMDxvjDf899W9vQ=="], + + "@opentui/core/diff": ["diff@8.0.2", "", {}, "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg=="], + "@opentui/react/ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="], "image-q/@types/node": ["@types/node@16.9.1", "", {}, "sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g=="], "marked-terminal/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], - "node-gyp-build-optional-packages/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], - "parse5-htmlparser2-tree-adapter/parse5": ["parse5@6.0.1", "", {}, "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw=="], "pixelmatch/pngjs": ["pngjs@6.0.0", "", {}, "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg=="], diff --git a/package.json b/package.json index c03b522..e7245d1 100644 --- a/package.json +++ b/package.json @@ -28,13 +28,15 @@ "ai": "^6.0.39", "cli-highlight": "^2.1.11", "dedent": "^1.7.1", + "diff": "^8.0.3", "effect": "^3.19.14", "htmlrewriter": "^0.0.13", "ignore": "^7.0.5", "marked": "^16.4.2", "marked-terminal": "^7.3.0", "react": "^19.2.3", - "turndown": "^7.2.2" + "turndown": "^7.2.2", + "unpdf": "^1.4.0" }, "devDependencies": { "@biomejs/biome": "2.3.11", @@ -46,6 +48,7 @@ "@total-typescript/tsconfig": "^1.0.4", "@tsconfig/bun": "^1.0.10", "@types/bun": "1.3.6", + "@types/diff": "^8.0.0", "@types/json-schema": "^7.0.15", "@types/react": "^19.2.8", "react-devtools-core": "^7.0.1" diff --git a/src/commands/utils/workflow-errors.ts b/src/commands/utils/workflow-errors.ts index 6f407bd..b42b50a 100644 --- a/src/commands/utils/workflow-errors.ts +++ b/src/commands/utils/workflow-errors.ts @@ -1,23 +1,6 @@ -import { log, note } from "@clack/prompts"; -import { Effect, Match, Option } from "effect"; +import { log } from "@clack/prompts"; +import { Effect, Match } from "effect"; import { AgentLoopError, AIGenerationError, MaxIterationsReached, UserCancel } from "@/domain/errors"; -import type { WorkflowState } from "@/domain/workflow"; -import { renderMarkdownSnippet } from "@/text/utils"; - -/** - * Represents the current state of a workflow, used for error recovery. - */ -export interface WorkflowSnapshot { - readonly workflowState: WorkflowState; - readonly totalCost: number; -} - -/** - * Result of handling a workflow error. - */ -export type ErrorHandlingResult = - | { readonly _tag: "Rethrow"; readonly error: UserCancel } - | { readonly _tag: "Handled"; readonly savedPath: Option.Option }; /** * Displays a user-friendly error message based on the error type. @@ -46,58 +29,9 @@ export const displayError = (error: unknown): Effect.Effect => ); }); -/** - * Displays the last draft if available. - */ -export const displayLastDraft = (snapshot: WorkflowSnapshot): Effect.Effect> => - Effect.gen(function* () { - const lastDraft = Option.getOrUndefined(snapshot.workflowState.latestDraft); - if (lastDraft && lastDraft.trim().length > 0) { - yield* Effect.sync(() => note(renderMarkdownSnippet(lastDraft), "Last Draft Before Error")); - return Option.some(lastDraft); - } - yield* Effect.sync(() => log.info("No draft was available to save.")); - return Option.none(); - }); - -/** - * Displays summary information after saving a draft. - */ -export const displaySaveSuccess = (savedPath: string, snapshot: WorkflowSnapshot): Effect.Effect => - Effect.sync(() => { - log.success(`Draft saved to: ${savedPath}`); - if (snapshot.workflowState.iterationCount > 0) { - log.info(`Completed ${snapshot.workflowState.iterationCount} iteration(s)`); - } - if (snapshot.totalCost > 0) { - log.info(`Total cost: $${snapshot.totalCost.toFixed(6)}`); - } - }); - /** * Checks if an error should be rethrown (not handled inline). */ export const shouldRethrow = (error: unknown): error is UserCancel => error instanceof UserCancel; -/** - * Checks if an error was already handled (used to exit gracefully). - */ -export const isHandled = ( - result: ErrorHandlingResult, -): result is { _tag: "Handled"; savedPath: Option.Option } => result._tag === "Handled"; - -/** - * Creates a "handled" result. - */ -export const handled = (savedPath: Option.Option): ErrorHandlingResult => ({ - _tag: "Handled", - savedPath, -}); - -/** - * Creates a "rethrow" result. - */ -export const rethrow = (error: UserCancel): ErrorHandlingResult => ({ - _tag: "Rethrow", - error, -}); +export class WorkflowErrorHandled {} diff --git a/src/commands/write.ts b/src/commands/write.ts index a8eb152..57fcd44 100644 --- a/src/commands/write.ts +++ b/src/commands/write.ts @@ -1,20 +1,14 @@ import { cancel, intro, isCancel, log, note, outro, select, spinner, text } from "@clack/prompts"; import { Args, Command, Options } from "@effect/cli"; -import { FileSystem, Path } from "@effect/platform"; -import { BunContext } from "@effect/platform-bun"; -import { Effect, Fiber, Option, Stream } from "effect"; -import { - displayError, - displayLastDraft, - displaySaveSuccess, - shouldRethrow, - type WorkflowSnapshot, -} from "@/commands/utils/workflow-errors"; +import { Chunk, Effect, Fiber, Option, Stream } from "effect"; +import { displayError, shouldRethrow } from "@/commands/utils/workflow-errors"; import { UserCancel, WorkflowErrorHandled } from "@/domain/errors"; import { Messages } from "@/domain/messages"; +import type { FilePatch } from "@/domain/vfs"; import type { AgentEvent, RunResult, UserAction } from "@/services/agent"; import { Agent, reasoningOptions } from "@/services/agent"; import { Config } from "@/services/config"; +import { VFS } from "@/services/vfs"; import { formatWindow, renderMarkdown, renderMarkdownSnippet } from "@/text/utils"; const runPrompt = (promptFn: () => Promise) => @@ -43,31 +37,48 @@ const runPrompt = (promptFn: () => Promise) => ), ); +const formatDiffs = (diffs: ReadonlyArray): string => { + if (diffs.length === 0) return "No changes."; + return diffs + .map((patch) => { + const status = patch.isNew ? " (New)" : patch.isDeleted ? " (Deleted)" : ""; + const hunks = Chunk.toArray(patch.hunks) + .map((h) => h.content) + .join("\n"); + return `=== ${patch.path}${status} ===\n${hunks}`; + }) + .join("\n\n"); +}; + /** * Handle user feedback when AI review approves the draft. * Returns the user's decision to approve or request changes. */ -const getUserFeedback = (draft: string, cycle: number): Effect.Effect => +const getUserFeedback = ( + diffs: ReadonlyArray, + cycle: number, +): Effect.Effect => Effect.gen(function* () { - yield* Effect.sync(() => note(renderMarkdownSnippet(draft), `Draft (Cycle ${cycle}) - Preview`)); + const diffText = formatDiffs(diffs); + yield* Effect.sync(() => note(renderMarkdownSnippet(diffText), `Changes (Cycle ${cycle})`)); const action = yield* runPrompt(() => select({ - message: "AI review approved this draft. What would you like to do?", + message: "AI review approved these changes. What would you like to do?", options: [ { value: "approve", label: "Approve and finalize" }, { value: "reject", label: "Request changes" }, - { value: "view", label: "View full draft" }, + { value: "view", label: "View full diffs" }, ], }), ); if (action === "view") { - yield* Effect.sync(() => note(renderMarkdown(draft), "Full Draft")); + yield* Effect.sync(() => note(renderMarkdownSnippet(diffText), "Full Diffs")); // Re-prompt after viewing const finalAction = yield* runPrompt(() => select({ - message: "What would you like to do with this draft?", + message: "What would you like to do with these changes?", options: [ { value: "approve", label: "Approve and finalize" }, { value: "reject", label: "Request changes" }, @@ -79,7 +90,7 @@ const getUserFeedback = (draft: string, cycle: number): Effect.Effect text({ message: "What changes would you like?", - placeholder: "e.g., Make the tone more formal, add more examples...", + placeholder: "e.g., Fix the typo in the header, rename the variable...", }), ); return { type: "reject" as const, comment }; @@ -101,56 +112,14 @@ const getUserFeedback = (draft: string, cycle: number): Effect.Effect => - Effect.gen(function* () { - const shouldSave = yield* runPrompt(() => - select({ - message: "Would you like to save the draft to a file?", - options: [ - { value: "yes", label: "Yes, save to file" }, - { value: "no", label: "No, discard" }, - ], - }), - ); - - if (shouldSave === "no") { - return undefined; - } - - const defaultFileName = `draft-${new Date().toISOString().replace(/[:.]/g, "-").slice(0, -5)}.md`; - const filePath = yield* runPrompt(() => - text({ - message: "Enter the file path to save the draft:", - placeholder: defaultFileName, - defaultValue: defaultFileName, - }), - ); - - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const fullPath = path.resolve(filePath); - - yield* fs - .writeFileString(fullPath, draft) - .pipe(Effect.mapError((error) => new Error(`Failed to save file: ${String(error)}`))); - - return fullPath; - }); - -/** - * Handle workflow errors by displaying error info and offering to save any draft. + * Handle workflow errors by displaying error info. * Returns WorkflowErrorHandled to signal graceful exit, or rethrows UserCancel. */ const handleWorkflowError = ( error: unknown, - getSnapshot: () => Effect.Effect, s: ReturnType, -): Effect.Effect => + vfs: VFS, +): Effect.Effect => Effect.gen(function* () { yield* Effect.sync(() => s.stop()); @@ -159,25 +128,38 @@ const handleWorkflowError = ( return yield* error; } - // Get current state and display error - const snapshot = yield* getSnapshot(); yield* displayError(error); - // Offer to save draft if one exists - const draftOption = yield* displayLastDraft(snapshot); - - if (Option.isSome(draftOption)) { - const savedPath = yield* promptToSaveDraft(draftOption.value).pipe( - Effect.provide(BunContext.layer), - Effect.catchAll(() => Effect.succeed(undefined)), - ); - - if (savedPath) { - yield* displaySaveSuccess(savedPath, snapshot); - return yield* new WorkflowErrorHandled({ savedPath }); - } - yield* Effect.sync(() => log.info("Draft not saved.")); - } + yield* vfs.getDiffs().pipe( + Effect.flatMap((diffs) => + Effect.gen(function* () { + if (Chunk.size(diffs) > 0) { + const files = Chunk.map(diffs, (d) => d.path).pipe(Chunk.join(", ")); + log.warn(`There are unsaved changes in: ${files}`); + + const shouldSave = yield* runPrompt(() => + select({ + message: "Would you like to save these changes?", + options: [ + { value: "yes", label: "Yes, save changes" }, + { value: "no", label: "No, discard" }, + ], + }), + ); + + if (shouldSave === "yes") { + const savedFiles = yield* vfs.flush(); + yield* Effect.sync(() => { + log.success(`Saved ${savedFiles.length} file(s): ${savedFiles.join(", ")}`); + }); + } else { + yield* Effect.sync(() => log.info("Changes discarded.")); + } + } + }), + ), + Effect.catchAll((e) => Effect.sync(() => log.error(`Failed to check for unsaved changes: ${e}`))), + ); return yield* new WorkflowErrorHandled({}); }); @@ -243,6 +225,7 @@ export const writeCommand = Command.make( } const agent = yield* Agent; + const vfs = yield* VFS; const s = spinner(); yield* Effect.sync(() => s.start("Initializing agent...")); @@ -298,7 +281,7 @@ export const writeCommand = Command.make( case "UserActionRequired": { yield* Effect.sync(() => s.stop("Awaiting your review...")); - const userAction = yield* getUserFeedback(event.draft, event.cycle); + const userAction = yield* getUserFeedback(event.diffs, event.cycle); yield* agentSession.submitUserAction(userAction); @@ -317,12 +300,10 @@ export const writeCommand = Command.make( } }); - // Process events in the background const eventProcessor = yield* agentSession.events.pipe(Stream.runForEach(processEvent), Effect.fork); - // Wait for the result - handle errors inline where we have access to agentSession const agentResult = yield* agentSession.result.pipe( - Effect.catchAll((error) => handleWorkflowError(error, () => agentSession.getCurrentState(), s)), + Effect.catchAll((error) => handleWorkflowError(error, s, vfs)), ); yield* Fiber.join(eventProcessor); diff --git a/src/domain/constants.ts b/src/domain/constants.ts index 51450b0..a7c10be 100644 --- a/src/domain/constants.ts +++ b/src/domain/constants.ts @@ -21,3 +21,6 @@ export const MAX_LIST_FILE_SIZE_KB = 1024 * 1024; export const EXCERPT_SIZE_KB = 40 * 1024; export const STREAM_WINDOW_SIZE = 80; + +export const MAX_WEB_FETCH_BYTES = 5 * 1024 * 1024; +export const MAX_WEB_FETCH_CHARS = 100_000; diff --git a/src/domain/errors.ts b/src/domain/errors.ts index 7389848..8092654 100644 --- a/src/domain/errors.ts +++ b/src/domain/errors.ts @@ -1,5 +1,10 @@ import { Data } from "effect"; +export class VFSError extends Data.TaggedError("VFSError")<{ + readonly message: string; + readonly cause?: unknown; +}> {} + export class ConfigReadError extends Data.TaggedError("ConfigReadError")<{ readonly cause: unknown; }> {} diff --git a/src/domain/vfs.ts b/src/domain/vfs.ts new file mode 100644 index 0000000..766ef80 --- /dev/null +++ b/src/domain/vfs.ts @@ -0,0 +1,43 @@ +import { type Chunk, Data, type Option } from "effect"; + +// Represents a single file change +export class VirtualFile extends Data.Class<{ + readonly path: string; + readonly content: string; + /** None if new file */ + readonly originalContent: Option.Option; + readonly timestamp: number; +}> {} + +export class DiffHunk extends Data.Class<{ + readonly oldStart: number; + readonly oldLines: number; + readonly newStart: number; + readonly newLines: number; + /** Unified diff format */ + readonly content: string; +}> {} + +/** A patch/diff representation */ +export class FilePatch extends Data.Class<{ + readonly path: string; + readonly hunks: Chunk.Chunk; + readonly isNew: boolean; + readonly isDeleted: boolean; +}> {} + +/** Reviewer comments attached to specific files/lines */ +export class ReviewComment extends Data.Class<{ + readonly id: string; + readonly path: string; + /** None = file-level comment */ + readonly line: Option.Option; + readonly content: string; + readonly timestamp: number; +}> {} + +export class VFSSummary extends Data.Class<{ + readonly fileCount: number; + readonly files: ReadonlyArray; + readonly commentCount: number; +}> {} diff --git a/src/domain/workflow.test.ts b/src/domain/workflow.test.ts deleted file mode 100644 index 3f05a68..0000000 --- a/src/domain/workflow.test.ts +++ /dev/null @@ -1,324 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { Option } from "effect"; -import { DraftGenerated, ReviewCompleted, UserFeedback, WorkflowState } from "@/domain/workflow"; - -describe("WorkflowState", () => { - test("empty state has zero iterations", () => { - const state = WorkflowState.empty; - expect(state.iterationCount).toBe(0); - }); - - test("empty state has no latest draft", () => { - const state = WorkflowState.empty; - expect(Option.isNone(state.latestDraft)).toBe(true); - }); - - test("empty state has no latest feedback", () => { - const state = WorkflowState.empty; - expect(Option.isNone(state.latestFeedback)).toBe(true); - }); - - test("empty state is not approved", () => { - const state = WorkflowState.empty; - expect(state.isApproved).toBe(false); - }); - - test("adding a draft increments iteration count", () => { - const state = WorkflowState.empty.add( - new DraftGenerated({ - cycle: 1, - content: "First draft", - timestamp: Date.now(), - }), - ); - - expect(state.iterationCount).toBe(1); - }); - - test("latestDraft returns the most recent draft content", () => { - const state = WorkflowState.empty - .add( - new DraftGenerated({ - cycle: 1, - content: "First draft", - timestamp: Date.now(), - }), - ) - .add( - new ReviewCompleted({ - cycle: 1, - approved: false, - critique: "Needs improvement", - reasoning: "Not detailed enough", - timestamp: Date.now(), - }), - ) - .add( - new DraftGenerated({ - cycle: 2, - content: "Second draft", - timestamp: Date.now(), - }), - ); - - expect(Option.getOrElse(state.latestDraft, () => "")).toBe("Second draft"); - expect(state.iterationCount).toBe(2); - }); - - test("latestFeedback returns AI critique when not approved", () => { - const state = WorkflowState.empty - .add( - new DraftGenerated({ - cycle: 1, - content: "First draft", - timestamp: Date.now(), - }), - ) - .add( - new ReviewCompleted({ - cycle: 1, - approved: false, - critique: "Needs more examples", - reasoning: "Analysis", - timestamp: Date.now(), - }), - ); - - expect(Option.getOrElse(state.latestFeedback, () => "")).toBe("Needs more examples"); - }); - - test("latestFeedback returns user comment when user rejects", () => { - const state = WorkflowState.empty - .add( - new DraftGenerated({ - cycle: 1, - content: "First draft", - timestamp: Date.now(), - }), - ) - .add( - new ReviewCompleted({ - cycle: 1, - approved: true, - critique: "", - reasoning: "Looks good", - timestamp: Date.now(), - }), - ) - .add( - new UserFeedback({ - action: "reject", - comment: "Make it more formal", - timestamp: Date.now(), - }), - ); - - expect(Option.getOrElse(state.latestFeedback, () => "")).toBe("Make it more formal"); - }); - - test("latestFeedback returns default message when user rejects without comment", () => { - const state = WorkflowState.empty - .add( - new DraftGenerated({ - cycle: 1, - content: "First draft", - timestamp: Date.now(), - }), - ) - .add( - new ReviewCompleted({ - cycle: 1, - approved: true, - critique: "", - reasoning: "Looks good", - timestamp: Date.now(), - }), - ) - .add( - new UserFeedback({ - action: "reject", - timestamp: Date.now(), - }), - ); - - expect(Option.getOrElse(state.latestFeedback, () => "")).toBe("Please revise based on user request."); - }); - - test("isApproved is true when AI review approves", () => { - const state = WorkflowState.empty - .add( - new DraftGenerated({ - cycle: 1, - content: "First draft", - timestamp: Date.now(), - }), - ) - .add( - new ReviewCompleted({ - cycle: 1, - approved: true, - critique: "", - reasoning: "Perfect", - timestamp: Date.now(), - }), - ); - - expect(state.isApproved).toBe(true); - }); - - test("isApproved is true when user approves", () => { - const state = WorkflowState.empty - .add( - new DraftGenerated({ - cycle: 1, - content: "First draft", - timestamp: Date.now(), - }), - ) - .add( - new ReviewCompleted({ - cycle: 1, - approved: true, - critique: "", - reasoning: "Looks good", - timestamp: Date.now(), - }), - ) - .add( - new UserFeedback({ - action: "approve", - timestamp: Date.now(), - }), - ); - - expect(state.isApproved).toBe(true); - }); - - test("isApproved is false after user rejection", () => { - const state = WorkflowState.empty - .add( - new DraftGenerated({ - cycle: 1, - content: "First draft", - timestamp: Date.now(), - }), - ) - .add( - new ReviewCompleted({ - cycle: 1, - approved: true, - critique: "", - reasoning: "Looks good", - timestamp: Date.now(), - }), - ) - .add( - new UserFeedback({ - action: "reject", - comment: "Needs work", - timestamp: Date.now(), - }), - ); - - expect(state.isApproved).toBe(false); - }); - - test("immutability - add returns a new state instance", () => { - const state1 = WorkflowState.empty; - const state2 = state1.add( - new DraftGenerated({ - cycle: 1, - content: "Draft", - timestamp: Date.now(), - }), - ); - - expect(state1).not.toBe(state2); - expect(state1.iterationCount).toBe(0); - expect(state2.iterationCount).toBe(1); - }); - - test("latestReview returns the most recent review", () => { - const review = new ReviewCompleted({ - cycle: 1, - approved: false, - critique: "Test critique", - reasoning: "Test reasoning", - timestamp: 12345, - }); - - const state = WorkflowState.empty - .add( - new DraftGenerated({ - cycle: 1, - content: "Draft", - timestamp: Date.now(), - }), - ) - .add(review); - - const latestReview = Option.getOrNull(state.latestReview); - expect(latestReview).not.toBeNull(); - expect(latestReview?.critique).toBe("Test critique"); - expect(latestReview?.approved).toBe(false); - }); - - test("full workflow cycle - draft, reject, revise, approve", () => { - let state = WorkflowState.empty; - - // Cycle 1: Initial draft - state = state.add( - new DraftGenerated({ - cycle: 1, - content: "Initial draft content", - timestamp: Date.now(), - }), - ); - expect(state.iterationCount).toBe(1); - - // AI rejects - state = state.add( - new ReviewCompleted({ - cycle: 1, - approved: false, - critique: "Too short", - reasoning: "Analysis", - timestamp: Date.now(), - }), - ); - expect(state.isApproved).toBe(false); - expect(Option.getOrElse(state.latestFeedback, () => "")).toBe("Too short"); - - // Cycle 2: Revised draft - state = state.add( - new DraftGenerated({ - cycle: 2, - content: "Expanded draft with more content", - timestamp: Date.now(), - }), - ); - expect(state.iterationCount).toBe(2); - expect(Option.getOrElse(state.latestDraft, () => "")).toBe("Expanded draft with more content"); - - // AI approves - state = state.add( - new ReviewCompleted({ - cycle: 2, - approved: true, - critique: "", - reasoning: "Good", - timestamp: Date.now(), - }), - ); - expect(state.isApproved).toBe(true); - - // User also approves - state = state.add( - new UserFeedback({ - action: "approve", - timestamp: Date.now(), - }), - ); - expect(state.isApproved).toBe(true); - }); -}); diff --git a/src/domain/workflow.ts b/src/domain/workflow.ts deleted file mode 100644 index 0aec634..0000000 --- a/src/domain/workflow.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { Chunk, Data, Option, Schema } from "effect"; - -export class DraftGenerated extends Data.TaggedClass("DraftGenerated")<{ - readonly cycle: number; - readonly content: string; - readonly timestamp: number; -}> {} - -export class ReviewCompleted extends Data.TaggedClass("ReviewCompleted")<{ - readonly cycle: number; - readonly approved: boolean; - readonly critique: string; - readonly reasoning: string; - readonly timestamp: number; -}> {} - -export class UserFeedback extends Data.TaggedClass("UserFeedback")<{ - readonly action: "approve" | "reject"; - readonly comment?: string; - readonly timestamp: number; -}> {} - -export type WorkflowEvent = DraftGenerated | ReviewCompleted | UserFeedback; - -export class ReviewResult extends Schema.Class("ReviewResult")({ - approved: Schema.Boolean.annotations({ description: "True if the reviewed content meets the goal" }), - critique: Schema.String.annotations({ description: "Specific feedback if not approved, empty if approved" }), - reasoning: Schema.String.annotations({ description: "Reasoning behind the review result" }), -}) {} - -export class WorkflowState extends Data.Class<{ - readonly history: Chunk.Chunk; -}> { - static readonly empty = new WorkflowState({ history: Chunk.empty() }); - - get iterationCount(): number { - return Chunk.reduce(this.history, 0, (count, event) => (event._tag === "DraftGenerated" ? count + 1 : count)); - } - - get latestDraft(): Option.Option { - return Chunk.findLast(this.history, (e): e is DraftGenerated => e._tag === "DraftGenerated").pipe( - Option.map((e) => e.content), - ); - } - - get latestFeedback(): Option.Option { - return Chunk.findLast(this.history, (e): e is ReviewCompleted | UserFeedback => { - if (e._tag === "ReviewCompleted" && !e.approved) return true; - if (e._tag === "UserFeedback" && e.action === "reject") return true; - return false; - }).pipe( - Option.map((e) => { - if (e._tag === "ReviewCompleted") return e.critique; - if (e._tag === "UserFeedback") return e.comment ?? "Please revise based on user request."; - return ""; - }), - ); - } - - get latestReview(): Option.Option { - return Chunk.findLast(this.history, (e): e is ReviewCompleted => e._tag === "ReviewCompleted"); - } - - get isApproved(): boolean { - const lastEvent = Chunk.findLast( - this.history, - (e): e is ReviewCompleted | UserFeedback => e._tag === "ReviewCompleted" || e._tag === "UserFeedback", - ); - return Option.match(lastEvent, { - onNone: () => false, - onSome: (e) => { - if (e._tag === "ReviewCompleted") return e.approved; - if (e._tag === "UserFeedback") return e.action === "approve"; - return false; - }, - }); - } - - add(event: WorkflowEvent): WorkflowState { - return new WorkflowState({ - history: Chunk.append(this.history, event), - }); - } -} diff --git a/src/index.ts b/src/index.ts index 9362cda..18fa014 100755 --- a/src/index.ts +++ b/src/index.ts @@ -3,7 +3,6 @@ import { Command } from "@effect/cli"; import { BunRuntime } from "@effect/platform-bun"; import { Effect } from "effect"; import { antigravityCommand } from "@/commands/antigravity"; -import { authCommand } from "@/commands/antigravity/auth"; import { configCommand } from "@/commands/config"; import { writeCommand } from "@/commands/write"; import { TUIStartupError } from "@/domain/errors"; @@ -11,9 +10,14 @@ import { UniversalLayer } from "@/runtime"; import { startTUI } from "@/tui/app"; import { version } from "../package.json"; -const command = Command.make("jot").pipe( +const command = Command.make("jot", {}, () => + Effect.tryPromise({ + try: () => startTUI(), + catch: (error) => new TUIStartupError({ cause: error, message: `Failed to start TUI: ${error}` }), + }), +).pipe( + Command.withSubcommands([writeCommand, configCommand, antigravityCommand]), Command.withDescription("AI Research Assistant CLI"), - Command.withSubcommands([configCommand, writeCommand, authCommand, antigravityCommand]), ); const cli = Command.run(command, { @@ -21,19 +25,6 @@ const cli = Command.run(command, { version: version, }); -const program = Effect.gen(function* () { - const args = process.argv; - - if (args.length <= 2) { - // Launch TUI when no arguments provided - yield* Effect.tryPromise({ - try: () => startTUI(), - catch: (error) => new TUIStartupError({ cause: error, message: `Failed to start TUI: ${error}` }), - }); - } else { - // Run normal CLI with arguments - yield* cli(args); - } -}).pipe(Effect.tapErrorCause((cause) => Effect.logError("Application error", cause))); +const program = cli(process.argv).pipe(Effect.tapErrorCause((cause) => Effect.logError("Application error", cause))); program.pipe(Effect.provide(UniversalLayer), BunRuntime.runMain({ disablePrettyLogger: true })); diff --git a/src/prompts/editor.md b/src/prompts/editor.md deleted file mode 100644 index 6454076..0000000 --- a/src/prompts/editor.md +++ /dev/null @@ -1,5 +0,0 @@ -You are an expert editor tasked with applying approved content to a codebase. -Your goal is to ensure the project files accurately reflect the approved draft. -You have access to tools for reading, writing, searching files, and web browsing for reference. -Work autonomously to create or update the necessary files. -Ensure your changes are precise and follow project conventions. diff --git a/src/prompts/index.ts b/src/prompts/index.ts index 2b3937c..5ce1063 100644 --- a/src/prompts/index.ts +++ b/src/prompts/index.ts @@ -1,9 +1,7 @@ -import editor from "./editor.md" with { type: "file" }; import reviewer from "./reviewer.md" with { type: "file" }; import writer from "./writer.md" with { type: "file" }; export const promptPaths = { - editor, reviewer, writer, }; diff --git a/src/prompts/reviewer.md b/src/prompts/reviewer.md index d5af6a8..e9e5e66 100644 --- a/src/prompts/reviewer.md +++ b/src/prompts/reviewer.md @@ -1,7 +1,20 @@ -You are a strict academic reviewer. -Critique the following text for clarity, accuracy, academic tone, and adherence to formatting standards if applicable. -Check the text for factual errors and typos, make sure all data is backed by existing credible sources. -You may use web search and web fetch tools to verify claims and check for current research when fact-checking. -Do not critique the writer for not providing proof of reading/writing project files. -The output can be formatted in Markdown with code blocks indicating the required changes. -Be constructive but rigorous. +You are a strict reviewer. +Your task is to review the staged changes (diffs) and provide actionable feedback to the writer. + +# Workflow Instructions + +1. Analyze Diffs: Read the provided diffs carefully. If you need more context, use `read_file` or `read_all_diffs`. +2. Provide Feedback: Use the `add_review_comment` tool to leave specific comments on the code. + - **CRITICAL**: The writer will see feedback provided via `add_review_comment` and the `critique` in `reject_changes`. + - Do **NOT** provide feedback in your final text response. It will be lost. + - For general feedback that applies to the whole set of changes, use `reject_changes` with a detailed critique or `add_review_comment` with `line` set to `null` or 0. +3. Finalize: + - If the changes are good: Call `approve_changes` **ONCE** as your final action. + - If changes are needed: Call `reject_changes` **ONCE** as your final action. The `critique` you provide will be passed to the writer. + +# Review Guidelines + +- Check for correctness, potential bugs, and adherence to best practices. +- Be specific in your comments. Explain WHY something is wrong and HOW to fix it. +- Do not hallucinate issues. Verify your claims. +- Be constructive but rigorous. diff --git a/src/prompts/writer.md b/src/prompts/writer.md index bff3a23..9f1e576 100644 --- a/src/prompts/writer.md +++ b/src/prompts/writer.md @@ -30,16 +30,11 @@ Follow these guidelines to ensure the work is of high quality: - Explore: First, explore the project files to understand the structure and context using tools. - Search: Search files to find specific content patterns, existing writing style, bibliography, and formatting. - Research: Use web search and web fetch tools when you need current information, citations, or to verify facts from external sources. - - Write: Draft the content based on the gathered context and research. + - Write: Draft the content based on the gathered context and research. Use tools to save the draft to a Virtual File System (VFS). 2. Revision (When a draft and critique are provided) - - Address the specific points raised in the "Critique to Address". - - Edit the text directly. Fix exactly what the critique identifies: typos, factual corrections, missing citations, stylistic issues. Trust the critique's facts. + - Address both the specific comments and general feedback. + - Edit the text directly using tools. Fix exactly what the critique identifies: typos, factual corrections, missing citations, stylistic issues. Trust the critique's facts. - Your output should be the complete revised draft with all corrections applied. -3. Output Requirements - - Your response should be formatted using Markdown. - - If you wish to edit multiple files, use code blocks with specified file paths and language tags (i.e. `markdown` or `latex`) so that an editor can easily identify and edit the files. - - You do not have tools to directly edit files. Do not worry about this, your work will be saved appropriately. - Do NOT include any responses that are not directly related to the task at hand. diff --git a/src/providers/antigravity/constants.ts b/src/providers/antigravity/constants.ts index edab5d6..47973c0 100644 --- a/src/providers/antigravity/constants.ts +++ b/src/providers/antigravity/constants.ts @@ -13,7 +13,7 @@ export const ANTIGRAVITY_ENDPOINT_PROD = "https://cloudcode-pa.googleapis.com"; export const ANTIGRAVITY_ENDPOINT_DAILY = "https://daily-cloudcode-pa.googleapis.com"; export const ANTIGRAVITY_ENDPOINT_SANDBOX = "https://daily-cloudcode-pa.sandbox.googleapis.com"; -export const ANTIGRAVITY_DEFAULT_ENDPOINT = ANTIGRAVITY_ENDPOINT_DAILY; +export const ANTIGRAVITY_DEFAULT_ENDPOINT = ANTIGRAVITY_ENDPOINT_SANDBOX; export const ANTIGRAVITY_ENDPOINT_FALLBACKS = [ ANTIGRAVITY_ENDPOINT_SANDBOX, ANTIGRAVITY_ENDPOINT_DAILY, diff --git a/src/runtime/index.ts b/src/runtime/index.ts index 7f9ac09..063dad2 100644 --- a/src/runtime/index.ts +++ b/src/runtime/index.ts @@ -10,6 +10,7 @@ import { ProjectFiles } from "@/services/project-files"; import { Prompts } from "@/services/prompts"; import { Session } from "@/services/session"; import { UserDirs } from "@/services/user-dirs"; +import { VFS } from "@/services/vfs"; import { Web } from "@/services/web"; export const UniversalLayer = Layer.mergeAll( @@ -22,6 +23,7 @@ export const UniversalLayer = Layer.mergeAll( Session.Default, UserDirs.Default, Web.Default, + VFS.Default, Clipboard.Default, FetchHttpClient.layer, ).pipe(Layer.provideMerge(BunContext.layer)); @@ -35,6 +37,7 @@ export type UniversalServices = | Session | UserDirs | Web + | VFS | Clipboard | HttpClient.HttpClient | BunContext.BunContext; diff --git a/src/services/agent.test.ts b/src/services/agent.test.ts index 4843a89..3fd5de4 100644 --- a/src/services/agent.test.ts +++ b/src/services/agent.test.ts @@ -1,12 +1,13 @@ import { beforeEach, describe, expect, mock, test } from "bun:test"; -import { Chunk, Effect, Layer, Ref, Stream } from "effect"; +import { Chunk, Effect, Layer, Option, Ref, Stream } from "effect"; import type { MaxIterationsReached } from "@/domain/errors"; +import type { FilePatch } from "@/domain/vfs"; import { Agent, type AgentEvent } from "@/services/agent"; import { TestConfigLayer } from "@/services/config"; import { TestLLM, TestLLMLayer } from "@/services/llm"; import { TestAppLogger } from "@/services/logger"; import { TestPromptsLayer } from "@/services/prompts"; -import { TestSessionLayer } from "@/services/session"; +import { Session, SessionData, TestSessionLayer } from "@/services/session"; import { VFS } from "@/services/vfs"; import { TestWebLayer } from "@/services/web"; import { TestProjectFilesLayer } from "@/test/mocks/project-files"; @@ -43,8 +44,8 @@ describe("Agent Service", () => { return Effect.succeed({ content: "Draft content", cost: 0 }); }); - streamTextMock.mockImplementationOnce(() => - Effect.gen(function* () { + streamTextMock.mockImplementationOnce( + Effect.fn(function* () { const vfs = yield* VFS; yield* vfs.approve(); return { content: "Approved", cost: 0 }; @@ -87,8 +88,8 @@ describe("Agent Service", () => { streamTextMock.mockImplementation(() => Effect.succeed({ content: "Fallback", cost: 0 })); streamTextMock.mockImplementationOnce(() => Effect.succeed({ content: "Draft 1", cost: 0 })); - streamTextMock.mockImplementationOnce(() => - Effect.gen(function* () { + streamTextMock.mockImplementationOnce( + Effect.fn(function* () { const vfs = yield* VFS; yield* vfs.reject(); return { content: "Rejected", cost: 0 }; @@ -96,8 +97,8 @@ describe("Agent Service", () => { ); streamTextMock.mockImplementationOnce(() => Effect.succeed({ content: "Draft 2", cost: 0 })); - streamTextMock.mockImplementationOnce(() => - Effect.gen(function* () { + streamTextMock.mockImplementationOnce( + Effect.fn(function* () { const vfs = yield* VFS; yield* vfs.approve(); return { content: "Approved", cost: 0 }; @@ -129,8 +130,8 @@ describe("Agent Service", () => { test("stops at max iterations", async () => { const streamTextMock = mock(); - streamTextMock.mockImplementation(() => - Effect.gen(function* () { + streamTextMock.mockImplementation( + Effect.fn(function* () { const vfs = yield* VFS; yield* vfs.reject(); return { content: "Draft", cost: 0 }; @@ -145,7 +146,7 @@ describe("Agent Service", () => { const result = yield* runner.result.pipe(Effect.flip); expect(result._tag).toBe("MaxIterationsReached"); - expect((result as MaxIterationsReached).iterations).toBe(2); + expect((result as MaxIterationsReached).iterations).toBe(3); }); await Effect.runPromise(program.pipe(Effect.provide(TestLayer))); @@ -156,8 +157,8 @@ describe("Agent Service", () => { streamTextMock.mockImplementation(() => Effect.succeed({ content: "Fallback", cost: 0 })); streamTextMock.mockImplementationOnce(() => Effect.succeed({ content: "Draft 1", cost: 0 })); - streamTextMock.mockImplementationOnce(() => - Effect.gen(function* () { + streamTextMock.mockImplementationOnce( + Effect.fn(function* () { const vfs = yield* VFS; yield* vfs.approve(); return { content: "Approved", cost: 0 }; @@ -165,8 +166,8 @@ describe("Agent Service", () => { ); streamTextMock.mockImplementationOnce(() => Effect.succeed({ content: "Draft 2", cost: 0 })); - streamTextMock.mockImplementationOnce(() => - Effect.gen(function* () { + streamTextMock.mockImplementationOnce( + Effect.fn(function* () { const vfs = yield* VFS; yield* vfs.approve(); return { content: "Approved", cost: 0 }; @@ -209,8 +210,8 @@ describe("Agent Service", () => { streamTextMock.mockImplementation(() => Effect.succeed({ content: "Fallback", cost: 0 })); streamTextMock.mockImplementationOnce(() => Effect.succeed({ content: "Draft 1", cost: 0 })); - streamTextMock.mockImplementationOnce(() => - Effect.gen(function* () { + streamTextMock.mockImplementationOnce( + Effect.fn(function* () { const vfs = yield* VFS; yield* vfs.approve(); return { content: "Approved", cost: 0 }; @@ -222,12 +223,12 @@ describe("Agent Service", () => { const agent = yield* Agent; const runner = yield* agent.run({ prompt: "Do work" }); - const stateRef = yield* Ref.make(undefined); + const stateRef = yield* Ref.make<{ cycle: number; totalCost: number } | undefined>(undefined); yield* Stream.runCollect( runner.events.pipe( - Stream.tap((event: AgentEvent) => - Effect.gen(function* () { + Stream.tap( + Effect.fn(function* (event: AgentEvent) { if (event._tag === "DraftComplete") { const current = yield* Ref.get(stateRef); if (!current) { @@ -247,9 +248,116 @@ describe("Agent Service", () => { const capturedState = yield* Ref.get(stateRef); expect(capturedState).toBeTruthy(); - expect(capturedState?.workflowState.iterationCount).toBe(1); + expect(capturedState?.cycle).toBe(1); }); await Effect.runPromise(program.pipe(Effect.provide(TestLayer))); }); + + test("resumes session and skips vfs reset if cycle > 1", async () => { + const streamTextMock = mock(); + streamTextMock.mockImplementation(() => Effect.succeed({ content: "Resumed content", cost: 0 })); + TestLLM.streamText = streamTextMock; + + const vfsResetMock = mock(() => Effect.void); + + const MockVFSLayer = Layer.succeed(VFS, { + reset: vfsResetMock, + writeFile: () => Effect.void, + editFile: () => Effect.void, + getSummary: () => Effect.succeed({ files: [], fileCount: 0, commentCount: 0 }), + getDiffs: () => Effect.succeed(Chunk.empty()), + getComments: () => Effect.succeed(Chunk.empty()), + getDecision: () => Effect.succeed(Option.none()), + flush: () => Effect.succeed([]), + readFile: () => Effect.fail(new Error("Not implemented")), + addComment: () => Effect.void, + approve: () => Effect.void, + reject: () => Effect.void, + getFileDiff: () => Effect.dieMessage("Not implemented"), + listFiles: () => Effect.succeed([]), + readDirectory: () => Effect.succeed([]), + } as unknown as typeof VFS.Service); + + const sessionMock = { + create: mock(), + resume: mock(() => + Effect.succeed({ + id: "test-session", + path: "/tmp/test-session.json", + addEntry: () => Effect.void, + addAgentEvent: () => Effect.void, + addToolCall: () => Effect.void, + updateStatus: () => Effect.void, + addCost: () => Effect.void, + getTotalCost: () => Effect.succeed(0), + getToolCalls: () => Effect.succeed([]), + updateIterations: () => Effect.void, + getIterations: () => Effect.succeed(1), + flush: () => Effect.void, + close: () => Effect.void, + }), + ), + list: mock(), + get: mock(() => + Effect.succeed( + new SessionData({ + id: "test-session", + modelWriter: "gpt-4", + modelReviewer: "claude-3", + reasoning: true, + reasoningEffort: "high", + maxIterations: 5, + startedAt: Date.now(), + updatedAt: Date.now(), + iterations: 1, + totalCost: 0, + status: "running", + entries: [ + { + _tag: "UserInput", + prompt: "Test prompt", + timestamp: Date.now(), + }, + { + _tag: "ToolCall", + name: "write_file", + input: { filePath: "/test.txt", content: "hello" }, + output: "ok", + timestamp: Date.now(), + }, + ], + }), + ), + ), + getSessionsDir: mock(), + } as unknown as typeof Session.Service; + + const TestLayerWithResume = Agent.DefaultWithoutDependencies.pipe( + Layer.provideMerge( + Layer.mergeAll( + TestConfigLayer, + TestPromptsLayer, + TestAppLogger, + Layer.succeed(Session, sessionMock), + TestLLMLayer, + MockVFSLayer, + TestWebLayer, + TestProjectFilesLayer, + ), + ), + ); + + const program = Effect.gen(function* () { + const agent = yield* Agent; + const runner = yield* agent.run({ sessionId: "test-session", maxIterations: 2 }); + + yield* Stream.runDrain(runner.events); + yield* runner.result.pipe(Effect.ignore); + }); + + await Effect.runPromise(program.pipe(Effect.provide(TestLayerWithResume))); + + expect(vfsResetMock).not.toHaveBeenCalled(); + }); }); diff --git a/src/services/agent.ts b/src/services/agent.ts index 36d703d..e5a8643 100644 --- a/src/services/agent.ts +++ b/src/services/agent.ts @@ -1,19 +1,24 @@ -import { Deferred, Effect, Fiber, Option, Queue, Ref, Runtime, Schema, Stream } from "effect"; +import type { PlatformError } from "@effect/platform/Error"; +import { Chunk, Deferred, Effect, Fiber, Option, Queue, Ref, Runtime, Schema, Stream } from "effect"; import { DEFAULT_MODEL_REVIEWER, DEFAULT_MODEL_WRITER, MAX_STEP_COUNT } from "@/domain/constants"; import { AgentLoopError, AgentStreamError, + type FileReadError, + type FileWriteError, MaxIterationsReached, NoUserActionPending, UserCancel, + type VFSError, } from "@/domain/errors"; -import { DraftGenerated, ReviewCompleted, ReviewResult, UserFeedback, WorkflowState } from "@/domain/workflow"; +import type { FilePatch } from "@/domain/vfs"; import { Config } from "@/services/config"; import { LLM, type ToolCallRecord } from "@/services/llm"; -import { Prompts } from "@/services/prompts"; -import { Session } from "@/services/session"; +import { Prompts, type WriterContext } from "@/services/prompts"; +import { Session, type SessionHandle } from "@/services/session"; +import { VFS } from "@/services/vfs"; import { Web } from "@/services/web"; -import { edit_tools, explore_tools } from "@/tools"; +import { makeReviewerTools, makeWriterTools } from "@/tools"; export const reasoningOptions = Schema.Literal("low", "medium", "high"); @@ -46,7 +51,7 @@ export type AgentEvent = } | { readonly _tag: "UserActionRequired"; - readonly draft: string; + readonly diffs: ReadonlyArray; readonly cycle: number; } | { @@ -69,10 +74,16 @@ export type AgentEvent = readonly _tag: "IterationLimitReached"; readonly iterations: number; readonly lastDraft: string; + } + | { + readonly _tag: "StateUpdate"; + readonly files: ReadonlyArray; + readonly cost: number; }; export interface RunOptions { - readonly prompt: string; + readonly prompt?: string; + readonly sessionId?: string; readonly modelWriter?: string; readonly modelReviewer?: string; readonly reasoning?: boolean; @@ -83,7 +94,6 @@ export interface RunOptions { export interface RunResult { readonly finalContent: string; readonly iterations: number; - readonly state: WorkflowState; readonly totalCost: number; readonly sessionId: string; readonly sessionPath: string; @@ -95,50 +105,198 @@ export class Agent extends Effect.Service()("services/agent", { const config = yield* Config; const session = yield* Session; const llm = yield* LLM; + const vfs = yield* VFS; + const runtime = yield* Effect.runtime(); return { - /** - * Run the autonomous agent loop. - * Returns a stream of events and a result promise. - */ - run: (options: RunOptions) => - Effect.gen(function* () { - yield* Effect.logInfo("Starting agent run").pipe( - Effect.annotateLogs({ - writer: options.modelWriter, - reviewer: options.modelReviewer, - reasoning: options.reasoning, - maxIterations: options.maxIterations, - }), + run: Effect.fn("run")(function* (options: RunOptions) { + const userConfig = yield* config.get; + + const maxIterations = options.maxIterations ?? userConfig.agentMaxIterations; + const reasoning = options.reasoning ?? true; + const reasoningEffort = options.reasoningEffort ?? "high"; + const modelWriter = options.modelWriter ?? userConfig.writerModel ?? DEFAULT_MODEL_WRITER; + const modelReviewer = options.modelReviewer ?? userConfig.reviewerModel ?? DEFAULT_MODEL_REVIEWER; + + yield* Effect.logInfo("Starting agent run").pipe( + Effect.annotateLogs({ + writer: modelWriter, + reviewer: modelReviewer, + reasoning: reasoning, + maxIterations: maxIterations, + sessionId: options.sessionId, + }), + ); + + const writerModel = yield* llm.createModel({ + name: modelWriter, + role: "writer", + reasoning, + reasoningEffort, + }); + + const reviewerModel = yield* llm.createModel({ + name: modelReviewer, + role: "reviewer", + reasoning, + reasoningEffort, + }); + + let sessionHandle: SessionHandle; + let initialPrompt = options.prompt ?? ""; + let startCycle = 0; + + let initialContext: WriterContext = { + filesRead: [], + filesModified: [], + }; + let initialFeedback: Option.Option = Option.none(); + + const extractContextFromToolCall = ( + name: string, + input: unknown, + output: unknown, + ): Partial => { + if (name === "read_file" && typeof input === "object" && input !== null) { + const filePath = (input as { filePath?: string }).filePath; + if (filePath) { + const summary = + typeof output === "string" + ? output + .split("\n") + .find((l) => l.trim()) + ?.slice(0, 80) + : undefined; + return { filesRead: [{ path: filePath, summary }] }; + } + } + if ( + (name === "write_file" || name === "edit_file") && + typeof input === "object" && + input !== null + ) { + const filePath = (input as { filePath?: string }).filePath; + if (filePath) { + return { filesModified: [filePath] }; + } + } + return {}; + }; + + if (options.sessionId) { + sessionHandle = yield* session.resume(options.sessionId).pipe( + Effect.mapError( + (error) => + new AgentStreamError({ + cause: error, + message: "message" in error ? error.message : "Failed to resume session", + }), + ), ); + const sessionData = yield* session.get(options.sessionId); + if (sessionData) { + startCycle = sessionData.iterations; + if (sessionData.status === "failed") { + startCycle = Math.max(0, startCycle - 1); + } + + let replayCycle = 0; + for (const entry of sessionData.entries) { + if ( + entry._tag === "AgentEvent" && + typeof entry.event === "object" && + entry.event !== null && + "cycle" in entry.event + ) { + replayCycle = (entry.event as { cycle: number }).cycle; + } - const userConfig = yield* config.get; - - const maxIterations = options.maxIterations ?? userConfig.agentMaxIterations; - const reasoning = options.reasoning ?? true; - const reasoningEffort = options.reasoningEffort ?? "high"; - - const writerModel = yield* llm.createModel({ - name: options.modelWriter ?? userConfig.writerModel ?? DEFAULT_MODEL_WRITER, - role: "writer", - reasoning, - reasoningEffort, - }); - - const reviewerModel = yield* llm.createModel({ - name: options.modelReviewer ?? userConfig.reviewerModel ?? DEFAULT_MODEL_REVIEWER, - role: "reviewer", - reasoning, - reasoningEffort, - }); - - const writerModelName = options.modelWriter ?? DEFAULT_MODEL_WRITER; - const reviewerModelName = options.modelReviewer ?? DEFAULT_MODEL_REVIEWER; - const sessionHandle = yield* session + if (entry._tag === "ToolCall" && replayCycle <= startCycle) { + if (entry.name === "write_file") { + const input = entry.input as { filePath: string; content: string }; + if (input?.filePath && typeof input.content === "string") { + yield* vfs.writeFile(input.filePath, input.content, true).pipe(Effect.ignore); + } + } else if (entry.name === "edit_file") { + const input = entry.input as { + filePath: string; + oldString: string; + newString: string; + replaceAll?: boolean; + }; + if ( + input?.filePath && + typeof input.oldString === "string" && + typeof input.newString === "string" + ) { + yield* vfs + .editFile( + input.filePath, + input.oldString, + input.newString, + input.replaceAll ?? false, + ) + .pipe(Effect.ignore); + } + } + } + } + + const promptEntry = sessionData.entries.find((e) => e._tag === "UserInput") as + | { prompt: string } + | undefined; + if (promptEntry) { + initialPrompt = promptEntry.prompt; + } + + for (const entry of sessionData.entries) { + if (entry._tag === "ToolCall") { + const partial = extractContextFromToolCall(entry.name, entry.input, entry.output); + initialContext = { + filesRead: [...initialContext.filesRead, ...(partial.filesRead ?? [])], + filesModified: [...initialContext.filesModified, ...(partial.filesModified ?? [])], + }; + } + if ( + entry._tag === "AgentEvent" && + typeof entry.event === "object" && + entry.event !== null + ) { + const evt = entry.event as AgentEvent; + if (evt._tag === "ReviewComplete") { + if (!evt.approved) { + initialFeedback = Option.some(evt.critique); + } else { + initialFeedback = Option.none(); + } + } + if (evt._tag === "UserInput") { + if (evt.content.startsWith("Rejected:")) { + initialFeedback = Option.some(evt.content.replace("Rejected: ", "")); + } else if (evt.content === "Approved") { + initialFeedback = Option.none(); + } + } + } + } + initialContext = { + ...initialContext, + filesModified: [...new Set(initialContext.filesModified)], + }; + } + } else { + if (!options.prompt) { + return yield* new AgentStreamError({ + message: "Prompt is required for new sessions", + cause: new Error("Missing prompt"), + }); + } + initialPrompt = options.prompt; + sessionHandle = yield* session .create({ prompt: options.prompt, - modelWriter: writerModelName, - modelReviewer: reviewerModelName, + modelWriter, + modelReviewer, reasoning, reasoningEffort, maxIterations, @@ -152,445 +310,395 @@ export class Agent extends Effect.Service()("services/agent", { }), ), ); + } - yield* Effect.logDebug(`Session created: ${sessionHandle.id}`); - - const writerTask = yield* prompts.getWriterTask; - const reviewerTask = yield* prompts.getReviewerTask; - const editorTask = yield* prompts.getEditorTask; - - const eventQueue = yield* Queue.unbounded(); - const userActionDeferred = yield* Ref.make | null>(null); + yield* Effect.logDebug(`Session ID: ${sessionHandle.id}, Cycle: ${startCycle}`); - const stateRef = yield* Ref.make(WorkflowState.empty); + const writerTask = yield* prompts.getWriterTask; + const reviewerTask = yield* prompts.getReviewerTask; - const runtime = yield* Effect.runtime(); + const eventQueue = yield* Queue.unbounded(); + const userActionDeferred = yield* Ref.make | null>(null); - const emitEvent = (event: AgentEvent) => - Effect.all([Queue.offer(eventQueue, event), sessionHandle.addAgentEvent(event)], { - discard: true, - }); + const lastFeedbackRef = yield* Ref.make>(initialFeedback); + const writerContextRef = yield* Ref.make(initialContext); - const saveToolCall = (record: ToolCallRecord) => { - Runtime.runPromise(runtime)( - Effect.all( - [ - sessionHandle.addToolCall(record.name, record.input, record.output), - Queue.offer(eventQueue, { - _tag: "ToolCall", - name: record.name, - input: record.input, - output: record.output, - } as const), - ], - { discard: true }, - ), - ); - }; - - const step = (): Effect.Effect< - string, - AgentLoopError | MaxIterationsReached | UserCancel | AgentStreamError - > => - Effect.gen(function* () { - const state = yield* Ref.get(stateRef); - const cycle = state.iterationCount + 1; - - yield* Effect.logDebug(`Starting agent cycle ${cycle}`); - - if (state.iterationCount >= maxIterations) { - const lastDraft = Option.getOrElse(state.latestDraft, () => ""); - const totalCost = yield* sessionHandle - .getTotalCost() - .pipe(Effect.orElseSucceed(() => 0)); - yield* emitEvent({ - _tag: "IterationLimitReached", - iterations: state.iterationCount, - lastDraft, - }); - return yield* new MaxIterationsReached({ - iterations: state.iterationCount, - lastDraft, - totalCost, - }); - } + const writer_tools = makeWriterTools(runtime); + const reviewer_tools = makeReviewerTools(runtime); - const isRevision = Option.isSome(state.latestDraft); - const latestFeedback = state.latestFeedback; - - // Extract previously read files to provide context during revision - const getSourceContext = Effect.gen(function* () { - if (!isRevision) return undefined; - const toolCalls = yield* sessionHandle.getToolCalls(); - const readCalls = toolCalls.filter( - (tc) => - tc.name === "read_file" && - typeof tc.output === "string" && - typeof tc.input === "object" && - tc.input !== null && - "filePath" in tc.input, - ); - - // Deduplicate by filePath, keeping the latest read - const fileMap = new Map(); - for (const call of readCalls) { - const path = (call.input as { filePath: string }).filePath; - fileMap.set(path, call.output as string); - } - - if (fileMap.size === 0) return undefined; + const emitEvent = (event: AgentEvent) => + Effect.all([Queue.offer(eventQueue, event), sessionHandle.addAgentEvent(event)], { + discard: true, + }); - return Array.from(fileMap.entries()) - .map(([path, content]) => `File: ${path}\n\`\`\`\n${content}\n\`\`\``) - .join("\n\n"); - }); + const broadcastState = Effect.fn("broadcastState")(function* () { + const summary = yield* vfs.getSummary(); + const cost = yield* sessionHandle.getTotalCost().pipe(Effect.orElseSucceed(() => 0)); + yield* emitEvent({ + _tag: "StateUpdate", + files: summary.files, + cost, + }); + }); + + const saveToolCall = (record: ToolCallRecord) => { + Runtime.runPromise(runtime)( + Effect.all( + [ + sessionHandle.addToolCall(record.name, record.input, record.output), + Queue.offer(eventQueue, { + _tag: "ToolCall", + name: record.name, + input: record.input, + output: record.output, + } as const), + Effect.suspend(() => { + const partial = extractContextFromToolCall( + record.name, + record.input, + record.output, + ); + const contextUpdate = + Object.keys(partial).length > 0 + ? Ref.update(writerContextRef, (ctx) => ({ + filesRead: [...ctx.filesRead, ...(partial.filesRead ?? [])], + filesModified: [ + ...new Set([ + ...ctx.filesModified, + ...(partial.filesModified ?? []), + ]), + ], + })) + : Effect.void; + + return Effect.all([contextUpdate, broadcastState()], { discard: true }); + }), + ], + { discard: true }, + ), + ); + }; + + const step = Effect.fn("step")(function* ( + currentCycle: number, + ): Effect.fn.Return< + string, + | AgentStreamError + | AgentLoopError + | MaxIterationsReached + | UserCancel + | PlatformError + | FileReadError + | FileWriteError + | VFSError, + never + > { + const cycle = currentCycle + 1; + yield* sessionHandle.updateIterations(cycle); + + yield* Effect.logDebug(`Starting agent cycle ${cycle}`); + + if (currentCycle === 0) { + yield* emitEvent({ + _tag: "UserInput", + content: initialPrompt, + cycle, + }); + } + + if (cycle > maxIterations) { + const totalCost = yield* sessionHandle.getTotalCost().pipe(Effect.orElseSucceed(() => 0)); + yield* emitEvent({ + _tag: "IterationLimitReached", + iterations: cycle, + lastDraft: "", + }); + return yield* new MaxIterationsReached({ + iterations: cycle, + lastDraft: "", + totalCost, + }); + } - const sourceFiles = yield* getSourceContext; + const isRevision = cycle > 1; - yield* Effect.logDebug("Starting drafting phase", { - isRevision, - hasSourceContext: !!sourceFiles, - }); + yield* emitEvent({ + _tag: "Progress", + message: isRevision ? "Revising changes..." : "Drafting changes...", + cycle, + }); - yield* emitEvent({ - _tag: "Progress", - message: isRevision ? "Revising draft..." : "Drafting initial content...", - cycle, - }); + if (!isRevision) { + yield* vfs.reset(); + } - if (cycle === 1 && !isRevision) { - yield* emitEvent({ - _tag: "UserInput", - content: options.prompt, - cycle, - }); - } + const lastFeedback = yield* Ref.get(lastFeedbackRef); + const lastComments = yield* vfs.getComments(); + const previousContext = yield* Ref.get(writerContextRef); - const writerPrompt = writerTask.render({ - goal: options.prompt, - context: - isRevision && Option.isSome(latestFeedback) - ? { - draft: Option.getOrElse(state.latestDraft, () => ""), - feedback: latestFeedback.value, - sourceFiles, - } - : undefined, - }); + const writerPrompt = writerTask.render({ + goal: initialPrompt, + latestComments: lastComments, + latestFeedback: lastFeedback, + previousContext: isRevision ? Option.some(previousContext) : Option.none(), + }); - const { content: newContent, cost: draftCost } = yield* llm - .streamText( - { - model: writerModel, - system: writerTask.system, - prompt: writerPrompt, - tools: isRevision ? {} : explore_tools, - maxSteps: MAX_STEP_COUNT, - }, - (chunk) => { - Runtime.runSync(runtime)( - Effect.all( - [ - Queue.offer(eventQueue, { - _tag: "StreamChunk", - content: chunk, - phase: "drafting", - } as const), - ], - { discard: true }, - ), - ); - }, - saveToolCall, - ) - .pipe( - Effect.mapError( - (error) => - new AgentLoopError({ - cause: error, - message: error.message, + const { content: _writerOutput, cost: draftCost } = yield* llm + .streamText( + { + model: writerModel, + system: writerTask.system, + prompt: writerPrompt, + tools: writer_tools, + maxSteps: MAX_STEP_COUNT, + }, + (chunk) => { + Runtime.runSync(runtime)( + Effect.all( + [ + Queue.offer(eventQueue, { + _tag: "StreamChunk", + content: chunk, phase: "drafting", - }), + } as const), + ], + { discard: true }, ), ); + }, + saveToolCall, + ) + .pipe( + Effect.mapError( + (error) => + new AgentLoopError({ + cause: error, + message: error.message, + phase: "drafting", + }), + ), + ); - yield* sessionHandle.addCost(draftCost); + yield* sessionHandle.addCost(draftCost); + yield* broadcastState(); - yield* Ref.update(stateRef, (s) => - s.add( - new DraftGenerated({ - cycle, - content: newContent, - timestamp: Date.now(), - }), - ), - ); + const summary = yield* vfs.getSummary(); - yield* Effect.logDebug("Drafting complete", { length: newContent.length }); + yield* Effect.logDebug("Drafting complete", { files: summary.fileCount }); - yield* emitEvent({ - _tag: "DraftComplete", - content: newContent, - cycle, - }); + yield* emitEvent({ + _tag: "DraftComplete", + content: _writerOutput, + cycle, + }); - yield* Effect.logDebug("Starting review phase", { cycle }); - yield* emitEvent({ - _tag: "Progress", - message: "Reviewing draft...", - cycle, - }); + yield* emitEvent({ + _tag: "Progress", + message: "Reviewer inspecting changes...", + cycle, + }); - const reviewPrompt = reviewerTask.render({ - goal: options.prompt, - draft: newContent, - sourceFiles, - }); + const diffs = yield* vfs.getDiffs(); + const reviewPrompt = reviewerTask.render({ + goal: initialPrompt, + diffs, + }); - const { result: reviewResult, cost: reviewCost } = yield* llm - .generateObject({ - model: reviewerModel, - system: reviewerTask.system, - prompt: reviewPrompt, - tools: explore_tools, - schema: ReviewResult, - }) - .pipe( - Effect.mapError( - (error) => - new AgentLoopError({ - cause: error, - message: error.message, + const { content: _reviewOutput, cost: reviewCost } = yield* llm + .streamText( + { + model: reviewerModel, + system: reviewerTask.system, + prompt: reviewPrompt, + tools: reviewer_tools, + maxSteps: MAX_STEP_COUNT, + }, + (chunk) => { + Runtime.runSync(runtime)( + Effect.all( + [ + Queue.offer(eventQueue, { + _tag: "StreamChunk", + content: chunk, phase: "reviewing", - }), + } as const), + ], + { discard: true }, ), ); + }, + saveToolCall, + ) + .pipe( + Effect.mapError( + (error) => + new AgentLoopError({ + cause: error, + message: error.message, + phase: "reviewing", + }), + ), + ); - yield* sessionHandle.addCost(reviewCost); + yield* sessionHandle.addCost(reviewCost); + yield* broadcastState(); + + const decision = yield* vfs.getDecision(); + const comments = yield* vfs.getComments(); + const decisionValue = Option.getOrElse(decision, () => ({ + type: "rejected" as const, + message: undefined as string | undefined, + })); + const approved = decisionValue.type === "approved"; + + yield* emitEvent({ + _tag: "ReviewComplete", + approved, + critique: + decisionValue.type === "rejected" && decisionValue.message + ? decisionValue.message + : `Reviewer left ${Chunk.size(comments)} comments.`, + cycle, + }); - yield* Effect.logDebug("Review complete", { approved: reviewResult.approved }); + if (!approved) { + yield* emitEvent({ + _tag: "Progress", + message: "AI review rejected. Starting revision...", + cycle, + }); + yield* Ref.set( + lastFeedbackRef, + Option.some( + decisionValue.type === "rejected" && decisionValue.message + ? decisionValue.message + : "Please address the review comments.", + ), + ); + return yield* step(cycle); + } - yield* Ref.update(stateRef, (s) => - s.add( - new ReviewCompleted({ - cycle, - approved: reviewResult.approved, - critique: reviewResult.critique, - reasoning: reviewResult.reasoning, - timestamp: Date.now(), - }), - ), - ); + yield* emitEvent({ + _tag: "UserActionRequired", + diffs: Chunk.toArray(diffs), + cycle, + }); - yield* emitEvent({ - _tag: "ReviewComplete", - approved: reviewResult.approved, - critique: reviewResult.critique, - cycle, - }); + const deferred = yield* Deferred.make(); + yield* Ref.set(userActionDeferred, deferred); - if (!reviewResult.approved) { - yield* emitEvent({ - _tag: "Progress", - message: "AI review rejected. Starting revision...", - cycle, - }); - return yield* step(); - } + const userAction = yield* Deferred.await(deferred); - yield* emitEvent({ - _tag: "UserActionRequired", - draft: newContent, - cycle, - }); + yield* emitEvent({ + _tag: "UserInput", + content: + userAction.type === "approve" + ? "Approved" + : `Rejected: ${userAction.comment ?? "No comment"}`, + cycle, + }); - const deferred = yield* Deferred.make(); - yield* Ref.set(userActionDeferred, deferred); + if (userAction.type === "reject") { + yield* emitEvent({ + _tag: "Progress", + message: "User requested changes. Starting revision...", + cycle, + }); + yield* Ref.set(lastFeedbackRef, Option.some(userAction.comment ?? "Please revise.")); + return yield* step(cycle); + } - const userAction = yield* Deferred.await(deferred); + const flushedFiles = yield* vfs.flush(); - yield* emitEvent({ - _tag: "UserInput", - content: - userAction.type === "approve" - ? "Approved" - : `Rejected: ${userAction.comment ?? "No comment"}`, - cycle, - }); + return `Applied ${flushedFiles.length} file(s): ${flushedFiles.join(", ")}`; + }); - yield* Ref.update(stateRef, (s) => - s.add( - new UserFeedback({ - action: userAction.type, - comment: userAction.comment, - timestamp: Date.now(), - }), - ), - ); - - if (userAction.type === "reject") { - yield* emitEvent({ - _tag: "Progress", - message: "User requested changes. Starting revision...", - cycle, - }); - return yield* step(); + const workflowFiber = yield* step(startCycle).pipe( + Effect.tap((content) => sessionHandle.updateStatus("completed", content)), + Effect.tapError( + Effect.fn(function* (error) { + if (error instanceof UserCancel) { + return yield* sessionHandle.updateStatus("cancelled"); } + const message = error instanceof Error ? error.message : String(error); yield* emitEvent({ - _tag: "Progress", - message: "Applying approved changes to project files...", - cycle, - }); - - const editPrompt = editorTask.render({ - goal: options.prompt, - approvedContent: newContent, + _tag: "Error", + message, + cycle: 0, }); - - const { content: _editOutput, cost: editCost } = yield* llm - .streamText( - { - model: writerModel, - system: editorTask.system, - prompt: editPrompt, - tools: edit_tools, - maxSteps: MAX_STEP_COUNT, - }, - (chunk) => { - Runtime.runSync(runtime)( - Effect.all( - [ - Queue.offer(eventQueue, { - _tag: "StreamChunk", - content: chunk, - phase: "editing", - } as const), - ], - { discard: true }, - ), - ); - }, - saveToolCall, - ) - .pipe( - Effect.mapError( - (error) => - new AgentLoopError({ - cause: error, - message: error.message, - phase: "editing", - }), - ), - ); - - yield* sessionHandle.addCost(editCost); - - return newContent; - }); - - const workflowFiber = yield* step().pipe( - Effect.tap((content) => sessionHandle.updateStatus("completed", content)), - Effect.tapError((error) => - Effect.gen(function* () { - if (error instanceof UserCancel) { - return yield* sessionHandle.updateStatus("cancelled"); - } - const message = error instanceof Error ? error.message : String(error); - - yield* emitEvent({ + return yield* sessionHandle + .addEntry({ _tag: "Error", message, - cycle: 0, - }); - return yield* sessionHandle - .addEntry({ - _tag: "Error", - message, - phase: "phase" in error ? String(error.phase) : undefined, - }) - .pipe(Effect.andThen(sessionHandle.updateStatus("failed"))); - }), - ), - Effect.ensuring( - Queue.shutdown(eventQueue).pipe( - Effect.andThen(Effect.logDebug("Event queue shutdown")), - Effect.andThen(sessionHandle.close()), - ), - ), - Effect.fork, - ); - - return { - events: Stream.fromQueue(eventQueue), - sessionId: sessionHandle.id, - sessionPath: sessionHandle.path, - result: Effect.gen(function* () { - const content = yield* Fiber.join(workflowFiber); - const finalState = yield* Ref.get(stateRef); - const totalCost = yield* sessionHandle.getTotalCost().pipe(Effect.orElseSucceed(() => 0)); - return { - finalContent: content, - iterations: finalState.iterationCount, - state: finalState, - totalCost, - sessionId: sessionHandle.id, - sessionPath: sessionHandle.path, - } satisfies RunResult; + phase: "phase" in error ? String(error.phase) : undefined, + }) + .pipe(Effect.andThen(sessionHandle.updateStatus("failed"))); }), - - /** - * Submit user action to continue the workflow. - */ - submitUserAction: (action: UserAction) => - Effect.gen(function* () { - const deferred = yield* Ref.get(userActionDeferred); - if (!deferred) { - return yield* new NoUserActionPending({ - message: - "No user action is pending. The agent may have already completed or not yet reached a user feedback point.", - }); - } - const isDone = yield* Deferred.isDone(deferred); - if (isDone) { - return yield* new NoUserActionPending({ - message: "User action was already submitted for this cycle.", - }); - } - yield* Deferred.succeed(deferred, action); - }), - - /** - * Cancel the workflow and cleanup resources - */ - cancel: () => - Effect.gen(function* () { - const deferred = yield* Ref.get(userActionDeferred); - if (deferred) { - yield* Deferred.fail(deferred, new UserCancel()); - } - yield* Fiber.interrupt(workflowFiber); - yield* Queue.shutdown(eventQueue); - }), - - /** - * Get the current workflow state and cost. - * Useful for retrieving the last draft when an error occurs. - */ - getCurrentState: () => - Effect.gen(function* () { - const workflowState = yield* Ref.get(stateRef); - const totalCost = yield* sessionHandle - .getTotalCost() - .pipe(Effect.orElseSucceed(() => 0)); - return { - workflowState, - totalCost, - }; - }), - }; - }), + ), + Effect.ensuring( + Queue.shutdown(eventQueue).pipe( + Effect.andThen(Effect.logDebug("Event queue shutdown")), + Effect.andThen(sessionHandle.close()), + ), + ), + Effect.fork, + ); + + return { + events: Stream.fromQueue(eventQueue), + sessionId: sessionHandle.id, + sessionPath: sessionHandle.path, + result: Effect.gen(function* () { + const content = yield* Fiber.join(workflowFiber); + const cycle = yield* sessionHandle.getIterations(); + const totalCost = yield* sessionHandle.getTotalCost().pipe(Effect.orElseSucceed(() => 0)); + return { + finalContent: content, + iterations: cycle, + totalCost, + sessionId: sessionHandle.id, + sessionPath: sessionHandle.path, + } satisfies RunResult; + }), + + submitUserAction: Effect.fn("submitUserAction")(function* (action: UserAction) { + const deferred = yield* Ref.get(userActionDeferred); + if (!deferred) { + return yield* new NoUserActionPending({ + message: + "No user action is pending. The agent may have already completed or not yet reached a user feedback point.", + }); + } + const isDone = yield* Deferred.isDone(deferred); + if (isDone) { + return yield* new NoUserActionPending({ + message: "User action was already submitted for this cycle.", + }); + } + yield* Deferred.succeed(deferred, action); + }), + + cancel: Effect.fn("cancel")(function* () { + const deferred = yield* Ref.get(userActionDeferred); + if (deferred) { + yield* Deferred.fail(deferred, new UserCancel()); + } + yield* Fiber.interrupt(workflowFiber); + yield* Queue.shutdown(eventQueue); + }), + + getCurrentState: Effect.fn("getCurrentState")(function* () { + const cycle = yield* sessionHandle.getIterations(); + const totalCost = yield* sessionHandle.getTotalCost().pipe(Effect.orElseSucceed(() => 0)); + return { + cycle, + totalCost, + }; + }), + }; + }), }; }), - dependencies: [Prompts.Default, Config.Default, Session.Default, LLM.Default, Web.Default], + dependencies: [Prompts.Default, Config.Default, Session.Default, LLM.Default, Web.Default, VFS.Default], }) {} diff --git a/src/services/llm.ts b/src/services/llm.ts index f40129d..74306e5 100644 --- a/src/services/llm.ts +++ b/src/services/llm.ts @@ -168,14 +168,6 @@ export class LLM extends Effect.Service()("services/llm", { return yield* AIGenerationError.fromUnknown(streamError); } - if (!accumulatedText || accumulatedText.trim().length === 0) { - return yield* new AIGenerationError({ - cause: null, - message: "Generation failed: Empty response received.", - isRetryable: true, - }); - } - let cost = 0; const metadata = (yield* Effect.tryPromise(() => response.providerMetadata).pipe( Effect.orElseSucceed(() => undefined), diff --git a/src/services/project-files.ts b/src/services/project-files.ts index a62dd5b..8d2251d 100644 --- a/src/services/project-files.ts +++ b/src/services/project-files.ts @@ -49,12 +49,10 @@ export class ProjectFiles extends Effect.Service()("services/Proje const lowerCwd = normCwd.toLowerCase(); if (!lowerResolved.startsWith(lowerCwd)) { - return yield* Effect.fail( - new FileReadError({ - cause: "Access denied", - message: `Access denied: ${resolved} is outside of ${cwd}`, - }), - ); + return yield* new FileReadError({ + cause: "Access denied", + message: `Access denied: ${resolved} is outside of ${cwd}`, + }); } return resolved; diff --git a/src/services/prompts.test.ts b/src/services/prompts.test.ts index af0c6da..6006b1f 100644 --- a/src/services/prompts.test.ts +++ b/src/services/prompts.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { Effect } from "effect"; +import { Chunk, Effect, Option } from "effect"; +import { DiffHunk, FilePatch, ReviewComment } from "@/domain/vfs"; import { Prompts } from "@/services/prompts"; describe("Prompts Service", () => { @@ -33,4 +34,89 @@ describe("Prompts Service", () => { expect(Effect.runPromise(program)).rejects.toThrow(); }); + + test("generates writer task prompt with full context", async () => { + const program = Effect.gen(function* () { + const writerTask = yield* Prompts.getWriterTask; + + const input = { + goal: "Write a test", + latestComments: Chunk.make( + new ReviewComment({ + id: "1", + path: "/src/test.ts", + line: Option.some(10), + content: "Fix this typo", + timestamp: Date.now(), + }), + ), + latestFeedback: Option.some("Please add more tests"), + previousContext: Option.some({ + filesRead: [{ path: "/src/existing.ts", summary: "Existing code" }], + filesModified: ["/src/test.ts"], + }), + }; + + const prompt = writerTask.render(input); + + expect(prompt).toContain("# Task"); + expect(prompt).toContain("Write a test"); + expect(prompt).toContain("Current date:"); + + expect(prompt).toContain("## Context from Previous Iterations"); + expect(prompt).toContain("### Files Read"); + expect(prompt).toContain("- /src/existing.ts: Existing code"); + expect(prompt).toContain("### Files Modified"); + expect(prompt).toContain("- /src/test.ts"); + + expect(prompt).toContain("## Reviewer Feedback"); + expect(prompt).toContain("Please add more tests"); + expect(prompt).toContain("### Specific Comments"); + expect(prompt).toContain("- /src/test.ts:10: Fix this typo"); + }).pipe(Effect.provide(Prompts.Default)); + + await Effect.runPromise(program); + }); + + test("generates reviewer task prompt with diffs", async () => { + const program = Effect.gen(function* () { + const reviewerTask = yield* Prompts.getReviewerTask; + + const diffHunk = new DiffHunk({ + oldStart: 1, + oldLines: 1, + newStart: 1, + newLines: 2, + content: "@@ -1,1 +1,2 @@\n-old\n+new line 1\n+new line 2", + }); + + const input = { + goal: "Review changes", + diffs: Chunk.make( + new FilePatch({ + path: "/src/changed.ts", + hunks: Chunk.make(diffHunk), + isNew: false, + isDeleted: false, + }), + ), + }; + + const prompt = reviewerTask.render(input); + + expect(prompt).toContain("# Task"); + expect(prompt).toContain("Review changes"); + expect(prompt).toContain("Current date:"); + + expect(prompt).toContain("## Staged Changes (Diffs)"); + expect(prompt).toContain("### /src/changed.ts"); + expect(prompt).toContain("```diff"); + expect(prompt).toContain("@@ -1,1 +1,2 @@"); + expect(prompt).toContain("-old"); + expect(prompt).toContain("+new line 1"); + expect(prompt).toContain("+new line 2"); + }).pipe(Effect.provide(Prompts.Default)); + + await Effect.runPromise(program); + }); }); diff --git a/src/services/prompts.ts b/src/services/prompts.ts index 3caaa8c..9dcbe55 100644 --- a/src/services/prompts.ts +++ b/src/services/prompts.ts @@ -1,36 +1,38 @@ import { FileSystem } from "@effect/platform"; import { BunFileSystem } from "@effect/platform-bun"; -import { Effect, Layer } from "effect"; +import { Chunk, Effect, Layer, Option } from "effect"; import { PromptReadError } from "@/domain/errors"; +import type { FilePatch, ReviewComment } from "@/domain/vfs"; import { promptPaths } from "@/prompts"; export type PromptType = keyof typeof promptPaths; -export interface WriterTaskInput { - readonly goal: string; - readonly context?: { - readonly draft: string; - readonly feedback: string; - readonly sourceFiles?: string; - }; +export interface FileContext { + readonly path: string; + readonly summary?: string; } -export interface ReviewerTaskInput { +export interface WriterContext { + readonly filesRead: ReadonlyArray; + readonly filesModified: ReadonlyArray; +} + +export interface WriterTaskInput { readonly goal: string; - readonly draft: string; - readonly sourceFiles?: string; + readonly latestComments: Chunk.Chunk; + readonly latestFeedback: Option.Option; + readonly previousContext: Option.Option; } -export interface EditorTaskInput { +export interface ReviewerTaskInput { readonly goal: string; - readonly approvedContent: string; + readonly diffs: Chunk.Chunk; } export class Prompts extends Effect.Service()("services/prompts", { effect: Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - // Load raw prompt templates const loadRaw = (promptType: PromptType) => Effect.gen(function* () { const promptPath = promptPaths[promptType]; @@ -47,95 +49,115 @@ export class Prompts extends Effect.Service()("services/prompts", { ); return { - /** - * Get raw prompt content by type - */ get: (promptType: PromptType) => loadRaw(promptType), - /** - * Get a function that renders writer task prompts. - * Handles both initial drafting and revision scenarios. - */ getWriterTask: Effect.gen(function* () { const systemPrompt = yield* loadRaw("writer"); return { system: systemPrompt, render: (input: WriterTaskInput): string => { - if (input.context) { - // Revision prompt - include current draft and feedback - const parts = [`Task: ${input.goal}`, "", "## Current Draft", input.context.draft]; - - if (input.context.sourceFiles) { - parts.push("", "## Source Files (from initial exploration)", input.context.sourceFiles); + const parts = [ + "# Task", + "", + input.goal, + "", + "## Environment details", + `Current date: ${formatter.format(new Date())}`, + ]; + + const previousContext = input.previousContext; + if (Option.isSome(previousContext)) { + const ctx = previousContext.value; + parts.push("", "## Context from Previous Iterations"); + + if (ctx.filesRead.length > 0) { + parts.push("### Files Read"); + parts.push( + ctx.filesRead + .map((f) => (f.summary ? `- ${f.path}: ${f.summary}` : `- ${f.path}`)) + .join("\n"), + ); } - parts.push( - "", - "## Critique to Address", - input.context.feedback, - "", - "## Instructions", - "Revise the draft IN-PLACE to address each critique point.", - "Output the complete revised draft.", - ); + if (ctx.filesModified.length > 0) { + parts.push("### Files Modified"); + parts.push(ctx.filesModified.map((f) => `- ${f}`).join("\n")); + } - return parts.join("\n"); + parts.push("", "Use this context to avoid re-reading files unnecessarily."); } - // Initial draft prompt - return [ - `Task: ${input.goal}`, - "", - "Please draft the initial content based on the project context.", - "Use tools to explore the project structure and gather relevant information first.", - ].join("\n"); - }, - }; - }), + const latestComments = input.latestComments; + const latestFeedback = input.latestFeedback; - /** - * Get a function that renders reviewer task prompts. - */ - getReviewerTask: Effect.gen(function* () { - const systemPrompt = yield* loadRaw("reviewer"); + if (Chunk.isNonEmpty(latestComments) || Option.isSome(latestFeedback)) { + parts.push("", "## Reviewer Feedback", ""); - return { - system: systemPrompt, - render: (input: ReviewerTaskInput): string => { - const parts = ["## Original Goal", input.goal, "", "## Draft to Review", input.draft]; + if (Option.isSome(latestFeedback)) { + parts.push(latestFeedback.value, ""); + } - if (input.sourceFiles) { - parts.push("", "## Source Files (from initial exploration)", input.sourceFiles); + if (Chunk.isNonEmpty(latestComments)) { + parts.push( + "### Specific Comments", + Chunk.toArray(latestComments) + .map( + (c) => + `- ${c.path}${Option.isSome(c.line) ? `:${c.line.value}` : ""}: ${c.content}`, + ) + .join("\n"), + ); + } + + parts.push("", "You have to address the feedback and resubmit the work for review."); } - parts.push("", "Evaluate this draft against the original goal."); + parts.push( + "", + "Use tools to explore the project, then use write_file/edit_file to make changes.", + "Changes are staged and will be reviewed before applying.", + ); return parts.join("\n"); }, }; }), - /** - * Get a function that renders editor task prompts. - */ - getEditorTask: Effect.gen(function* () { - const systemPrompt = yield* loadRaw("editor"); + getReviewerTask: Effect.gen(function* () { + const systemPrompt = yield* loadRaw("reviewer"); return { system: systemPrompt, - render: (input: EditorTaskInput): string => { + render: (input: ReviewerTaskInput): string => { + const formatPatch = (patch: FilePatch): string => { + return Chunk.head(patch.hunks).pipe( + Option.map((h) => h.content), + Option.getOrElse(() => ""), + ); + }; + return [ + "# Task", + "", + "You are a strict academic reviewer.", + "Your task is to review the changes to files and provide feedback.", + "Use tools to provide specific feedback to exact lines of text", + "or provide general feedback to the entire set of changes.", + "You can approve or reject the work and ask for additional changes.", + "As a last action, you must ALWAYS call either approve_changes or reject_changes.", + "", "## Original Goal", + "", input.goal, "", - "## Approved Content", - input.approvedContent, + "## Staged Changes (Diffs)", + Chunk.toArray(input.diffs) + .map((p) => `### ${p.path}\n\`\`\`diff\n${formatPatch(p)}\n\`\`\``) + .join("\n\n"), "", - "Please apply the approved content to the project files using the available tools.", - "You may need to create new files or edit existing ones.", - "You HAVE TO save the changes, if none of the files seem fitting, just save it in the CWD with a descriptive name.", - "When you have finished, provide a brief summary of the changes.", + "## Environment details", + `Current date: ${formatter.format(new Date())}`, ].join("\n"); }, }; @@ -146,16 +168,17 @@ export class Prompts extends Effect.Service()("services/prompts", { accessors: true, }) {} +const formatter = new Intl.DateTimeFormat(undefined, { + dateStyle: "full", + timeStyle: "short", +}); + export const TestPrompts = new Prompts({ get: () => Effect.succeed("prompt"), getWriterTask: Effect.succeed({ render: (_: WriterTaskInput) => "prompt", system: "system", }), - getEditorTask: Effect.succeed({ - render: (_: EditorTaskInput) => "prompt", - system: "system", - }), getReviewerTask: Effect.succeed({ render: (_: ReviewerTaskInput) => "prompt", system: "system", diff --git a/src/services/session.ts b/src/services/session.ts index 2930609..603bac8 100644 --- a/src/services/session.ts +++ b/src/services/session.ts @@ -30,7 +30,6 @@ const ErrorEntry = Schema.TaggedStruct("Error", { const SessionEntryBase = Schema.Union(UserInput, AgentEventEntry, ToolCall, ModelResponse, ErrorEntry); export type SessionEntryInput = Schema.Schema.Type; -// Full entry schema with timestamp - used for storage const withTimestamp = (s: Schema.Struct) => Schema.Struct({ ...s.fields, timestamp: Schema.Number }); @@ -90,12 +89,136 @@ export interface SessionHandle { readonly getTotalCost: () => Effect.Effect; readonly getToolCalls: () => Effect.Effect>; readonly updateIterations: (iterations: number) => Effect.Effect; + readonly getIterations: () => Effect.Effect; readonly flush: () => Effect.Effect; readonly close: () => Effect.Effect; } const SAVE_INTERVAL_MS = 500; +const makeSessionHandle = (fs: FileSystem.FileSystem, id: string, sessionPath: string, initialData: SessionData) => + Effect.gen(function* () { + const stateRef = yield* Ref.make(initialData); + const dirtyRef = yield* Ref.make(true); + + const doSave = Effect.gen(function* () { + const isDirty = yield* Ref.getAndSet(dirtyRef, false); + if (!isDirty) return; + + const data = yield* Ref.get(stateRef); + yield* fs + .writeFileString(sessionPath, JSON.stringify(data, null, 2)) + .pipe(Effect.catchAll((error) => Effect.logWarning(`Session save failed: ${error}`))); + }).pipe(Effect.uninterruptible); + + const backgroundSaver = Effect.forever( + Effect.sleep(`${SAVE_INTERVAL_MS} millis`).pipe(Effect.zipRight(doSave)), + ); + + const saverFiber = yield* Effect.forkDaemon(backgroundSaver); + + const markDirty = Ref.set(dirtyRef, true); + + const updateState = (f: (data: SessionData) => SessionData): Effect.Effect => + Ref.update(stateRef, f).pipe(Effect.zipRight(markDirty)); + + const flush = Effect.gen(function* () { + yield* Ref.set(dirtyRef, true); + yield* doSave; + }); + + const close = Effect.gen(function* () { + yield* Fiber.interrupt(saverFiber); + yield* flush; + }); + + const handle: SessionHandle = { + id, + path: sessionPath, + + addEntry: (entry) => + updateState((data) => { + const timestamp = Date.now(); + const fullEntry = { ...entry, timestamp } as SessionEntry; + return new SessionData({ + ...data, + updatedAt: timestamp, + entries: [...data.entries, fullEntry], + }); + }), + + addAgentEvent: (event) => + updateState((data) => { + const timestamp = Date.now(); + return new SessionData({ + ...data, + updatedAt: timestamp, + entries: [...data.entries, { _tag: "AgentEvent", event, timestamp }], + }); + }), + + addToolCall: (name, input, output) => + updateState((data) => { + const timestamp = Date.now(); + return new SessionData({ + ...data, + updatedAt: timestamp, + entries: [...data.entries, { _tag: "ToolCall", name, input, output, timestamp }], + }); + }), + + updateStatus: (status, finalContent) => + updateState((data) => { + const now = Date.now(); + return new SessionData({ + ...data, + status, + updatedAt: now, + completedAt: status !== "running" ? now : data.completedAt, + finalContent: finalContent ?? data.finalContent, + }); + }), + + addCost: (cost) => + updateState( + (data) => + new SessionData({ + ...data, + totalCost: data.totalCost + cost, + updatedAt: Date.now(), + }), + ), + + getTotalCost: () => Ref.get(stateRef).pipe(Effect.map((d) => d.totalCost)), + + getToolCalls: () => + Ref.get(stateRef).pipe( + Effect.map((d) => + d.entries + .filter((e) => e._tag === "ToolCall") + .map((e) => ({ name: e.name, input: e.input, output: e.output })), + ), + ), + + updateIterations: (iterations) => + updateState( + (data) => + new SessionData({ + ...data, + iterations, + updatedAt: Date.now(), + }), + ), + + getIterations: () => Ref.get(stateRef).pipe(Effect.map((d) => d.iterations)), + + flush: () => flush, + close: () => close, + }; + + return handle; + }); + export class Session extends Effect.Service()("services/session", { effect: Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -136,125 +259,35 @@ export class Session extends Effect.Service()("services/session", { ], }); - const stateRef = yield* Ref.make(initialData); - const dirtyRef = yield* Ref.make(true); + yield* fs + .writeFileString(sessionPath, JSON.stringify(initialData, null, 2)) + .pipe(Effect.catchAll((error) => Effect.logWarning(`Initial session save failed: ${error}`))); - const doSave = Effect.gen(function* () { - const isDirty = yield* Ref.getAndSet(dirtyRef, false); - if (!isDirty) return; + yield* Effect.logDebug(`Created session: ${id}`); - const data = yield* Ref.get(stateRef); - yield* fs - .writeFileString(sessionPath, JSON.stringify(data, null, 2)) - .pipe(Effect.catchAll((error) => Effect.logWarning(`Session save failed: ${error}`))); - }).pipe(Effect.uninterruptible); + return yield* makeSessionHandle(fs, id, sessionPath, initialData); + }), - const backgroundSaver = Effect.forever( - Effect.sleep(`${SAVE_INTERVAL_MS} millis`).pipe(Effect.zipRight(doSave)), + resume: (id: string): Effect.Effect => + Effect.gen(function* () { + const sessionPath = yield* userDirs.getPath("data", DIR_NAME.SESSIONS, `${id}.json`); + const json = yield* fs + .readFileString(sessionPath) + .pipe( + Effect.catchAll((error) => Effect.fail(new Error(`Failed to read session file: ${error}`))), + ); + const initialData = yield* Effect.try(() => JSON.parse(json)).pipe( + Effect.flatMap(Schema.decodeUnknown(SessionData)), + Effect.catchAll((error) => Effect.fail(new Error(`Failed to parse session data: ${error}`))), ); - const saverFiber = yield* Effect.forkDaemon(backgroundSaver); - - const markDirty = Ref.set(dirtyRef, true); - - const updateState = (f: (data: SessionData) => SessionData): Effect.Effect => - Ref.update(stateRef, f).pipe(Effect.zipRight(markDirty)); - - yield* Effect.logDebug(`Created session: ${id}`); - - const flush = Effect.gen(function* () { - yield* Ref.set(dirtyRef, true); - yield* doSave; - }); - - const close = Effect.gen(function* () { - yield* Fiber.interrupt(saverFiber); - yield* flush; + const dataWithRunningStatus = new SessionData({ + ...initialData, + status: "running", }); - const handle: SessionHandle = { - id, - path: sessionPath, - - addEntry: (entry) => - updateState((data) => { - const timestamp = Date.now(); - const fullEntry = { ...entry, timestamp } as SessionEntry; - return new SessionData({ - ...data, - updatedAt: timestamp, - entries: [...data.entries, fullEntry], - }); - }), - - addAgentEvent: (event) => - updateState((data) => { - const timestamp = Date.now(); - return new SessionData({ - ...data, - updatedAt: timestamp, - entries: [...data.entries, { _tag: "AgentEvent", event, timestamp }], - }); - }), - - addToolCall: (name, input, output) => - updateState((data) => { - const timestamp = Date.now(); - return new SessionData({ - ...data, - updatedAt: timestamp, - entries: [...data.entries, { _tag: "ToolCall", name, input, output, timestamp }], - }); - }), - - updateStatus: (status, finalContent) => - updateState((data) => { - const now = Date.now(); - return new SessionData({ - ...data, - status, - updatedAt: now, - completedAt: status !== "running" ? now : data.completedAt, - finalContent: finalContent ?? data.finalContent, - }); - }), - - addCost: (cost) => - updateState( - (data) => - new SessionData({ - ...data, - totalCost: data.totalCost + cost, - updatedAt: Date.now(), - }), - ), - - getTotalCost: () => Ref.get(stateRef).pipe(Effect.map((d) => d.totalCost)), - - getToolCalls: () => - Ref.get(stateRef).pipe( - Effect.map((d) => - d.entries - .filter((e) => e._tag === "ToolCall") - .map((e) => ({ name: e.name, input: e.input, output: e.output })), - ), - ), - - updateIterations: (iterations) => - updateState( - (data) => - new SessionData({ - ...data, - iterations, - updatedAt: Date.now(), - }), - ), - - flush: () => flush, - close: () => close, - }; - - return handle; + yield* Effect.logDebug(`Resumed session: ${id}`); + return yield* makeSessionHandle(fs, id, sessionPath, dataWithRunningStatus); }), list: () => @@ -269,7 +302,8 @@ export class Session extends Effect.Service()("services/session", { for (const file of files) { const filePath = yield* userDirs.getPath("data", DIR_NAME.SESSIONS, file); const content = yield* fs.readFileString(filePath).pipe( - Effect.flatMap((json) => Effect.try(() => JSON.parse(json) as SessionData)), + Effect.flatMap((json) => Effect.try(() => JSON.parse(json))), + Effect.flatMap(Schema.decodeUnknown(SessionData)), Effect.catchAll(() => Effect.succeed(null)), ); if (content) { @@ -284,7 +318,8 @@ export class Session extends Effect.Service()("services/session", { Effect.gen(function* () { const sessionPath = yield* userDirs.getPath("data", DIR_NAME.SESSIONS, `${id}.json`); const content = yield* fs.readFileString(sessionPath).pipe( - Effect.flatMap((json) => Effect.try(() => JSON.parse(json) as SessionData)), + Effect.flatMap((json) => Effect.try(() => JSON.parse(json))), + Effect.flatMap(Schema.decodeUnknown(SessionData)), Effect.catchAll(() => Effect.succeed(null)), ); return content; @@ -299,23 +334,46 @@ export class Session extends Effect.Service()("services/session", { export const TestSession = new Session({ create: () => - Effect.succeed({ - id: "test-session", - path: "/tmp/test-session.json", - addEntry: (_entry) => Effect.void, - addAgentEvent: (_event) => Effect.void, - addToolCall: (_name, _input, _output) => Effect.void, - updateStatus: (_status, _finalContent) => Effect.void, - addCost: (_cost) => Effect.void, - getTotalCost: () => Effect.succeed(0), - getToolCalls: () => Effect.succeed([]), - updateIterations: (_iterations) => Effect.void, - flush: () => Effect.void, - close: () => Effect.void, + Effect.gen(function* () { + const iterationsRef = yield* Ref.make(0); + return { + id: "test-session", + path: "/tmp/test-session.json", + addEntry: (_entry) => Effect.void, + addAgentEvent: (_event) => Effect.void, + addToolCall: (_name, _input, _output) => Effect.void, + updateStatus: (_status, _finalContent) => Effect.void, + addCost: (_cost) => Effect.void, + getTotalCost: () => Effect.succeed(0), + getToolCalls: () => Effect.succeed([]), + updateIterations: (i) => Ref.set(iterationsRef, i), + getIterations: () => Ref.get(iterationsRef), + flush: () => Effect.void, + close: () => Effect.void, + }; }), list: () => Effect.succeed([]), get: () => Effect.succeed(null), getSessionsDir: () => Effect.succeed("/tmp/sessions"), + resume: () => + Effect.gen(function* () { + const iterationsRef = yield* Ref.make(0); + return { + id: "test-session", + path: "/tmp/test-session.json", + addEntry: (_entry) => Effect.void, + addAgentEvent: (_event) => Effect.void, + addToolCall: (_name, _input, _output) => Effect.void, + updateStatus: (_status, _finalContent) => Effect.void, + addCost: (_cost) => Effect.void, + getTotalCost: () => Effect.succeed(0), + getToolCalls: () => Effect.succeed([]), + updateIterations: (i) => Ref.set(iterationsRef, i), + getIterations: () => Ref.get(iterationsRef), + flush: () => Effect.void, + close: () => Effect.void, + }; + }), }); export const TestSessionLayer = Layer.succeed(Session, TestSession); diff --git a/src/services/user-dirs.ts b/src/services/user-dirs.ts index a025526..db1ba29 100644 --- a/src/services/user-dirs.ts +++ b/src/services/user-dirs.ts @@ -24,7 +24,7 @@ const getConfigDir = Effect.gen(function* () { return path.join(homeDir, ".config", APP_NAME); } - return yield* Effect.fail(new UserDirError({ message: "Could not determine config directory" })); + return yield* new UserDirError({ message: "Could not determine config directory" }); }); const getDataDir = Effect.gen(function* () { @@ -45,7 +45,7 @@ const getDataDir = Effect.gen(function* () { return path.join(homeDir, ".local", "share", APP_NAME); } - return yield* Effect.fail(new UserDirError({ message: "Could not determine data directory" })); + return yield* new UserDirError({ message: "Could not determine data directory" }); }); export class UserDirs extends Effect.Service()("services/user-dirs", { diff --git a/src/services/vfs.test.ts b/src/services/vfs.test.ts new file mode 100644 index 0000000..31b5420 --- /dev/null +++ b/src/services/vfs.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, test } from "bun:test"; +import { Chunk, Effect, Layer, Option } from "effect"; +import { ProjectFiles } from "@/services/project-files"; +import { VFS } from "@/services/vfs"; +import { TestProjectFilesLayer } from "@/test/mocks/project-files"; + +describe("VFS Service", () => { + const TestLayer = VFS.DefaultWithoutDependencies.pipe(Layer.provideMerge(TestProjectFilesLayer)); + + test("writeFile stages content without writing to disk", async () => { + const program = Effect.gen(function* () { + const vfs = yield* VFS; + const projectFiles = yield* ProjectFiles; + + yield* vfs.writeFile("test.txt", "staged content"); + + const stagedContent = yield* vfs.readFile("test.txt"); + expect(stagedContent).toBe("staged content"); + + const diskContent = yield* projectFiles + .readFile("test.txt") + .pipe(Effect.catchTag("FileReadError", () => Effect.succeed("NOT_FOUND"))); + expect(diskContent).toBe("NOT_FOUND"); + }); + + await Effect.runPromise(program.pipe(Effect.provide(TestLayer))); + }); + + test("readFile returns staged content if present, else disk content", async () => { + const program = Effect.gen(function* () { + const vfs = yield* VFS; + const projectFiles = yield* ProjectFiles; + + yield* projectFiles.writeFile("disk.txt", "disk content"); + + const content1 = yield* vfs.readFile("disk.txt"); + expect(content1).toBe("disk content"); + + yield* vfs.writeFile("disk.txt", "staged content", true); + + const content2 = yield* vfs.readFile("disk.txt"); + expect(content2).toBe("staged content"); + }); + + await Effect.runPromise(program.pipe(Effect.provide(TestLayer))); + }); + + test("editFile modifies content correctly", async () => { + const program = Effect.gen(function* () { + const vfs = yield* VFS; + const projectFiles = yield* ProjectFiles; + + yield* projectFiles.writeFile("code.ts", "const x = 1;\nconst y = 2;"); + + yield* vfs.editFile("code.ts", "const x = 1;", "const x = 10;"); + + const content = yield* vfs.readFile("code.ts"); + expect(content).toBe("const x = 10;\nconst y = 2;"); + + const diskContent = yield* projectFiles.readFile("code.ts"); + expect(diskContent).toBe("const x = 1;\nconst y = 2;"); + }); + + await Effect.runPromise(program.pipe(Effect.provide(TestLayer))); + }); + + test("getDiffs generates unified diffs", async () => { + const program = Effect.gen(function* () { + const vfs = yield* VFS; + const projectFiles = yield* ProjectFiles; + + yield* projectFiles.writeFile("file.txt", "line1\nline2\nline3"); + yield* vfs.editFile("file.txt", "line2", "line2_modified"); + + const diffs = yield* vfs.getDiffs(); + expect(Chunk.size(diffs)).toBe(1); + + const patch = Chunk.head(diffs).pipe(Option.getOrThrow); + expect(patch.path).toBe("file.txt"); + + const hunk = Chunk.head(patch.hunks).pipe(Option.getOrThrow); + expect(hunk.content).toContain("-line2"); + expect(hunk.content).toContain("+line2_modified"); + }); + + await Effect.runPromise(program.pipe(Effect.provide(TestLayer))); + }); + + test("flush applies changes to disk", async () => { + const program = Effect.gen(function* () { + const vfs = yield* VFS; + const projectFiles = yield* ProjectFiles; + + yield* projectFiles.writeFile("test.txt", "original"); + yield* vfs.writeFile("test.txt", "new", true); + yield* vfs.writeFile("new.txt", "created"); + + yield* vfs.flush(); + + const diskTest = yield* projectFiles.readFile("test.txt"); + const diskNew = yield* projectFiles.readFile("new.txt"); + + expect(diskTest).toBe("new"); + expect(diskNew).toBe("created"); + + const summary = yield* vfs.getSummary(); + expect(summary.fileCount).toBe(0); + }); + + await Effect.runPromise(program.pipe(Effect.provide(TestLayer))); + }); + + test("comments and decisions management", async () => { + const program = Effect.gen(function* () { + const vfs = yield* VFS; + + yield* vfs.addComment("test.txt", 1, "Fix this"); + + const comments = yield* vfs.getComments(); + expect(Chunk.size(comments)).toBe(1); + + const comment = Chunk.head(comments).pipe(Option.getOrThrow); + expect(comment.content).toBe("Fix this"); + expect(Option.getOrNull(comment.line)).toBe(1); + + yield* vfs.approve(); + const decision1 = yield* vfs.getDecision(); + expect(Option.getOrNull(decision1)).toEqual({ type: "approved", message: undefined }); + + yield* vfs.reject(); + const decision2 = yield* vfs.getDecision(); + expect(Option.getOrNull(decision2)).toEqual({ type: "rejected", message: undefined }); + }); + + await Effect.runPromise(program.pipe(Effect.provide(TestLayer))); + }); + + test("reset clears all state", async () => { + const program = Effect.gen(function* () { + const vfs = yield* VFS; + + yield* vfs.writeFile("test.txt", "content"); + yield* vfs.addComment("test.txt", null, "comment"); + yield* vfs.approve(); + + yield* vfs.reset(); + + const summary = yield* vfs.getSummary(); + expect(summary.fileCount).toBe(0); + expect(summary.commentCount).toBe(0); + + const decision = yield* vfs.getDecision(); + expect(Option.isNone(decision)).toBe(true); + }); + + await Effect.runPromise(program.pipe(Effect.provide(TestLayer))); + }); +}); diff --git a/src/services/vfs.ts b/src/services/vfs.ts new file mode 100644 index 0000000..ae24701 --- /dev/null +++ b/src/services/vfs.ts @@ -0,0 +1,242 @@ +import { createTwoFilesPatch } from "diff"; +import { Chunk, Data, Effect, HashMap, Layer, Option, Ref } from "effect"; +import { VFSError } from "@/domain/errors"; +import { DiffHunk, FilePatch, ReviewComment, VFSSummary, VirtualFile } from "@/domain/vfs"; +import { ProjectFiles } from "@/services/project-files"; +import { replace } from "@/tools/edit-file"; + +export class VFSState extends Data.Class<{ + readonly files: HashMap.HashMap; + readonly comments: Chunk.Chunk; + readonly decision: Option.Option<{ type: "approved" | "rejected"; message?: string }>; +}> { + static readonly empty = new VFSState({ + files: HashMap.empty(), + comments: Chunk.empty(), + decision: Option.none(), + }); +} + +export class VFS extends Effect.Service()("services/vfs", { + effect: Effect.gen(function* () { + const stateRef = yield* Ref.make(VFSState.empty); + const projectFiles = yield* ProjectFiles; + + const readFile = (path: string) => + Effect.gen(function* () { + const state = yield* Ref.get(stateRef); + const staged = HashMap.get(state.files, path); + if (Option.isSome(staged)) { + return staged.value.content; + } + return yield* projectFiles + .readFile(path, { disableExcerpts: true }) + .pipe(Effect.catchTag("FileReadError", () => Effect.succeed(""))); + }); + + const generateUnifiedDiff = (path: string, oldContent: string, newContent: string) => + Effect.sync(() => { + const patch = createTwoFilesPatch(`a/${path}`, `b/${path}`, oldContent, newContent, "", ""); + + return new FilePatch({ + path, + hunks: Chunk.make( + new DiffHunk({ + oldStart: 0, + oldLines: 0, + newStart: 0, + newLines: 0, + content: patch, + }), + ), + isNew: !oldContent && !!newContent, + isDeleted: !!oldContent && !newContent, + }); + }); + + return { + writeFile: (path: string, content: string, overwrite = false) => + Effect.gen(function* () { + const original = yield* projectFiles.readFile(path, { disableExcerpts: true }).pipe( + Effect.map(Option.some), + Effect.catchAll(() => Effect.succeed(Option.none())), + ); + + if (!overwrite && Option.isSome(original)) { + return yield* new VFSError({ + message: `File ${path} already exists. Set overwrite to true if you intended to replace it.`, + }); + } + + yield* Ref.update( + stateRef, + (state) => + new VFSState({ + ...state, + files: HashMap.set( + state.files, + path, + new VirtualFile({ + path, + content, + originalContent: original, + timestamp: Date.now(), + }), + ), + }), + ); + return `[VFS] Staged write to ${path}`; + }), + + editFile: (path: string, oldString: string, newString: string, replaceAll = false) => + Effect.gen(function* () { + const currentContent = yield* readFile(path); + const newContent = yield* replace(currentContent, oldString, newString, replaceAll).pipe( + Effect.mapError((e) => new VFSError({ message: e.message })), + ); + + const state = yield* Ref.get(stateRef); + let original: Option.Option = Option.none(); + + const existing = HashMap.get(state.files, path); + if (Option.isSome(existing)) { + original = existing.value.originalContent; + } else { + original = yield* projectFiles.readFile(path, { disableExcerpts: true }).pipe( + Effect.map(Option.some), + Effect.catchAll(() => Effect.succeed(Option.none())), + ); + } + + yield* Ref.update( + stateRef, + (state) => + new VFSState({ + ...state, + files: HashMap.set( + state.files, + path, + new VirtualFile({ + path, + content: newContent, + originalContent: original, + timestamp: Date.now(), + }), + ), + }), + ); + return `[VFS] Staged edit to ${path}`; + }), + + readFile, + + getDiffs: () => + Effect.gen(function* () { + const state = yield* Ref.get(stateRef); + const patches: FilePatch[] = []; + for (const [path, file] of HashMap.entries(state.files)) { + const patch = yield* generateUnifiedDiff( + path, + Option.getOrElse(file.originalContent, () => ""), + file.content, + ); + patches.push(patch); + } + return Chunk.fromIterable(patches); + }), + + getFileDiff: (path: string) => + Effect.gen(function* () { + const state = yield* Ref.get(stateRef); + const file = HashMap.get(state.files, path); + if (Option.isNone(file)) { + return yield* new VFSError({ message: `${path} not staged` }); + } + return yield* generateUnifiedDiff( + path, + Option.getOrElse(file.value.originalContent, () => ""), + file.value.content, + ); + }), + + addComment: (path: string, line: number | null, content: string) => + Ref.update( + stateRef, + (state) => + new VFSState({ + ...state, + comments: Chunk.append( + state.comments, + new ReviewComment({ + id: crypto.randomUUID(), + path, + line: line ? Option.some(line) : Option.none(), + content, + timestamp: Date.now(), + }), + ), + }), + ), + + getComments: () => Ref.get(stateRef).pipe(Effect.map((s) => s.comments)), + + approve: (message?: string) => + Ref.update( + stateRef, + (s) => new VFSState({ ...s, decision: Option.some({ type: "approved", message }) }), + ), + + reject: (message?: string) => + Ref.update( + stateRef, + (s) => new VFSState({ ...s, decision: Option.some({ type: "rejected", message }) }), + ), + + getDecision: () => Ref.get(stateRef).pipe(Effect.map((s) => s.decision)), + + flush: () => + Effect.gen(function* () { + const state = yield* Ref.get(stateRef); + const results: string[] = []; + for (const [path, file] of HashMap.entries(state.files)) { + yield* projectFiles.writeFile(path, file.content, true); + results.push(path); + } + yield* Ref.set(stateRef, VFSState.empty); + return results; + }), + + reset: () => Ref.set(stateRef, VFSState.empty), + + getSummary: () => + Effect.gen(function* () { + const state = yield* Ref.get(stateRef); + return new VFSSummary({ + fileCount: HashMap.size(state.files), + files: Array.from(HashMap.keys(state.files)), + commentCount: Chunk.size(state.comments), + }); + }), + }; + }), + dependencies: [ProjectFiles.Default], + accessors: true, +}) {} + +export const TestVFS = new VFS({ + reset: () => Effect.void, + getSummary: () => Effect.succeed({ fileCount: 0, files: [], commentCount: 0 }), + getDiffs: () => Effect.succeed(Chunk.empty()), + getDecision: () => Effect.succeed(Option.none()), + getComments: () => Effect.succeed(Chunk.empty()), + flush: () => Effect.succeed([]), + writeFile: () => Effect.succeed(""), + readFile: () => Effect.succeed(""), + editFile: () => Effect.succeed(""), + getFileDiff: () => Effect.succeed({ path: "", hunks: Chunk.empty(), isDeleted: false, isNew: false }), + addComment: () => Effect.void, + approve: () => Effect.void, + reject: () => Effect.void, +}); + +export const TestVFSLayer = Layer.succeed(VFS, TestVFS); diff --git a/src/services/web.ts b/src/services/web.ts index d876bb9..d3880bc 100644 --- a/src/services/web.ts +++ b/src/services/web.ts @@ -1,5 +1,7 @@ import { FetchHttpClient, HttpBody, HttpClient } from "@effect/platform"; -import { Effect, Match } from "effect"; +import { Effect, Layer, Match } from "effect"; +import { extractText } from "unpdf"; +import { MAX_WEB_FETCH_BYTES, MAX_WEB_FETCH_CHARS } from "@/domain/constants"; import { WebFetchError, WebSearchError } from "@/domain/errors"; import { convertHTMLToMarkdown } from "@/text/converters/html-markdown-converter"; import { extractTextFromHTML } from "@/text/converters/html-text-extractor"; @@ -13,6 +15,54 @@ const API_CONFIG = { TIMEOUT: 25000, } as const; +const BINARY_CONTENT_TYPES = [ + "application/octet-stream", + "application/zip", + "application/gzip", + "application/x-tar", + "application/x-rar-compressed", + "application/x-7z-compressed", + "application/msword", + "application/vnd.ms-", + "application/vnd.openxmlformats-", + "image/", + "audio/", + "video/", +] as const; + +type ContentType = "pdf" | "binary" | "text"; + +const detectContentType = (contentType: string | undefined, url: string): ContentType => { + const normalizedType = contentType?.toLowerCase().split(";").at(0)?.trim() ?? ""; + const lowerUrl = url.toLowerCase(); + + return Match.value({ type: normalizedType, url: lowerUrl }).pipe( + Match.when( + ({ type, url }) => type.includes("application/pdf") || url.endsWith(".pdf"), + () => "pdf" as const, + ), + Match.when( + ({ type }) => BINARY_CONTENT_TYPES.some((pattern) => type.startsWith(pattern)), + () => "binary" as const, + ), + Match.orElse(() => "text" as const), + ); +}; + +const truncateOutput = (text: string, maxChars = MAX_WEB_FETCH_CHARS): string => { + if (text.length <= maxChars) return text; + + const excerptSize = Math.floor(maxChars / 2) - 100; + const beginning = text.slice(0, excerptSize); + const end = text.slice(-excerptSize); + + return [ + beginning, + `\n\n[... truncated: showing ${excerptSize.toLocaleString()} of ${text.length.toLocaleString()} chars ...]\n\n`, + end, + ].join(""); +}; + export class Web extends Effect.Service()("services/web", { effect: Effect.gen(function* () { const client = yield* HttpClient.HttpClient; @@ -131,26 +181,82 @@ export class Web extends Effect.Service()("services/web", { ); const contentLength = response.headers?.["content-length"]; - if (contentLength && parseInt(contentLength, 10) > 5 * 1024 * 1024) { - return yield* new WebFetchError({ message: "Response too large (>5MB)" }); + if (contentLength && parseInt(contentLength, 10) > MAX_WEB_FETCH_BYTES) { + return yield* new WebFetchError({ + message: `Response too large (${(parseInt(contentLength, 10) / 1024 / 1024).toFixed(1)}MB). Maximum is ${MAX_WEB_FETCH_BYTES / 1024 / 1024}MB.`, + }); } - const responseText = yield* response.text.pipe( - Effect.mapError( - (error) => new WebFetchError({ message: `Failed to read response: ${error}`, cause: error }), + const contentType = detectContentType(response.headers?.["content-type"], url); + + return yield* Match.value(contentType).pipe( + Match.when("pdf", () => + Effect.gen(function* () { + const buffer = yield* response.arrayBuffer.pipe( + Effect.mapError( + (error) => + new WebFetchError({ + message: `Failed to read PDF response: ${error}`, + cause: error, + }), + ), + ); + const result = yield* Effect.tryPromise({ + try: () => extractText(new Uint8Array(buffer)), + catch: (error) => + new WebFetchError({ message: `Failed to parse PDF: ${error}`, cause: error }), + }); + const text = Array.isArray(result.text) ? result.text.join("\n") : result.text; + return truncateOutput(text); + }), ), - ); + Match.when( + "binary", + () => + new WebFetchError({ + message: `Cannot fetch binary content (${response.headers?.["content-type"] ?? "unknown"}).`, + }), + ), + Match.when("text", () => + Effect.gen(function* () { + const responseText = yield* response.text.pipe( + Effect.mapError( + (error) => + new WebFetchError({ + message: `Failed to read response: ${error}`, + cause: error, + }), + ), + ); - if (format === "html") { - return responseText; - } else if (format === "text") { - return yield* extractTextFromHTML(responseText).pipe(Effect.tapError(Effect.logWarning)); - } else { - return yield* convertHTMLToMarkdown(responseText).pipe(Effect.tapError(Effect.logWarning)); - } + let result: string; + if (format === "html") { + result = responseText; + } else if (format === "text") { + result = yield* extractTextFromHTML(responseText).pipe( + Effect.tapError(Effect.logWarning), + ); + } else { + result = yield* convertHTMLToMarkdown(responseText).pipe( + Effect.tapError(Effect.logWarning), + ); + } + + return truncateOutput(result); + }), + ), + Match.exhaustive, + ); }); return { search, fetch }; }), dependencies: [FetchHttpClient.layer], }) {} + +export const TestWeb = new Web({ + search: () => Effect.succeed(""), + fetch: () => Effect.succeed(""), +}); + +export const TestWebLayer = Layer.succeed(Web, TestWeb); diff --git a/src/tools/edit-file.ts b/src/tools/edit-file.ts index 5f96c92..5eb20ba 100644 --- a/src/tools/edit-file.ts +++ b/src/tools/edit-file.ts @@ -15,7 +15,7 @@ import { WhitespaceNormalizedReplacer, } from "@/text/replacers"; -function replace( +export function replace( content: string, oldString: string, newString: string, diff --git a/src/tools/index.ts b/src/tools/index.ts index 3ec267d..f12e0d4 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -1,39 +1,45 @@ import type { ToolSet } from "ai"; +import type { Runtime } from "effect"; +import type { VFS } from "@/services/vfs"; import { editFileTool } from "./edit-file"; import { listFilesTool } from "./list-files"; import { readFileTool } from "./read-file"; +import { + makeAddReviewCommentTool, + makeApproveChangesTool, + makeReadAllDiffsTool, + makeReadFileDiffTool, + makeRejectChangesTool, +} from "./review"; import { searchFilesTool } from "./search-files"; +import { makeVfsEditFileTool, makeVfsReadFileTool, makeVfsWriteFileTool } from "./vfs"; import { webFetchTool } from "./web-fetch"; import { webSearchTool } from "./web-search"; import { writeFileTool } from "./write-file"; export { editFileTool, listFilesTool, readFileTool, searchFilesTool, webFetchTool, webSearchTool, writeFileTool }; -export const tools = { - list_files: listFilesTool, - read_file: readFileTool, - search_files: searchFilesTool, - write_file: writeFileTool, - edit_file: editFileTool, - web_fetch: webFetchTool, - web_search: webSearchTool, -} satisfies ToolSet; +export const makeWriterTools = (runtime: Runtime.Runtime) => + ({ + list_files: listFilesTool, + search_files: searchFilesTool, + read_all_diffs: makeReadAllDiffsTool(runtime), + read_file_diff: makeReadFileDiffTool(runtime), + read_file: makeVfsReadFileTool(runtime), + write_file: makeVfsWriteFileTool(runtime), + edit_file: makeVfsEditFileTool(runtime), + web_fetch: webFetchTool, + web_search: webSearchTool, + }) satisfies ToolSet; -export const explore_tools = { - list_files: listFilesTool, - search_files: searchFilesTool, - read_file: readFileTool, - web_fetch: webFetchTool, - web_search: webSearchTool, -} satisfies ToolSet; - -export const edit_tools = { - list_files: listFilesTool, - search_files: searchFilesTool, - read_file: readFileTool, - write_file: writeFileTool, - edit_file: editFileTool, -} satisfies ToolSet; - -export type ExploreTools = typeof explore_tools; -export type EditTools = typeof edit_tools; +export const makeReviewerTools = (runtime: Runtime.Runtime) => + ({ + read_all_diffs: makeReadAllDiffsTool(runtime), + read_file_diff: makeReadFileDiffTool(runtime), + read_file: makeVfsReadFileTool(runtime), + add_review_comment: makeAddReviewCommentTool(runtime), + approve_changes: makeApproveChangesTool(runtime), + reject_changes: makeRejectChangesTool(runtime), + web_fetch: webFetchTool, + web_search: webSearchTool, + }) satisfies ToolSet; diff --git a/src/tools/review.ts b/src/tools/review.ts new file mode 100644 index 0000000..b18cdc9 --- /dev/null +++ b/src/tools/review.ts @@ -0,0 +1,118 @@ +import { jsonSchema, tool } from "ai"; +import dedent from "dedent"; +import { Chunk, Effect, JSONSchema, Option, Runtime, Schema } from "effect"; +import type { DiffHunk, FilePatch } from "@/domain/vfs"; +import { VFS } from "@/services/vfs"; + +const formatPatch = (patch: FilePatch): string => { + return Chunk.head(patch.hunks).pipe( + Option.map((h: DiffHunk) => h.content), + Option.getOrElse(() => ""), + ); +}; + +export const makeReadAllDiffsTool = (runtime: Runtime.Runtime) => + tool({ + description: "Get unified diffs of all staged file changes.", + inputSchema: jsonSchema({ type: "object", properties: {} }), + execute: async () => { + return Runtime.runPromise(runtime)( + VFS.getDiffs().pipe( + Effect.map((patches) => { + if (Chunk.isEmpty(patches)) { + return "No staged changes found."; + } + return Chunk.toArray(patches) + .map((p) => `=== ${p.path} ===\n${formatPatch(p)}`) + .join("\n\n"); + }), + ), + ); + }, + }); + +export const makeReadFileDiffTool = (runtime: Runtime.Runtime) => + tool({ + description: "Get the diff for a specific file.", + inputSchema: jsonSchema<{ filePath: string }>( + JSONSchema.make( + Schema.Struct({ + filePath: Schema.String.annotations({ description: "The path to the file" }), + }), + ), + ), + execute: async ({ filePath }) => { + return Runtime.runPromise(runtime)( + VFS.getFileDiff(filePath).pipe( + Effect.map(formatPatch), + Effect.catchAll((error) => Effect.succeed(`Error getting file diff: ${error}`)), + ), + ); + }, + }); + +export const makeAddReviewCommentTool = (runtime: Runtime.Runtime) => + tool({ + description: dedent` + Add a comment to a file or specific line in a diff. + IMPORTANT: Use this for ALL feedback passed to the writer. + The writer will ONLY see comments added via this tool. + `, + inputSchema: jsonSchema<{ filePath: string; line?: number; comment: string }>( + JSONSchema.make( + Schema.Struct({ + filePath: Schema.String.annotations({ description: "The path to the file" }), + line: Schema.optional(Schema.Number.annotations({ description: "The line number (optional)" })), + comment: Schema.String.annotations({ description: "The comment content" }), + }), + ), + ), + execute: async ({ filePath, line, comment }) => { + return Runtime.runPromise(runtime)( + VFS.addComment(filePath, line ?? null, comment).pipe( + Effect.map(() => `Comment added to ${filePath}${line ? `:${line}` : ""}`), + ), + ); + }, + }); + +export const makeApproveChangesTool = (runtime: Runtime.Runtime) => + tool({ + description: dedent` + Approve all staged changes. + Call this tool EXACTLY ONCE as your final action when the diff looks correct. + `, + inputSchema: jsonSchema<{ summary?: string }>( + JSONSchema.make( + Schema.Struct({ + summary: Schema.optional( + Schema.String.annotations({ description: "Optional summary of approval" }), + ), + }), + ), + ), + execute: async ({ summary }) => { + return Runtime.runPromise(runtime)( + VFS.approve(summary).pipe( + Effect.map(() => `Changes approved.${summary ? ` Summary: ${summary}` : ""}`), + ), + ); + }, + }); + +export const makeRejectChangesTool = (runtime: Runtime.Runtime) => + tool({ + description: "Reject staged changes. You must provide a critique to explain the rejection reason.", + inputSchema: jsonSchema<{ critique: string }>( + JSONSchema.make( + Schema.Struct({ + critique: Schema.String.annotations({ description: "The critique/reason for rejection" }), + }), + ), + ), + execute: async ({ critique }) => { + return Runtime.runPromise(runtime)( + VFS.reject(critique).pipe(Effect.map(() => `Changes rejected. Critique: ${critique}`)), + ); + }, + }); diff --git a/src/tools/vfs.ts b/src/tools/vfs.ts new file mode 100644 index 0000000..5e77c96 --- /dev/null +++ b/src/tools/vfs.ts @@ -0,0 +1,76 @@ +import { jsonSchema, tool } from "ai"; +import dedent from "dedent"; +import { Effect, JSONSchema, Runtime, Schema } from "effect"; +import { VFS } from "@/services/vfs"; + +export const makeVfsWriteFileTool = (runtime: Runtime.Runtime) => + tool({ + description: dedent` + Write content to a file (staged in VFS, not applied to disk until approved). + Use this tool if you want to create a new file or fully overwrite an existing one. + If you wish to edit an existing file, use the 'edit-file' tool instead. + `, + inputSchema: jsonSchema<{ filePath: string; content: string; overwrite?: boolean }>( + JSONSchema.make( + Schema.Struct({ + filePath: Schema.String.annotations({ description: "The path to the file to write" }), + content: Schema.String.annotations({ description: "The content to write" }), + overwrite: Schema.optional( + Schema.Boolean.annotations({ + description: "Whether to overwrite the file if it already exists. Use with caution!", + }), + ), + }), + ), + ), + execute: async ({ filePath, content, overwrite = false }) => { + return Runtime.runPromise(runtime)( + VFS.writeFile(filePath, content, overwrite).pipe( + Effect.map(() => `Successfully wrote to ${filePath}. Staged in VFS.`), + Effect.catchAll((error) => Effect.succeed(`Error writing to ${filePath}: ${error.message}`)), + ), + ); + }, + }); + +export const makeVfsEditFileTool = (runtime: Runtime.Runtime) => + tool({ + description: "Edit a file by replacing text (staged in VFS).", + inputSchema: jsonSchema<{ filePath: string; oldString: string; newString: string; replaceAll?: boolean }>( + JSONSchema.make( + Schema.Struct({ + filePath: Schema.String.annotations({ description: "The path to the file to edit" }), + oldString: Schema.String.annotations({ description: "The text to replace" }), + newString: Schema.String.annotations({ description: "The text to replace it with" }), + replaceAll: Schema.optional(Schema.Boolean.annotations({ description: "Replace all occurrences" })), + }), + ), + ), + execute: async ({ filePath, oldString, newString, replaceAll }) => { + return Runtime.runPromise(runtime)( + VFS.editFile(filePath, oldString, newString, replaceAll ?? false).pipe( + Effect.map(() => `Successfully edited ${filePath}. Staged in VFS.`), + Effect.catchAll((error) => Effect.succeed(`Error editing ${filePath}: ${error.message}`)), + ), + ); + }, + }); + +export const makeVfsReadFileTool = (runtime: Runtime.Runtime) => + tool({ + description: "Read a file (returns staged version if modified, otherwise disk version).", + inputSchema: jsonSchema<{ filePath: string }>( + JSONSchema.make( + Schema.Struct({ + filePath: Schema.String.annotations({ description: "The path to the file to read" }), + }), + ), + ), + execute: async ({ filePath }) => { + return Runtime.runPromise(runtime)( + VFS.readFile(filePath).pipe( + Effect.catchAll((error) => Effect.succeed(`Error reading ${filePath}: ${error.message}`)), + ), + ); + }, + }); diff --git a/src/tools/web-fetch.ts b/src/tools/web-fetch.ts index ef8bb22..7e504b0 100644 --- a/src/tools/web-fetch.ts +++ b/src/tools/web-fetch.ts @@ -21,6 +21,7 @@ export const webFetchTool = tool({ description: dedent` Fetches content from a specified URL and converts it to the requested format. Use this tool when you need to retrieve and analyze web content. + Supports HTML, plain text, and PDF files. Large responses are truncated. `, inputSchema: jsonSchema(JSONSchema.make(webFetchSchema)), execute: async ({ url, format, timeout }) => { diff --git a/src/tools/write-file.ts b/src/tools/write-file.ts index 317ad1b..1bdcb93 100644 --- a/src/tools/write-file.ts +++ b/src/tools/write-file.ts @@ -1,4 +1,5 @@ import { jsonSchema, tool } from "ai"; +import dedent from "dedent"; import { Effect, JSONSchema, Schema } from "effect"; import { ProjectFiles } from "@/services/project-files"; @@ -11,7 +12,7 @@ const writeFileSchema = Schema.Struct({ }), overwrite: Schema.optional( Schema.Boolean.annotations({ - description: "Whether to overwrite the file if it already exists", + description: "Whether to overwrite the file if it already exists. Use with caution!", }), ), }); @@ -19,7 +20,11 @@ const writeFileSchema = Schema.Struct({ type WriteFileInput = Schema.Schema.Type; export const writeFileTool = tool({ - description: "Write or overwrite content to a file. USE WITH CAUTION.", + description: dedent` + Write content to a file. + Use this tool if you want to create a new file or fully overwrite an existing one. + If you wish to edit an existing file, use the 'edit-file' tool instead. + `, inputSchema: jsonSchema(JSONSchema.make(writeFileSchema)), execute: async ({ filePath, content, overwrite = false }) => { const execute = ProjectFiles.writeFile(filePath, content, overwrite).pipe( diff --git a/src/tui/app.tsx b/src/tui/app.tsx index 6a53301..4dc9552 100644 --- a/src/tui/app.tsx +++ b/src/tui/app.tsx @@ -1,30 +1,35 @@ import { createCliRenderer } from "@opentui/core"; -import { createRoot, useKeyboard } from "@opentui/react"; +import { createRoot, useKeyboard, useRenderer } from "@opentui/react"; import { DialogProvider, useDialog, useDialogState } from "@opentui-ui/dialog/react"; import { Effect } from "effect"; import { StrictMode, useState } from "react"; import { copyToClipboard } from "@/services/clipboard"; import { ErrorBoundary } from "@/tui/components/ErrorBoundary"; import { SettingsModal } from "@/tui/components/SettingsModal"; +import { Sidebar } from "@/tui/components/Sidebar"; import { StatusBar } from "@/tui/components/StatusBar"; import { AgentProvider, useAgentContext } from "@/tui/context/AgentContext"; import { ConfigProvider } from "@/tui/context/ConfigContext"; import { EffectProvider } from "@/tui/context/EffectContext"; import { RendererProvider } from "@/tui/context/RendererContext"; +import { ThemeProvider } from "@/tui/context/ThemeContext"; +import { Keymap } from "@/tui/keyboard/keymap"; import { TaskInput } from "./components/TaskInput"; import { Timeline } from "./components/Timeline"; +import { areKeyBindingsEqual } from "./keyboard/utils"; function AgentWorkflow() { - const { state, start, submitAction, cancel } = useAgentContext(); const dialog = useDialog(); + const renderer = useRenderer(); + const { state, start, submitAction, cancel } = useAgentContext(); const isDialogOpen = useDialogState((s) => s.isOpen); const [activeFocus, setActiveFocus] = useState<"input" | "timeline">("input"); - useKeyboard((key) => { + useKeyboard((keyEvent) => { if (isDialogOpen) return; - if (key.name === "f2") { + if (areKeyBindingsEqual(keyEvent, Keymap.Global.Settings)) { dialog.prompt({ content: (ctx) => , size: "large", @@ -32,14 +37,19 @@ function AgentWorkflow() { return; } - if (key.name === "tab") { + if (areKeyBindingsEqual(keyEvent, Keymap.Navigation.FocusNext)) { setActiveFocus((prev) => (prev === "input" ? "timeline" : "input")); } + if (state.phase === "awaiting-user" && activeFocus !== "timeline") { setActiveFocus("timeline"); } - if (key.name === "escape" && state.phase !== "idle") { + + if (areKeyBindingsEqual(keyEvent, Keymap.Global.Cancel)) { cancel(); + renderer.setTerminalTitle(""); + renderer.destroy(); + process.exit(0); } }); @@ -64,20 +74,23 @@ function AgentWorkflow() { state.phase !== "cancelled"; return ( - - - - - - + + + + + + + + + ); } @@ -88,13 +101,15 @@ function App() { - - - - - - - + + + + + + + + + diff --git a/src/tui/components/DiffReviewModal.tsx b/src/tui/components/DiffReviewModal.tsx new file mode 100644 index 0000000..16d1f45 --- /dev/null +++ b/src/tui/components/DiffReviewModal.tsx @@ -0,0 +1,131 @@ +import { useRenderer } from "@opentui/react"; +import { type PromptContext, useDialog, useDialogKeyboard } from "@opentui-ui/dialog/react"; +import { useState } from "react"; +import type { FilePatch } from "@/domain/vfs"; +import { DiffView } from "@/tui/components/DiffView"; +import { FeedbackModal } from "@/tui/components/FeedbackModal"; +import { useTheme } from "@/tui/context/ThemeContext"; +import { Keymap } from "@/tui/keyboard/keymap"; +import { formatFilePatch } from "@/tui/utils/diff"; + +interface DiffReviewModalProps extends PromptContext { + diffs: ReadonlyArray; + filetype?: string; + onApprove: () => void; + onReject: (comment?: string) => void; +} + +export function DiffReviewModal({ + dialogId, + dismiss, + diffs, + filetype = "markdown", + onApprove, + onReject, +}: DiffReviewModalProps) { + const renderer = useRenderer(); + const dialog = useDialog(); + const { theme } = useTheme(); + const [view, setView] = useState<"unified" | "split">("unified"); + const [showLineNumbers, setShowLineNumbers] = useState(true); + const [wrapMode, setWrapMode] = useState<"none" | "word">("none"); + + useDialogKeyboard((key) => { + if (key.raw === Keymap.Global.Help.name) { + return; + } + + if (key.name === Keymap.DiffView.ToggleView.name && !key.ctrl && !key.meta) { + setView((prev) => (prev === "unified" ? "split" : "unified")); + } else if (key.name === Keymap.DiffView.ToggleLineNumbers.name && !key.ctrl && !key.meta) { + setShowLineNumbers((prev) => !prev); + } else if (key.name === Keymap.DiffView.ToggleWrap.name && !key.ctrl && !key.meta) { + setWrapMode((prev) => (prev === "none" ? "word" : "none")); + } else if (key.name === Keymap.Feedback.Approve.name && !key.ctrl && !key.meta) { + onApprove(); + dismiss(); + } else if (key.name === Keymap.Feedback.Reject.name && !key.ctrl && !key.meta) { + dialog.prompt({ + content: (ctx) => ( + { + onReject(reason); + dismiss(); + }} + /> + ), + size: "medium", + }); + } else if (key.name === Keymap.Global.Exit.name && key.ctrl) { + dismiss(); + } + }, dialogId); + + return ( + + {diffs.length === 0 ? ( + No changes to display. + ) : ( + + {diffs.map((diff) => ( + + + + ))} + + )} + + + + + ); +} diff --git a/src/tui/components/DiffView.tsx b/src/tui/components/DiffView.tsx new file mode 100644 index 0000000..fec87b8 --- /dev/null +++ b/src/tui/components/DiffView.tsx @@ -0,0 +1,41 @@ +import { SyntaxStyle } from "@opentui/core"; +import { useMemo } from "react"; +import type { DiffTheme } from "@/tui/theme"; + +interface DiffViewProps { + diff: string; + filetype: string; + theme: DiffTheme; + view: "unified" | "split"; + showLineNumbers: boolean; + wrapMode: "none" | "word"; +} + +export function DiffView({ diff, filetype, theme, view, showLineNumbers, wrapMode = "word" }: DiffViewProps) { + const syntaxStyle = useMemo(() => SyntaxStyle.fromStyles(theme.syntaxStyle), [theme]); + + return ( + + ); +} diff --git a/src/tui/components/FeedbackModal.tsx b/src/tui/components/FeedbackModal.tsx new file mode 100644 index 0000000..40cf083 --- /dev/null +++ b/src/tui/components/FeedbackModal.tsx @@ -0,0 +1,48 @@ +import { type PromptContext, useDialogKeyboard } from "@opentui-ui/dialog/react"; +import { Input } from "@/tui/components/Input"; +import { useTheme } from "@/tui/context/ThemeContext"; + +interface FeedbackModalProps extends PromptContext { + onSubmit: (text: string) => void; + title?: string; + placeholder?: string; +} + +export function FeedbackModal({ + dialogId, + dismiss, + onSubmit, + title = "Reason for rejection", + placeholder = "Type your reason here...", +}: FeedbackModalProps) { + const { theme } = useTheme(); + + useDialogKeyboard((key) => { + if (key.name === "escape") { + dismiss(); + } + }, dialogId); + + return ( + + + {title} + + { + onSubmit(val); + dismiss(); + }} + /> + Enter: Submit | Esc: Cancel + + ); +} diff --git a/src/tui/components/FeedbackWidget.tsx b/src/tui/components/FeedbackWidget.tsx index e1b8509..c9f66a7 100644 --- a/src/tui/components/FeedbackWidget.tsx +++ b/src/tui/components/FeedbackWidget.tsx @@ -1,6 +1,10 @@ import { useKeyboard } from "@opentui/react"; -import { useState } from "react"; +import { useDialog } from "@opentui-ui/dialog/react"; +import { DiffReviewModal } from "@/tui/components/DiffReviewModal"; +import { FeedbackModal } from "@/tui/components/FeedbackModal"; +import { useTheme } from "@/tui/context/ThemeContext"; import type { PendingUserAction } from "@/tui/hooks/useAgent"; +import { Keymap } from "@/tui/keyboard/keymap"; interface FeedbackWidgetProps { pendingAction: PendingUserAction; @@ -10,66 +14,66 @@ interface FeedbackWidgetProps { } export const FeedbackWidget = ({ pendingAction, onApprove, onReject, focused }: FeedbackWidgetProps) => { - const [rejectMode, setRejectMode] = useState(false); - const [comment, setComment] = useState(""); + const dialog = useDialog(); + const { theme } = useTheme(); + + const openReviewModal = () => { + dialog.prompt({ + content: (ctx) => ( + + ), + size: "full", + style: { + padding: 0, + }, + }); + }; + + const openRejectModal = () => { + dialog.prompt({ + content: (ctx) => , + size: "medium", + }); + }; useKeyboard((key) => { if (!focused) return; - if (!rejectMode) { - if (key.name === "y") onApprove(); - else if (key.name === "n") setRejectMode(true); - } else { - if (key.name === "return") { - onReject(comment); - setRejectMode(false); - setComment(""); - } else if (key.name === "escape") { - setRejectMode(false); - setComment(""); - } else if (key.name === "backspace") { - setComment((prev) => prev.slice(0, -1)); - } else if (key.name === "space") { - setComment((prev) => `${prev} `); - } else if (key.name?.length === 1) { - setComment((prev) => prev + key.name); - } - } + if (key.name === Keymap.Feedback.Approve.name) onApprove(); + else if (key.name === Keymap.Feedback.Reject.name) openRejectModal(); + else if (key.name === Keymap.DiffView.ToggleView.name) openReviewModal(); }); return ( - - + + {focused ? "▶ USER ACTION REQUIRED" : "USER ACTION REQUIRED (Press Tab to Focus)"} - Draft (Cycle {pendingAction.cycle}) is ready for review. + + Cycle {pendingAction.cycle}: The agent has proposed changes to {pendingAction.diffs.length} file(s). + - - {rejectMode ? ( - - Reason for changes: - - {comment}█ - - [Enter] Submit [Esc] Cancel - - ) : ( - - Approve? [y] Yes /{" "} - [n] No (Request Changes) - - )} + + + [{Keymap.DiffView.ToggleView.label}] View Changes (Diff) + + + [{Keymap.Feedback.Approve.label}] Approve & Apply + + + [{Keymap.Feedback.Reject.label}] Reject & Request Changes + ); diff --git a/src/tui/components/Input.tsx b/src/tui/components/Input.tsx new file mode 100644 index 0000000..d124f79 --- /dev/null +++ b/src/tui/components/Input.tsx @@ -0,0 +1,43 @@ +import { useEffect, useState } from "react"; +import { useTheme } from "@/tui/context/ThemeContext"; + +export interface InputProps { + label?: string; + placeholder?: string; + focused?: boolean; + onInput?: (value: string) => void; + onSubmit?: (value: string) => void; + value?: string; +} + +export function Input({ label, placeholder, focused, onInput, onSubmit, value }: InputProps) { + const { theme } = useTheme(); + const [localValue, setLocalValue] = useState(value || ""); + + useEffect(() => { + if (value !== undefined) { + setLocalValue(value); + } + }, [value]); + + const handleInput = (newValue: string) => { + setLocalValue(newValue); + onInput?.(newValue); + }; + + return ( + + + + ); +} diff --git a/src/tui/components/SettingsModal.tsx b/src/tui/components/SettingsModal.tsx index 2ce78a0..cbe98dd 100644 --- a/src/tui/components/SettingsModal.tsx +++ b/src/tui/components/SettingsModal.tsx @@ -1,65 +1,77 @@ import { type PromptContext, useDialogKeyboard } from "@opentui-ui/dialog/react"; import { useEffect, useState } from "react"; +import { Input } from "@/tui/components/Input"; import { useConfigContext } from "@/tui/context/ConfigContext"; -import { useTextBuffer } from "@/tui/hooks/useTextBuffer"; +import { useTheme } from "@/tui/context/ThemeContext"; +import { type Theme, themes } from "@/tui/theme"; + +type Field = "writer" | "reviewer" | "theme"; export const SettingsModal = ({ dialogId, dismiss }: PromptContext) => { const { config, updateConfig } = useConfigContext(); - const [focusedField, setFocusedField] = useState<"writer" | "reviewer">("writer"); + const { theme, setTheme } = useTheme(); + const [focusedField, setFocusedField] = useState("writer"); const [status, setStatus] = useState<"idle" | "saving">("idle"); + const [writerModel, setWriterModel] = useState(config?.writerModel ?? ""); + const [reviewerModel, setReviewerModel] = useState(config?.reviewerModel ?? ""); - const writerBuffer = useTextBuffer(config?.writerModel ?? ""); - const reviewerBuffer = useTextBuffer(config?.reviewerModel ?? ""); - - // biome-ignore lint/correctness/useExhaustiveDependencies: sync only on config load useEffect(() => { - if (config?.writerModel && writerBuffer.text === "") { - writerBuffer.setText(config.writerModel); - } - if (config?.reviewerModel && reviewerBuffer.text === "") { - reviewerBuffer.setText(config.reviewerModel); - } + if (config?.writerModel) setWriterModel(config.writerModel); + if (config?.reviewerModel) setReviewerModel(config.reviewerModel); }, [config]); + const handleSave = () => { + setStatus("saving"); + updateConfig({ + writerModel, + reviewerModel, + }) + .then(() => { + setStatus("idle"); + dismiss(); + }) + .catch(() => { + setStatus("idle"); + }); + }; + + const cycleTheme = () => { + const currentIndex = themes.findIndex((t: Theme) => t.name === theme.name); + const nextIndex = (currentIndex + 1) % themes.length; + setTheme(nextIndex); + }; + useDialogKeyboard((key) => { if (status === "saving") return; - if (key.name === "tab" || key.name === "down" || key.name === "up") { - setFocusedField((prev) => (prev === "writer" ? "reviewer" : "writer")); + if (key.name === "tab" || key.name === "down") { + setFocusedField((prev) => { + if (prev === "writer") return "reviewer"; + if (prev === "reviewer") return "theme"; + return "writer"; + }); return; } - if (key.name === "return") { - setStatus("saving"); - updateConfig({ - writerModel: writerBuffer.text, - reviewerModel: reviewerBuffer.text, - }) - .then(() => { - setStatus("idle"); - dismiss(); - }) - .catch(() => { - setStatus("idle"); - }); + if (key.name === "up") { + setFocusedField((prev) => { + if (prev === "writer") return "theme"; + if (prev === "reviewer") return "writer"; + return "reviewer"; + }); return; } - const activeBuffer = focusedField === "writer" ? writerBuffer : reviewerBuffer; + if ( + focusedField === "theme" && + (key.name === "right" || key.name === "left" || key.name === "return" || key.name === "space") + ) { + cycleTheme(); + return; + } - if (key.name === "backspace") { - activeBuffer.deleteBack(); - } else if (key.name === "left") { - activeBuffer.moveLeft(); - } else if (key.name === "right") { - activeBuffer.moveRight(); - } else if (key.name === "space") { - activeBuffer.insert(" "); - } else if (key.name?.length === 1 && !key.ctrl && !key.meta) { - const char = key.sequence && key.sequence.length === 1 ? key.sequence : key.name; - if (char && char.length === 1) { - activeBuffer.insert(char); - } + if (key.name === "return") { + handleSave(); } }, dialogId); @@ -68,53 +80,37 @@ export const SettingsModal = ({ dialogId, dismiss }: PromptContext) => { Settings - - - + - - {status === "saving" ? "Saving..." : "Enter: Save | Tab: Switch | Esc: Cancel"} - - - ); -}; + -const Input = ({ - label, - buffer, - isFocused, -}: { - label: string; - buffer: ReturnType; - isFocused: boolean; -}) => { - const text = buffer.text; - const cursor = buffer.cursor; - const before = text.slice(0, cursor); - const cursorChar = text[cursor] || " "; - const after = text.slice(cursor + 1); + + {theme.name} + + - return ( - - {label} - - - {before} - {isFocused ? ( - - {cursorChar} - - ) : ( - cursorChar - )} - {after} + + + {status === "saving" ? "Saving..." : "Enter: Save | Tab: Switch | Esc: Cancel"} diff --git a/src/tui/components/Sidebar.tsx b/src/tui/components/Sidebar.tsx new file mode 100644 index 0000000..cb78a34 --- /dev/null +++ b/src/tui/components/Sidebar.tsx @@ -0,0 +1,53 @@ +import { useAgentContext } from "@/tui/context/AgentContext"; +import { useTheme } from "@/tui/context/ThemeContext"; + +export const Sidebar = () => { + const { state } = useAgentContext(); + const { totalCost, files, phase } = state; + const { theme } = useTheme(); + + return ( + + + STATUS + + + {phase.toUpperCase()} + + + + + + COST + ${totalCost.toFixed(4)} + + + {files.length > 0 && ( + + + EDITED FILES + + + {files.map((path) => ( + + • {path} + + ))} + + + )} + + ); +}; diff --git a/src/tui/components/StatusBar.tsx b/src/tui/components/StatusBar.tsx index c34884e..5eff453 100644 --- a/src/tui/components/StatusBar.tsx +++ b/src/tui/components/StatusBar.tsx @@ -1,6 +1,9 @@ import { useKeyboard } from "@opentui/react"; +import { useAgentContext } from "@/tui/context/AgentContext"; import { useConfigContext } from "@/tui/context/ConfigContext"; -import { useRenderer } from "@/tui/context/RendererContext"; +import { useTheme } from "@/tui/context/ThemeContext"; +import { Keymap } from "@/tui/keyboard/keymap"; +import { areKeyBindingsEqual } from "../keyboard/utils"; export interface StatusBarProps { isRunning: boolean; @@ -9,16 +12,15 @@ export interface StatusBarProps { export const StatusBar = ({ isRunning, disabled = false }: StatusBarProps) => { const { config } = useConfigContext(); - const renderer = useRenderer(); + const { state, retry } = useAgentContext(); + const { theme } = useTheme(); - useKeyboard((key) => { + useKeyboard((keyEvent) => { if (disabled) return; - if (key.name === "escape") { - // Reset window title before destroying renderer - renderer.setTerminalTitle(""); - renderer.destroy(); - process.exit(0); + if (state.error && areKeyBindingsEqual(keyEvent, Keymap.Global.Retry)) { + retry(); + return; } }); @@ -32,14 +34,20 @@ export const StatusBar = ({ isRunning, disabled = false }: StatusBarProps) => { flexDirection: "row", justifyContent: "space-between", alignItems: "center", - minHeight: 3, + height: 3, + borderColor: theme.borderColor, }} > - Esc: Exit | F2: Settings + + {Keymap.Global.Cancel.label}: Exit | {Keymap.Global.Settings.label}: Settings + {state.error ? ` | ${Keymap.Global.Retry.label}: Retry` : ""} + W: {writer} | R: {reviewer} - {isRunning ? "Status: Running" : "Status: Ready"} + + {isRunning ? "Status: Running" : "Status: Ready"} + ); }; diff --git a/src/tui/components/TaskInput.tsx b/src/tui/components/TaskInput.tsx index 40e69f5..5c3a228 100644 --- a/src/tui/components/TaskInput.tsx +++ b/src/tui/components/TaskInput.tsx @@ -1,8 +1,7 @@ -import { useKeyboard } from "@opentui/react"; -import { Effect } from "effect"; -import { readFromClipboard } from "@/services/clipboard"; -import { useEffectRuntime } from "@/tui/context/EffectContext"; -import { useTextBuffer } from "@/tui/hooks/useTextBuffer"; +import type { KeyBinding, PasteEvent, TextareaRenderable } from "@opentui/core"; +import { useEffect, useRef } from "react"; +import { useTheme } from "@/tui/context/ThemeContext"; +import { Keymap } from "@/tui/keyboard/keymap"; export interface TaskInputProps { onTaskSubmit: (task: string) => void; @@ -10,71 +9,60 @@ export interface TaskInputProps { focused: boolean; } +const keyBindings: KeyBinding[] = [ + { + name: Keymap.TaskInput.Submit.name, + ctrl: false, + action: "submit", + }, + { + name: Keymap.TaskInput.NewLine.name, + ctrl: true, + action: "newline", + }, + { + name: "backspace", + ctrl: true, + action: "delete-word-backward", + }, + { + name: "left", + ctrl: true, + action: "word-backward", + }, + { + name: "right", + ctrl: true, + action: "word-forward", + }, +]; + export const TaskInput = ({ onTaskSubmit, isRunning, focused }: TaskInputProps) => { - const runtime = useEffectRuntime(); - const buffer = useTextBuffer(""); + const { theme } = useTheme(); + const inputRef = useRef(null); - useKeyboard((key) => { - if (!focused || isRunning) return; + useEffect(() => { + if (focused && inputRef.current) { + inputRef.current.focus(); + } + }, [focused]); - if (key.name === "return") { - if (key.ctrl || key.meta) { - buffer.insert("\n"); - } else { - if (buffer.text.trim()) { - onTaskSubmit(buffer.text); - buffer.clear(); - } - } - } else if ((key.ctrl || key.meta) && key.name === "v") { - runtime - .runPromise( - readFromClipboard().pipe( - Effect.tapError(Effect.logError), - Effect.catchAll(() => Effect.succeed("")), - ), - ) - .then((text) => buffer.insert(text)); - } else if (key.name === "backspace") { - buffer.deleteBack(); - } else if (key.name === "left") { - buffer.moveLeft(); - } else if (key.name === "right") { - buffer.moveRight(); - } else if (key.name === "up") { - buffer.moveUp(); - } else if (key.name === "down") { - buffer.moveDown(); - } else if (key.name === "space") { - buffer.insert(" "); - } else if (key.name?.length === 1 || (key.sequence && key.sequence.length === 1 && !key.ctrl && !key.meta)) { - const char = key.sequence && key.sequence.length === 1 ? key.sequence : key.name; - if (char && char.length === 1) { - buffer.insert(char); + const handleSubmit = () => { + const text = inputRef.current?.plainText || ""; + if (text?.trim()) { + onTaskSubmit(text); + if (inputRef.current) { + inputRef.current.clear(); + inputRef.current.focus(); } } - }); - - const renderContent = () => { - const text = buffer.text; - const cursor = buffer.cursor; + }; - const before = text.slice(0, cursor); - const cursorChar = text[cursor] || " "; - const after = text.slice(cursor + 1); + const handlePaste = (event: PasteEvent) => { + if (!focused) return event.preventDefault(); - return ( - - {before} - {focused && !isRunning ? ( - {cursorChar === "\n" ? " " : cursorChar} - ) : ( - cursorChar - )} - {cursorChar === "\n" ? "\n" : ""} - {after} - - ); + const text = event.text.trim(); + inputRef.current?.insertText(text); }; return ( @@ -82,24 +70,36 @@ export const TaskInput = ({ onTaskSubmit, isRunning, focused }: TaskInputProps) style={{ width: "100%", border: true, - borderColor: focused ? "cyan" : "gray", + borderColor: focused ? theme.primaryColor : theme.borderColor, flexDirection: "column", - minHeight: 4, + gap: 1, + flexGrow: 1, + flexShrink: 0, + flexBasis: "auto", }} > - - {!buffer.text && !focused ? ( - Enter your writing task here... (Ctrl+Enter for newline) - ) : ( - renderContent() - )} - +