diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..52795b8 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ + use flake diff --git a/.gitignore b/.gitignore index 66edbf5..ab4b64a 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,4 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json # Finder (MacOS) folder config .DS_Store *.sqlite +.direnv/ diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..2c63c08 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,2 @@ +{ +} diff --git a/CLAUDE.md b/CLAUDE.md index ebda995..c68cfa8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -109,3 +109,9 @@ bun --hot ./index.ts ``` For more information, read the Bun API docs in `node_modules/bun-types/docs/**.mdx`. + +## Learning about "effect" + +The source code is available at `.repos/effect` + +Use this instead of node_modules diff --git a/bun.lock b/bun.lock index ae913d8..70ffc60 100644 --- a/bun.lock +++ b/bun.lock @@ -5,17 +5,22 @@ "": { "name": "quipslop", "dependencies": { + "@effect/ai-openrouter": "4.0.0-beta.11", + "@effect/platform-bun": "^4.0.0-beta.11", "@openrouter/ai-sdk-provider": "^2.2.3", "ai": "^6.0.94", + "effect": "4.0.0-beta.11", "ink": "^6.8.0", "puppeteer": "^24.2.0", "react": "^19.2.4", "react-dom": "^19.2.4", }, "devDependencies": { + "@effect/language-service": "^0.75.1", "@types/bun": "latest", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", + "prettier": "^3.8.1", }, "peerDependencies": { "typescript": "^5", @@ -35,6 +40,26 @@ "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + "@effect/ai-openrouter": ["@effect/ai-openrouter@4.0.0-beta.11", "", { "peerDependencies": { "effect": "^4.0.0-beta.11" } }, "sha512-5V9bH8WSnLtV5jdtSviPRxXTxxE8kX3jCub1qxWN59jdbhsj9EB+5F+xeQxS/5ky6JLyylRgivLycKI1sLUWyA=="], + + "@effect/language-service": ["@effect/language-service@0.75.1", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-g9xD2tAQgRFpYC2YgpZq02VeSL5fBbFJ0B/g1o+14NuNmwtaYJc7SjiLWAA9eyhJHosNrn6h1Ye+Kx6j5mN0AA=="], + + "@effect/platform-bun": ["@effect/platform-bun@4.0.0-beta.11", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.11" }, "peerDependencies": { "effect": "^4.0.0-beta.11" } }, "sha512-LQXcLevp71pD+KeB8OciEo4Aq7h1QV5Bx3/Kka8ssjvGSWsuU42s8L/RLLSn9dJUHZGVzJVpv1/VMiffPV226A=="], + + "@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.11", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.19.0" }, "peerDependencies": { "effect": "^4.0.0-beta.11" } }, "sha512-1GdT63U6CpqDO8R5R9vVVn4fcwcJiYJOcKE5Tr208CB9fxAU2VEmp2Qp5rLqllLiHF+50ZYYMDPgqCKtXl8J7w=="], + + "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw=="], + + "@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw=="], + + "@msgpackr-extract/msgpackr-extract-linux-arm": ["@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3", "", { "os": "linux", "cpu": "arm" }, "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw=="], + + "@msgpackr-extract/msgpackr-extract-linux-arm64": ["@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg=="], + + "@msgpackr-extract/msgpackr-extract-linux-x64": ["@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3", "", { "os": "linux", "cpu": "x64" }, "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg=="], + + "@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3", "", { "os": "win32", "cpu": "x64" }, "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ=="], + "@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@2.2.3", "", { "peerDependencies": { "ai": "^6.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-NovC+BaCfEeJwhToDrs8JeDYXXlJdEyz7lcxkjtyePSE4eoAKik872SyDK0MzXKcz8MRkv7XlNhPI6zz4TQp0g=="], "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], @@ -53,6 +78,8 @@ "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], + "@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="], "@vercel/oidc": ["@vercel/oidc@3.1.0", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="], @@ -125,8 +152,12 @@ "degenerator": ["degenerator@5.0.1", "", { "dependencies": { "ast-types": "^0.13.4", "escodegen": "^2.1.0", "esprima": "^4.0.1" } }, "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ=="], + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "devtools-protocol": ["devtools-protocol@0.0.1566079", "", {}, "sha512-MJfAEA1UfVhSs7fbSQOG4czavUp1ajfg6prlAN0+cmfa2zNjaIbvq8VneP7do1WAQQIvgNJWSMeP6UyI90gIlQ=="], + "effect": ["effect@4.0.0-beta.11", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-xBUzwvNPoc7bcNUKS1Z7n6Nd5IL9AB/iV64GS1L44qmUgjuPex2A9DAXGUJkolTb8e65G55JerAX9T0Ha5fmRA=="], + "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], @@ -157,10 +188,14 @@ "extract-zip": ["extract-zip@2.0.1", "", { "dependencies": { "debug": "^4.1.1", "get-stream": "^5.1.0", "yauzl": "^2.10.0" }, "optionalDependencies": { "@types/yauzl": "^2.9.1" }, "bin": { "extract-zip": "cli.js" } }, "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg=="], + "fast-check": ["fast-check@4.5.3", "", { "dependencies": { "pure-rand": "^7.0.0" } }, "sha512-IE9csY7lnhxBnA8g/WI5eg/hygA6MGWJMSNfFRrBlXUciADEhS1EDB0SIsMSvzubzIlOBbVITSsypCsW717poA=="], + "fast-fifo": ["fast-fifo@1.3.2", "", {}, "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ=="], "fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="], + "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=="], "get-east-asian-width": ["get-east-asian-width@1.5.0", "", {}, "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA=="], @@ -177,6 +212,8 @@ "indent-string": ["indent-string@5.0.0", "", {}, "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg=="], + "ini": ["ini@6.0.0", "", {}, "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ=="], + "ink": ["ink@6.8.0", "", { "dependencies": { "@alcalzone/ansi-tokenize": "^0.2.4", "ansi-escapes": "^7.3.0", "ansi-styles": "^6.2.1", "auto-bind": "^5.0.1", "chalk": "^5.6.0", "cli-boxes": "^3.0.0", "cli-cursor": "^4.0.0", "cli-truncate": "^5.1.1", "code-excerpt": "^4.0.0", "es-toolkit": "^1.39.10", "indent-string": "^5.0.0", "is-in-ci": "^2.0.0", "patch-console": "^2.0.0", "react-reconciler": "^0.33.0", "scheduler": "^0.27.0", "signal-exit": "^3.0.7", "slice-ansi": "^8.0.0", "stack-utils": "^2.0.6", "string-width": "^8.1.1", "terminal-size": "^4.0.1", "type-fest": "^5.4.1", "widest-line": "^6.0.0", "wrap-ansi": "^9.0.0", "ws": "^8.18.0", "yoga-layout": "~3.2.1" }, "peerDependencies": { "@types/react": ">=19.0.0", "react": ">=19.0.0", "react-devtools-core": ">=6.1.2" }, "optionalPeers": ["@types/react", "react-devtools-core"] }, "sha512-sbl1RdLOgkO9isK42WCZlJCFN9hb++sX9dsklOvfd1YQ3bQ2AiFu12Q6tFlr0HvEUvzraJntQCCpfEoUe9DSzA=="], "ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="], @@ -195,6 +232,8 @@ "json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="], + "kubernetes-types": ["kubernetes-types@1.30.0", "", {}, "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q=="], + "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], "lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="], @@ -205,8 +244,16 @@ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "msgpackr": ["msgpackr@1.11.8", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.2" } }, "sha512-bC4UGzHhVvgDNS7kn9tV8fAucIYUBuGojcaLiz7v+P63Lmtm0Xeji8B/8tYKddALXxJLpwIeBmUN3u64C4YkRA=="], + + "msgpackr-extract": ["msgpackr-extract@3.0.3", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA=="], + + "multipasta": ["multipasta@0.2.7", "", {}, "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA=="], + "netmask": ["netmask@2.0.2", "", {}, "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg=="], + "node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="], + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], @@ -225,6 +272,8 @@ "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + "prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="], + "progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="], "proxy-agent": ["proxy-agent@6.5.0", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "http-proxy-agent": "^7.0.1", "https-proxy-agent": "^7.0.6", "lru-cache": "^7.14.1", "pac-proxy-agent": "^7.1.0", "proxy-from-env": "^1.1.0", "socks-proxy-agent": "^8.0.5" } }, "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A=="], @@ -237,6 +286,8 @@ "puppeteer-core": ["puppeteer-core@24.37.5", "", { "dependencies": { "@puppeteer/browsers": "2.13.0", "chromium-bidi": "14.0.0", "debug": "^4.4.3", "devtools-protocol": "0.0.1566079", "typed-query-selector": "^2.12.0", "webdriver-bidi-protocol": "0.4.1", "ws": "^8.19.0" } }, "sha512-ybL7iE78YPN4T6J+sPLO7r0lSByp/0NN6PvfBEql219cOnttoTFzCWKiBOjstXSqi/OKpwae623DWAsL7cn2MQ=="], + "pure-rand": ["pure-rand@7.0.1", "", {}, "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ=="], + "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], "react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], @@ -285,6 +336,8 @@ "text-decoder": ["text-decoder@1.2.7", "", { "dependencies": { "b4a": "^1.6.4" } }, "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ=="], + "toml": ["toml@3.0.0", "", {}, "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w=="], + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "type-fest": ["type-fest@5.4.4", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-JnTrzGu+zPV3aXIUhnyWJj4z/wigMsdYajGLIYakqyOW1nPllzXEJee0QQbHj+CTIQtXGlAjuK0UY+2xTyjVAw=="], @@ -295,6 +348,8 @@ "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + "uuid": ["uuid@13.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w=="], + "webdriver-bidi-protocol": ["webdriver-bidi-protocol@0.4.1", "", {}, "sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw=="], "widest-line": ["widest-line@6.0.0", "", { "dependencies": { "string-width": "^8.1.0" } }, "sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA=="], @@ -307,6 +362,8 @@ "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], + "yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="], + "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], diff --git a/db.ts b/db.ts index 6555a05..07c7a6c 100644 --- a/db.ts +++ b/db.ts @@ -14,27 +14,36 @@ db.exec(` `); export function saveRound(round: RoundState) { - const insert = db.prepare("INSERT INTO rounds (num, data) VALUES ($num, $data)"); + const insert = db.prepare( + "INSERT INTO rounds (num, data) VALUES ($num, $data)", + ); insert.run({ $num: round.num, $data: JSON.stringify(round) }); } export function getRounds(page: number = 1, limit: number = 10) { const offset = (page - 1) * limit; - const countQuery = db.query("SELECT COUNT(*) as count FROM rounds").get() as { count: number }; - const rows = db.query("SELECT data FROM rounds ORDER BY num DESC, id DESC LIMIT $limit OFFSET $offset") + const countQuery = db.query("SELECT COUNT(*) as count FROM rounds").get() as { + count: number; + }; + const rows = db + .query( + "SELECT data FROM rounds ORDER BY num DESC, id DESC LIMIT $limit OFFSET $offset", + ) .all({ $limit: limit, $offset: offset }) as { data: string }[]; return { - rounds: rows.map(r => JSON.parse(r.data) as RoundState), + rounds: rows.map((r) => JSON.parse(r.data) as RoundState), total: countQuery.count, page, limit, - totalPages: Math.ceil(countQuery.count / limit) + totalPages: Math.ceil(countQuery.count / limit), }; } export function getAllRounds() { - const rows = db.query("SELECT data FROM rounds ORDER BY num ASC, id ASC").all() as { data: string }[]; - return rows.map(r => JSON.parse(r.data) as RoundState); + const rows = db + .query("SELECT data FROM rounds ORDER BY num ASC, id ASC") + .all() as { data: string }[]; + return rows.map((r) => JSON.parse(r.data) as RoundState); } export function clearAllRounds() { diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..d978438 --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1771207753, + "narHash": "sha256-b9uG8yN50DRQ6A7JdZBfzq718ryYrlmGgqkRm9OOwCE=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "d1c15b7d5806069da59e819999d70e1cec0760bf", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..ac5b661 --- /dev/null +++ b/flake.nix @@ -0,0 +1,22 @@ + { + inputs = { + nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable"; + }; + outputs = {nixpkgs, ...}: let + forAllSystems = function: + nixpkgs.lib.genAttrs nixpkgs.lib.systems.flakeExposed ( + system: function nixpkgs.legacyPackages.${system} + ); + in { + formatter = forAllSystems (pkgs: pkgs.alejandra); + devShells = forAllSystems (pkgs: { + default = pkgs.mkShell { + packages = with pkgs; [ + bun + corepack + nodejs + ]; + }; + }); + }; + } diff --git a/game.ts b/game.ts index 044e3f9..62ef8aa 100644 --- a/game.ts +++ b/game.ts @@ -1,7 +1,402 @@ -import { generateText } from "ai"; -import { createOpenRouter } from "@openrouter/ai-sdk-provider"; -import { mkdirSync, appendFileSync } from "node:fs"; -import { join } from "node:path"; +import { + OpenRouterClient, + OpenRouterLanguageModel, +} from "@effect/ai-openrouter"; +import { BunFileSystem } from "@effect/platform-bun"; +import { + Config, + Data, + Effect, + flow, + Layer, + Logger, + pipe, + Random, + ServiceMap, +} from "effect"; +import { ALL_PROMPTS } from "./prompts"; +import { LanguageModel } from "effect/unstable/ai"; +import { saveRound } from "./db"; +import { FetchHttpClient } from "effect/unstable/http"; +import * as Path from "node:path"; + +export const runGame = ( + runs: number, + state: GameState, + rerender: () => void, + onViewerVotingStart?: () => void, +) => + Effect.runPromise(runGameEffect(runs, state, rerender, onViewerVotingStart)); + +export const runGameEffect = Effect.fn("runGame")( + function* ( + runs: number, + state: GameState, + rerender: () => void, + onViewerVotingStart?: () => void, + ) { + yield* Effect.logInfo("startup", `Game starting: ${runs} rounds`, { + models: MODELS.map((m) => m.id), + }); + + let startRound = 1; + const lastCompletedRound = state.completed.at(-1); + if (lastCompletedRound) { + startRound = lastCompletedRound.num + 1; + } + let endRound = startRound + runs - 1; + + for (let r = startRound; r <= endRound; r++) { + const roundGeneration = state.generation; + + const update = (f: () => void) => + Effect.suspend(() => { + if (state.generation !== roundGeneration) { + return Effect.interrupt; + } + f(); + rerender(); + return Effect.void; + }); + + yield* runRound(runs, r, state, update, onViewerVotingStart).pipe( + Effect.ignoreCause({ log: "Error" }), + Effect.annotateLogs({ round: r }), + ); + } + }, + (effect) => Effect.provide(effect, EnvLayer), +); + +const runRound = Effect.fn("runRound")(function* ( + runs: number, + r: number, + state: GameState, + update: (f: () => void) => Effect.Effect, + onViewerVotingStart?: () => void, +) { + while (state.isPaused) { + yield* Effect.sleep(1000); + } + + const gen = yield* GameGeneration; + const models = yield* Random.shuffle(MODELS); + const prompter = models[0]!; + const contestants: [Model, Model] = [models[1]!, models[2]!] as const; + const voters = [prompter, ...models.slice(3)]; + const now = Date.now(); + + const round: RoundState = { + num: r, + phase: "prompting", + prompter, + promptTask: { model: prompter, startedAt: now }, + contestants, + answerTasks: [ + { model: contestants[0], startedAt: 0 }, + { model: contestants[1], startedAt: 0 }, + ], + votes: [], + }; + yield* update(() => { + state.active = round; + }); + yield* Effect.logInfo(`=== Round ${r}/${runs} ===`, { + prompter: prompter.name, + contestants: [contestants[0].name, contestants[1].name], + voters: voters.map((v) => v.name), + }); + + // ── Prompt phase ── + const prompt = yield* gen.generatePrompt.pipe( + withModel(prompter), + Effect.onError(() => + update(() => { + round.promptTask.finishedAt = Date.now(); + round.promptTask.error = "Failed after 3 attempts"; + round.phase = "done"; + state.completed = [...state.completed, round]; + state.active = null; + }), + ), + ); + yield* update(() => { + round.promptTask.finishedAt = Date.now(); + round.promptTask.result = prompt; + round.prompt = prompt; + }); + + // ── Answer phase ── + const answerStart = Date.now(); + yield* update(() => { + round.phase = "answering"; + round.answerTasks[0].startedAt = answerStart; + round.answerTasks[1].startedAt = answerStart; + }); + + yield* Effect.forEach( + round.answerTasks, + Effect.fn(function* (task) { + task.result = yield* gen.generateAnswer(prompt).pipe( + withModel(task.model), + Effect.onError(() => { + task.error = "Failed to answer"; + task.result = "[no answer]"; + return Effect.void; + }), + ); + yield* update(() => { + task.finishedAt = Date.now(); + }); + }), + { concurrency: "unbounded", discard: true }, + ); + + // ── Vote phase ── + const answerA = round.answerTasks[0].result!; + const answerB = round.answerTasks[1].result!; + const voteStart = Date.now(); + yield* update(() => { + round.phase = "voting"; + round.votes = voters.map((v) => ({ voter: v, startedAt: voteStart })); + round.viewerVotesA = 0; + round.viewerVotesB = 0; + round.viewerVotingEndsAt = Date.now() + 30_000; + }); + if (onViewerVotingStart) { + onViewerVotingStart(); + } + const showAFirst = (yield* Random.next) > 0.5; + + yield* Effect.all( + [ + // Model votes + Effect.forEach( + round.votes, + Effect.fn(function* (vote) { + yield* pipe( + gen.generateVote({ + prompt, + answerA: showAFirst ? answerA : answerB, + answerB: showAFirst ? answerB : answerA, + }), + withModel(vote.voter), + Effect.matchCause({ + onFailure(_) { + vote.error = true; + }, + onSuccess(result) { + const reversed = showAFirst + ? contestants + : ([...contestants].reverse() as [Model, Model]); + const votedFor = result === "A" ? reversed[0] : reversed[1]; + vote.votedFor = votedFor; + }, + }), + ); + yield* update(() => { + vote.finishedAt = Date.now(); + }); + }), + { concurrency: "unbounded", discard: true }, + ), + // 30-second viewer voting window + Effect.sleep(30_000), + ], + { concurrency: "unbounded", discard: true }, + ); + + // ── Score ── + yield* update(() => { + let votesA = 0; + let votesB = 0; + for (const v of round.votes) { + if (v.votedFor === contestants[0]) votesA++; + else if (v.votedFor === contestants[1]) votesB++; + } + round.scoreA = votesA * 100; + round.scoreB = votesB * 100; + round.phase = "done"; + if (votesA > votesB) { + state.scores[contestants[0].name] = + (state.scores[contestants[0].name] || 0) + 1; + } else if (votesB > votesA) { + state.scores[contestants[1].name] = + (state.scores[contestants[1].name] || 0) + 1; + } + // Viewer vote scoring + const vvA = round.viewerVotesA ?? 0; + const vvB = round.viewerVotesB ?? 0; + if (vvA > vvB) { + state.viewerScores[contestants[0].name] = + (state.viewerScores[contestants[0].name] || 0) + 1; + } else if (vvB > vvA) { + state.viewerScores[contestants[1].name] = + (state.viewerScores[contestants[1].name] || 0) + 1; + } + }); + + yield* Effect.sleep(5000); + + // Archive round + saveRound(round); + yield* update(() => { + state.completed = [...state.completed, round]; + state.active = null; + }); +}); + +export class GameGeneration extends ServiceMap.Service()( + "quipslop/game-effect/GameGeneration", + { + make: Effect.gen(function* () { + const ai = yield* LanguageModel.LanguageModel; + + const systemPrompt = Effect.gen(function* () { + const examples = yield* Random.shuffle(ALL_PROMPTS); + return `You are a comedy writer for the game Quiplash. Generate a single funny fill-in-the-blank prompt that players will try to answer. The prompt should be surprising and designed to elicit hilarious responses. Return ONLY the prompt text, nothing else. Keep it short (under 15 words). + +Use a wide VARIETY of prompt formats. Do NOT always use "The worst thing to..." — mix it up! Here are examples of the range of styles: + +${examples.map((p) => `- ${p}`).join("\n")} + +Come up with something ORIGINAL — don't copy these examples.`; + }); + + const generatePrompt = Effect.gen(function* () { + yield* Effect.logInfo("Calling api"); + const response = yield* ai.generateText({ + prompt: [ + { role: "system", content: yield* systemPrompt }, + { + role: "user", + content: + "Generate a single original Quiplash prompt. Be creative and don't repeat common patterns.", + }, + ], + }); + yield* Effect.logInfo("Raw response", { + rawText: response.text, + usage: response.usage, + }); + if (response.text.length <= 10) { + return yield* new ResponseTooSmall(); + } + return cleanResponse(response.text); + }).pipe( + Effect.retry({ + while: (e) => + (e._tag === "AiError" && e.isRetryable) || + e._tag === "ResponseTooSmall", + times: 3, + }), + Effect.annotateLogs({ + method: "generatePrompt", + }), + ); + + const generateAnswer = Effect.fn("generateAnswer")( + function* (prompt: string) { + yield* Effect.logInfo("Calling api"); + const response = yield* ai.generateText({ + prompt: [ + { + role: "system", + content: `You are playing Quiplash! You'll be given a fill-in-the-blank prompt. Give the FUNNIEST possible answer. Be creative, edgy, unexpected, and concise. Reply with ONLY your answer — no quotes, no explanation, no preamble. Keep it short (under 12 words). Keep it concise and witty.`, + }, + { + role: "user", + content: `Fill in the blank: ${prompt}`, + }, + ], + }); + yield* Effect.logInfo("Raw response", { + rawText: response.text, + usage: response.usage, + }); + if (response.text.length <= 3) { + return yield* new ResponseTooSmall(); + } + return cleanResponse(response.text); + }, + Effect.retry({ + while: (e) => + (e._tag === "AiError" && e.isRetryable) || + e._tag === "ResponseTooSmall", + times: 3, + }), + Effect.annotateLogs({ method: "generateAnswer" }), + ); + + const generateVote = Effect.fn("generateVote")( + function* ({ + prompt, + answerA, + answerB, + }: { + readonly prompt: string; + readonly answerA: string; + readonly answerB: string; + }) { + yield* Effect.logInfo("Calling api"); + const response = yield* ai.generateText({ + prompt: [ + { + role: "system", + content: `You are a judge in a comedy game. You'll see a fill-in-the-blank prompt and two answers. Pick which answer is FUNNIER. You MUST respond with exactly "A" or "B" — nothing else.`, + }, + { + role: "user", + content: `Prompt: "${prompt}"\n\nAnswer A: "${answerA}"\nAnswer B: "${answerB}"\n\nWhich is funnier? Reply with just A or B.`, + }, + ], + }); + yield* Effect.logInfo("Raw response", { + rawText: response.text, + usage: response.usage, + }); + const cleaned = response.text.trim().toUpperCase(); + if (!cleaned.startsWith("A") && !cleaned.startsWith("B")) { + return yield* new InvalidVote(); + } + return cleaned.startsWith("A") ? "A" : "B"; + }, + Effect.retry({ + while: (e) => + (e._tag === "AiError" && e.isRetryable) || e._tag === "InvalidVote", + times: 3, + }), + Effect.annotateLogs({ method: "generateVote" }), + ); + + return { + generatePrompt, + generateAnswer, + generateVote, + } as const; + }), + }, +) { + static layer = Layer.effect(this, this.make).pipe( + Layer.provide( + OpenRouterLanguageModel.layer({ + model: "", + config: { + reasoning: { + effort: "medium", + }, + max_completion_tokens: 1000, + }, + }), + ), + Layer.provide( + OpenRouterClient.layerConfig({ + apiKey: Config.redacted("OPENROUTER_API_KEY"), + }), + ), + Layer.provide(FetchHttpClient.layer), + ); +} // ── Models ────────────────────────────────────────────────────────────────── @@ -79,421 +474,44 @@ export type GameState = { generation: number; }; -// ── OpenRouter ────────────────────────────────────────────────────────────── - -const openrouter = createOpenRouter({ - apiKey: process.env.OPENROUTER_API_KEY, - extraBody: { - reasoning: { - effort: "medium", - }, - }, -}); - -// ── Logger ────────────────────────────────────────────────────────────────── - -const LOGS_DIR = join(import.meta.dir, "logs"); -mkdirSync(LOGS_DIR, { recursive: true }); -const LOG_FILE = join( - LOGS_DIR, - `game-${new Date().toISOString().replace(/[:.]/g, "-")}.log`, -); - -export { LOG_FILE }; - -export function log( - level: "INFO" | "WARN" | "ERROR", - category: string, - message: string, - data?: Record, -) { - const ts = new Date().toISOString(); - let line = `[${ts}] ${level} [${category}] ${message}`; - if (data) { - line += " " + JSON.stringify(data); - } - appendFileSync(LOG_FILE, line + "\n"); - if (level === "ERROR") { - console.error(line); - } else if (level === "WARN") { - console.warn(line); - } else { - console.log(line); - } -} - -// ── Helpers ───────────────────────────────────────────────────────────────── - -export function shuffle(arr: T[]): T[] { - const a = [...arr]; - for (let i = a.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - [a[i], a[j]] = [a[j]!, a[i]!]; - } - return a; -} +// ── Errors ────────────────────────────────────────────────────────────────── -export async function withRetry( - fn: () => Promise, - validate: (result: T) => boolean, - retries = 3, - label = "unknown", -): Promise { - let lastErr: unknown; - for (let attempt = 1; attempt <= retries; attempt++) { - try { - const result = await fn(); - if (validate(result)) { - log("INFO", label, `Success on attempt ${attempt}`, { - result: typeof result === "string" ? result : String(result), - }); - return result; - } - const msg = `Validation failed (attempt ${attempt}/${retries})`; - log("WARN", label, msg, { - result: typeof result === "string" ? result : String(result), - }); - lastErr = new Error(`${msg}: ${JSON.stringify(result).slice(0, 100)}`); - } catch (err) { - const errMsg = err instanceof Error ? err.message : String(err); - log("WARN", label, `Error on attempt ${attempt}/${retries}: ${errMsg}`, { - error: errMsg, - stack: err instanceof Error ? err.stack : undefined, - }); - lastErr = err; - } - if (attempt < retries) { - await new Promise((r) => setTimeout(r, 1000 * attempt)); - } - } - log("ERROR", label, `All ${retries} attempts failed`, { - lastError: lastErr instanceof Error ? lastErr.message : String(lastErr), - }); - throw lastErr; -} +export class ResponseTooSmall extends Data.TaggedError("ResponseTooSmall") {} +export class InvalidVote extends Data.TaggedError("InvalidVote") {} -export function isRealString(s: string, minLength = 5): boolean { - return s.length >= minLength; -} +// ── Utils ─────────────────────────────────────────────────────────────────── -export function cleanResponse(text: string): string { +function cleanResponse(text: string): string { const trimmed = text.trim(); - if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || - (trimmed.startsWith("'") && trimmed.endsWith("'"))) { + if ( + (trimmed.startsWith('"') && trimmed.endsWith('"')) || + (trimmed.startsWith("'") && trimmed.endsWith("'")) + ) { return trimmed.slice(1, -1); } return trimmed; } -// ── AI functions ──────────────────────────────────────────────────────────── - -import { ALL_PROMPTS } from "./prompts"; - -function buildPromptSystem(): string { - const examples = shuffle([...ALL_PROMPTS]).slice(0, 80); - return `You are a comedy writer for the game Quiplash. Generate a single funny fill-in-the-blank prompt that players will try to answer. The prompt should be surprising and designed to elicit hilarious responses. Return ONLY the prompt text, nothing else. Keep it short (under 15 words). - -Use a wide VARIETY of prompt formats. Do NOT always use "The worst thing to..." — mix it up! Here are examples of the range of styles: - -${examples.map((p) => `- ${p}`).join("\n")} - -Come up with something ORIGINAL — don't copy these examples.`; -} - -export async function callGeneratePrompt(model: Model): Promise { - log("INFO", `prompt:${model.name}`, "Calling API", { modelId: model.id }); - const system = buildPromptSystem(); - const { text, usage, reasoning } = await generateText({ - model: openrouter.chat(model.id), - system, - prompt: - "Generate a single original Quiplash prompt. Be creative and don't repeat common patterns.", - }); - - log("INFO", `prompt:${model.name}`, "Raw response", { - rawText: text, - usage, - }); - return cleanResponse(text); -} - -export async function callGenerateAnswer( - model: Model, - prompt: string, -): Promise { - log("INFO", `answer:${model.name}`, "Calling API", { - modelId: model.id, - prompt, - }); - const { text, usage, reasoning } = await generateText({ - model: openrouter.chat(model.id), - system: `You are playing Quiplash! You'll be given a fill-in-the-blank prompt. Give the FUNNIEST possible answer. Be creative, edgy, unexpected, and concise. Reply with ONLY your answer — no quotes, no explanation, no preamble. Keep it short (under 12 words). Keep it concise and witty.`, - prompt: `Fill in the blank: ${prompt}`, - }); - - log("INFO", `answer:${model.name}`, "Raw response", { - rawText: text, - usage, - }); - return cleanResponse(text); -} - -export async function callVote( - voter: Model, - prompt: string, - a: { answer: string }, - b: { answer: string }, -): Promise<"A" | "B"> { - log("INFO", `vote:${voter.name}`, "Calling API", { - modelId: voter.id, - prompt, - answerA: a.answer, - answerB: b.answer, - }); - const { text, usage, reasoning } = await generateText({ - model: openrouter.chat(voter.id), - system: `You are a judge in a comedy game. You'll see a fill-in-the-blank prompt and two answers. Pick which answer is FUNNIER. You MUST respond with exactly "A" or "B" — nothing else.`, - prompt: `Prompt: "${prompt}"\n\nAnswer A: "${a.answer}"\nAnswer B: "${b.answer}"\n\nWhich is funnier? Reply with just A or B.`, - }); - - log("INFO", `vote:${voter.name}`, "Raw response", { rawText: text, usage }); - const cleaned = text.trim().toUpperCase(); - if (!cleaned.startsWith("A") && !cleaned.startsWith("B")) { - throw new Error(`Invalid vote: "${text.trim()}"`); - } - return cleaned.startsWith("A") ? "A" : "B"; -} - -import { saveRound } from "./db.ts"; - -// ── Game loop ─────────────────────────────────────────────────────────────── - -export async function runGame( - runs: number, - state: GameState, - rerender: () => void, - onViewerVotingStart?: () => void, -) { - let startRound = 1; - const lastCompletedRound = state.completed.at(-1); - if (lastCompletedRound) { - startRound = lastCompletedRound.num + 1; - } - - let endRound = startRound + runs - 1; - - for (let r = startRound; r <= endRound; r++) { - while (state.isPaused) { - await new Promise((resolve) => setTimeout(resolve, 1000)); - } - const roundGeneration = state.generation; - - // Reset round counter if generation changed (e.g. admin reset) - const latest = state.completed.at(-1); - const expectedR = latest ? latest.num + 1 : 1; - if (r !== expectedR) { - r = expectedR; - endRound = r + runs - 1; - } - - const shuffled = shuffle([...MODELS]); - const prompter = shuffled[0]!; - const contA = shuffled[1]!; - const contB = shuffled[2]!; - const voters = [prompter, ...shuffled.slice(3)]; - const now = Date.now(); - - const round: RoundState = { - num: r, - phase: "prompting", - prompter, - promptTask: { model: prompter, startedAt: now }, - contestants: [contA, contB], - answerTasks: [ - { model: contA, startedAt: 0 }, - { model: contB, startedAt: 0 }, - ], - votes: [], - }; - state.active = round; - log("INFO", "round", `=== Round ${r}/${runs} ===`, { - prompter: prompter.name, - contestants: [contA.name, contB.name], - voters: voters.map((v) => v.name), - }); - rerender(); - - // ── Prompt phase ── - try { - const prompt = await withRetry( - () => callGeneratePrompt(prompter), - (s) => isRealString(s, 10), - 3, - `R${r}:prompt:${prompter.name}`, - ); - if (state.generation !== roundGeneration) { - continue; - } - round.promptTask.finishedAt = Date.now(); - round.promptTask.result = prompt; - round.prompt = prompt; - rerender(); - } catch { - if (state.generation !== roundGeneration) { - continue; - } - round.promptTask.finishedAt = Date.now(); - round.promptTask.error = "Failed after 3 attempts"; - round.phase = "done"; - state.completed = [...state.completed, round]; - state.active = null; - rerender(); - continue; - } - - // ── Answer phase ── - round.phase = "answering"; - const answerStart = Date.now(); - round.answerTasks[0].startedAt = answerStart; - round.answerTasks[1].startedAt = answerStart; - rerender(); +const withModel = (model: Model) => + flow( + OpenRouterLanguageModel.withConfigOverride({ + model: model.id, + }), + Effect.annotateLogs({ + modelId: model.id, + }), + ); - await Promise.all( - round.answerTasks.map(async (task) => { - if (state.generation !== roundGeneration) { - return; - } - try { - const answer = await withRetry( - () => callGenerateAnswer(task.model, round.prompt!), - (s) => isRealString(s, 3), - 3, - `R${r}:answer:${task.model.name}`, - ); - if (state.generation !== roundGeneration) { - return; - } - task.result = answer; - } catch { - if (state.generation !== roundGeneration) { - return; - } - task.error = "Failed to answer"; - task.result = "[no answer]"; - } - if (state.generation !== roundGeneration) { - return; - } - task.finishedAt = Date.now(); - rerender(); - }), - ); - if (state.generation !== roundGeneration) { - continue; - } - - // ── Vote phase ── - round.phase = "voting"; - const answerA = round.answerTasks[0].result!; - const answerB = round.answerTasks[1].result!; - const voteStart = Date.now(); - round.votes = voters.map((v) => ({ voter: v, startedAt: voteStart })); - - // Initialize viewer voting - round.viewerVotesA = 0; - round.viewerVotesB = 0; - round.viewerVotingEndsAt = Date.now() + 30_000; - onViewerVotingStart?.(); - rerender(); - - await Promise.all([ - // Model votes - Promise.all( - round.votes.map(async (vote) => { - if (state.generation !== roundGeneration) { - return; - } - try { - const showAFirst = Math.random() > 0.5; - const first = showAFirst ? { answer: answerA } : { answer: answerB }; - const second = showAFirst ? { answer: answerB } : { answer: answerA }; - - const result = await withRetry( - () => callVote(vote.voter, round.prompt!, first, second), - (v) => v === "A" || v === "B", - 3, - `R${r}:vote:${vote.voter.name}`, - ); - if (state.generation !== roundGeneration) { - return; - } - const votedFor = showAFirst - ? result === "A" - ? contA - : contB - : result === "A" - ? contB - : contA; - - vote.finishedAt = Date.now(); - vote.votedFor = votedFor; - } catch { - if (state.generation !== roundGeneration) { - return; - } - vote.finishedAt = Date.now(); - vote.error = true; - } - if (state.generation !== roundGeneration) { - return; - } - rerender(); - }), - ), - // 30-second viewer voting window - new Promise((r) => setTimeout(r, 30_000)), - ]); - if (state.generation !== roundGeneration) { - continue; - } - - // ── Score ── - let votesA = 0; - let votesB = 0; - for (const v of round.votes) { - if (v.votedFor === contA) votesA++; - else if (v.votedFor === contB) votesB++; - } - round.scoreA = votesA * 100; - round.scoreB = votesB * 100; - round.phase = "done"; - if (votesA > votesB) { - state.scores[contA.name] = (state.scores[contA.name] || 0) + 1; - } else if (votesB > votesA) { - state.scores[contB.name] = (state.scores[contB.name] || 0) + 1; - } - // Viewer vote scoring - const vvA = round.viewerVotesA ?? 0; - const vvB = round.viewerVotesB ?? 0; - if (vvA > vvB) { - state.viewerScores[contA.name] = (state.viewerScores[contA.name] || 0) + 1; - } else if (vvB > vvA) { - state.viewerScores[contB.name] = (state.viewerScores[contB.name] || 0) + 1; - } - rerender(); +// ── Logger ────────────────────────────────────────────────────────────────── - await new Promise((r) => setTimeout(r, 5000)); - if (state.generation !== roundGeneration) { - continue; - } +const LOGS_DIR = Path.join(import.meta.dir, "logs"); +export const LOG_FILE = Path.join( + LOGS_DIR, + `game-${new Date().toISOString().replace(/[:.]/g, "-")}.log`, +); - // Archive round - saveRound(round); - state.completed = [...state.completed, round]; - state.active = null; - rerender(); - } +const FileLogger = Logger.layer([ + Logger.toFile(Logger.formatLogFmt, LOG_FILE), +]).pipe(Layer.provide(BunFileSystem.layer)); - state.done = true; - rerender(); -} +const EnvLayer = GameGeneration.layer.pipe(Layer.provideMerge(FileLogger)); diff --git a/package.json b/package.json index 50f949b..24da0fd 100644 --- a/package.json +++ b/package.json @@ -11,16 +11,21 @@ "start:stream:dryrun": "bun ./scripts/stream-browser.ts dryrun" }, "devDependencies": { + "@effect/language-service": "^0.75.1", "@types/bun": "latest", "@types/react": "^19.2.14", - "@types/react-dom": "^19.2.3" + "@types/react-dom": "^19.2.3", + "prettier": "^3.8.1" }, "peerDependencies": { "typescript": "^5" }, "dependencies": { + "@effect/ai-openrouter": "4.0.0-beta.11", + "@effect/platform-bun": "^4.0.0-beta.11", "@openrouter/ai-sdk-provider": "^2.2.3", "ai": "^6.0.94", + "effect": "4.0.0-beta.11", "ink": "^6.8.0", "puppeteer": "^24.2.0", "react": "^19.2.4", diff --git a/quipslop.tsx b/quipslop.tsx index 31008d8..4dc3f5f 100644 --- a/quipslop.tsx +++ b/quipslop.tsx @@ -1,15 +1,12 @@ import { useState, useEffect, useRef, useCallback } from "react"; -import { render, Box, Text, Static, useApp } from "ink"; +import { render, Box, Text, Static } from "ink"; import { MODELS, MODEL_COLORS, NAME_PAD, LOG_FILE, - log, runGame, type Model, - type TaskInfo, - type VoteInfo, type RoundState, type GameState, } from "./game.ts"; @@ -267,15 +264,6 @@ const runsVal = runsArg ? runsArg.split("=")[1] : "infinite"; const runs = runsVal === "infinite" ? Infinity : parseInt(runsVal || "infinite", 10); -if (!process.env.OPENROUTER_API_KEY) { - console.error("Error: Set OPENROUTER_API_KEY environment variable"); - process.exit(1); -} - -log("INFO", "startup", `Game starting: ${runs} rounds`, { - models: MODELS.map((m) => m.id), -}); - console.log( `\n\x1b[1m\x1b[45m\x1b[30m quipslop \x1b[0m \x1b[2m${runs} rounds\x1b[0m`, ); diff --git a/server.ts b/server.ts index 7f26f0f..dbcd5fb 100644 --- a/server.ts +++ b/server.ts @@ -8,7 +8,6 @@ import { clearAllRounds, getRounds, getAllRounds } from "./db.ts"; import { MODELS, LOG_FILE, - log, runGame, type GameState, type RoundState, @@ -103,7 +102,9 @@ const MAX_HISTORY_CACHE_KEYS = parsePositiveInt( ); const FOSSABOT_CHANNEL_LOGIN = ( process.env.FOSSABOT_CHANNEL_LOGIN ?? "quipslop" -).trim().toLowerCase(); +) + .trim() + .toLowerCase(); const FOSSABOT_VOTE_SECRET = process.env.FOSSABOT_VOTE_SECRET ?? ""; const FOSSABOT_VALIDATE_TIMEOUT_MS = parsePositiveInt( process.env.FOSSABOT_VALIDATE_TIMEOUT_MS, @@ -304,9 +305,9 @@ async function validateFossabotRequest(validateUrl: string): Promise { }); if (!res.ok) return false; - const body = (await res.json().catch(() => null)) as - | { context_url?: unknown } - | null; + const body = (await res.json().catch(() => null)) as { + context_url?: unknown; + } | null; return Boolean(body && typeof body.context_url === "string"); } catch { return false; @@ -453,7 +454,10 @@ const server = Bun.serve({ } const providedSecret = url.searchParams.get("secret") ?? ""; - if (!providedSecret || !secureCompare(providedSecret, FOSSABOT_VOTE_SECRET)) { + if ( + !providedSecret || + !secureCompare(providedSecret, FOSSABOT_VOTE_SECRET) + ) { log("WARN", "vote:fossabot", "Rejected due to missing/invalid secret", { ip, }); @@ -468,12 +472,20 @@ const server = Bun.serve({ .get("x-fossabot-channellogin") ?.trim() .toLowerCase(); - if (channelProvider !== "twitch" || channelLogin !== FOSSABOT_CHANNEL_LOGIN) { - log("WARN", "vote:fossabot", "Rejected due to channel/provider mismatch", { - ip, - channelProvider, - channelLogin, - }); + if ( + channelProvider !== "twitch" || + channelLogin !== FOSSABOT_CHANNEL_LOGIN + ) { + log( + "WARN", + "vote:fossabot", + "Rejected due to channel/provider mismatch", + { + ip, + channelProvider, + channelLogin, + }, + ); return new Response("", { status: 403 }); } @@ -647,7 +659,9 @@ const server = Bun.serve({ gameState.completed = []; gameState.active = null; gameState.scores = Object.fromEntries(MODELS.map((m) => [m.name, 0])); - gameState.viewerScores = Object.fromEntries(MODELS.map((m) => [m.name, 0])); + gameState.viewerScores = Object.fromEntries( + MODELS.map((m) => [m.name, 0]), + ); gameState.done = false; gameState.isPaused = true; gameState.generation += 1; diff --git a/tsconfig.json b/tsconfig.json index eee7ab0..20feee3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -24,6 +24,12 @@ // Some stricter flags (disabled by default) "noUnusedLocals": false, "noUnusedParameters": false, - "noPropertyAccessFromIndexSignature": false + "noPropertyAccessFromIndexSignature": false, + + "plugins": [ + { + "name": "@effect/language-service" + } + ] } }