diff --git a/chat/package.json b/chat/package.json index 6fb0edad..6b8f5b42 100644 --- a/chat/package.json +++ b/chat/package.json @@ -30,6 +30,8 @@ "@inkandswitch/patchwork-elements": "^6.0.2", "@inkandswitch/patchwork-filesystem": "^0.2.9", "cute.txt": "^0.0.6", + "dompurify": "^3.4.16", + "marked": "^18.1.0", "solid-automerge": "^2.0.1", "solid-js": "^1.9.11" }, @@ -37,6 +39,7 @@ "@chee/patchwork-bundles": "^0.2.0", "@inkandswitch/patchwork-bootloader": "^0.7.2", "happy-dom": "^15.11.7", + "jsdom": "^30.1.2", "typescript": "^5.8.3", "vite": "^5.4.19", "vite-plugin-css-injected-by-js": "^3.5.2", diff --git a/chat/pnpm-lock.yaml b/chat/pnpm-lock.yaml index 7d4289fa..ccf9ef02 100644 --- a/chat/pnpm-lock.yaml +++ b/chat/pnpm-lock.yaml @@ -50,6 +50,12 @@ importers: cute.txt: specifier: ^0.0.6 version: 0.0.6(@automerge/automerge@3.4.0)(@codemirror/state@6.7.5)(@codemirror/view@6.43.12)(solid-js@1.9.14) + dompurify: + specifier: ^3.4.16 + version: 3.4.16 + marked: + specifier: ^18.1.0 + version: 18.1.0 solid-automerge: specifier: ^2.0.1 version: 2.0.1(@automerge/automerge-repo@2.5.6)(solid-js@1.9.14) @@ -66,6 +72,9 @@ importers: happy-dom: specifier: ^15.11.7 version: 15.11.7 + jsdom: + specifier: ^30.1.2 + version: 30.1.2(@noble/hashes@1.8.0) typescript: specifier: ^5.8.3 version: 5.9.3 @@ -80,10 +89,18 @@ importers: version: 2.11.14(solid-js@1.9.14)(vite@5.4.21(@types/node@20.19.43)) vitest: specifier: ^3.2.4 - version: 3.2.7(@types/debug@4.1.13)(@types/node@20.19.43)(happy-dom@15.11.7) + version: 3.2.7(@types/debug@4.1.13)(@types/node@20.19.43)(happy-dom@15.11.7)(jsdom@30.1.2(@noble/hashes@1.8.0)) packages: + '@asamuzakjp/css-color@7.1.5': + resolution: {integrity: sha512-jCpAvj8apa7SKHZYogk0rbXB3Kb0hLFrpkWF+Ap+vBA5rirItnpv9NKyAyEuDuFizvl0qwnj6JWR70RGJ0rOlw==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + + '@asamuzakjp/dom-selector@9.2.4': + resolution: {integrity: sha512-YnVzxDxLaqL0t7Q4wfcgHZjg55Wm6y5EaO7Bhu3q9J7wq/8iw3PhsnWiOQhYekx4VexnbkgWpTT6n+QIV7kczw==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + '@automerge/automerge-repo-network-broadcastchannel@2.6.0-subduction.48': resolution: {integrity: sha512-fyq/ZqWkrYOuNrQ917KeCaRSuY6fzqH6Q4KCb5czjwKPKO2zFIGKVfK5hZjYsHGWRKuh5BzViaONHn6bJ0Jk4w==} engines: {node: '>=22.13'} @@ -207,6 +224,10 @@ packages: resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} engines: {node: '>=6.9.0'} + '@bramus/specificity@2.4.3': + resolution: {integrity: sha512-0GEz8qJWokkUVYQYRukmkSHIFNoBH8QTreeHrAFbvz1XFYc3Y+puhMs8PQ5x8GNugiC2l5jvDBXKLbBIByeSBw==} + hasBin: true + '@cbor-extract/cbor-extract-darwin-arm64@2.2.2': resolution: {integrity: sha512-ZKZ/F8US7JR92J4DMct6cLW/Y66o2K576+zjlEN/MevH70bFIsB10wkZEQPLzl2oNh2SMGy55xpJ9JoBRl5DOA==} cpu: [arm64] @@ -262,6 +283,42 @@ packages: '@codemirror/view@6.43.12': resolution: {integrity: sha512-Nv0vxQ19NAqvB/c2pFzjIzFlzzJl7jmdtNkwOwGbn0Ks9mFAzibvumz7cQem5cRsFA2cEw2fg+uHZGbcHupLQQ==} + '@csstools/color-helpers@6.1.2': + resolution: {integrity: sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.4.3': + resolution: {integrity: sha512-iex20d8CHVkyvg6B7UKV7uHnI2Bqo9g+EFfT9E0y+GvTvhZ/DwONJ+9aKb1dlqm0ZiGsL5RXjp0fCoJYnkeDjA==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.2 + '@csstools/css-tokenizer': ^4.0.2 + + '@csstools/css-color-parser@4.2.6': + resolution: {integrity: sha512-iiPQ3iRWwnJkeEn6RIu6SJPr7hYrLz6XZ9s/QZl+2/LI5KQVjpl2fdmDSZKuD4xP6GMmMPgHFFXg6k1Wkz0Trg==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.2 + '@csstools/css-tokenizer': ^4.0.2 + + '@csstools/css-parser-algorithms@4.0.2': + resolution: {integrity: sha512-40cSKyMvK+tq4qz6Awrlye2WGuOKt3FwPgtGg6KTfbHOWNw+Rk1rzbAtZnZ6IBhsY491HLRnDXwoyBAijmmILA==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.2 + + '@csstools/css-syntax-patches-for-csstree@1.1.15': + resolution: {integrity: sha512-J0u7HkVl2nzSlhsiTOp4AmwcUQ3D+mGEEKfBy/7To5/y7F2OHwyLrXfrhR0SMgr4p5Lo+eaMVSeai24zUcBIxA==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.2': + resolution: {integrity: sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==} + engines: {node: '>=20.19.0'} + '@esbuild/aix-ppc64@0.21.5': resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} engines: {node: '>=12'} @@ -400,6 +457,15 @@ packages: cpu: [x64] os: [win32] + '@exodus/bytes@1.16.0': + resolution: {integrity: sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@inkandswitch/patchwork-bootloader@0.7.2': resolution: {integrity: sha512-b5ki9fodXB7uEzvBvpZ0uAkaM7CwwtAdD596XKZJY+TjaSnYsWnx0IP0ewz9rL6ASU282ItyCB8917PKxr8ckA==} peerDependencies: @@ -676,6 +742,9 @@ packages: '@types/serviceworker@0.0.153': resolution: {integrity: sha512-/cg6dFEkNchJLyRCGo4Gb8mF200qr3xskM5dCPgbtK0OzXxcFcXa6BEBdyG7JksRsTrvCR+V6aFPncoOYAwYhQ==} + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@vitest/expect@3.2.7': resolution: {integrity: sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==} @@ -739,6 +808,9 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + bidi-js@1.1.0: + resolution: {integrity: sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==} + browserslist@4.28.7: resolution: {integrity: sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} @@ -787,6 +859,10 @@ packages: crelt@1.0.7: resolution: {integrity: sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==} + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} @@ -807,6 +883,10 @@ packages: solid-js: optional: true + data-urls@8.0.0: + resolution: {integrity: sha512-JQUCCK+/CAGbGGx618PlFZMXH81Jtj7t73CHv9dd/zVWvxgGT2zLFRjnKb+Ng46GIwcy+f7f88qd4duqllK+WQ==} + engines: {node: ^22.14.0 || >=24.0.0} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -816,6 +896,9 @@ packages: supports-color: optional: true + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + deep-eql@5.0.2: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} @@ -824,6 +907,9 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + dompurify@3.4.16: + resolution: {integrity: sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==} + electron-to-chromium@1.5.402: resolution: {integrity: sha512-/oOpMaPT6Yg+6/1XQhyIPlzgj7Ye9zf+nNM2Uh6OcE2G2oNptWazFa+qB2Pdqqbsc9KnIDzgAntoYN0dbwOXwA==} @@ -835,6 +921,10 @@ packages: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} + entities@8.1.0: + resolution: {integrity: sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==} + engines: {node: '>=20.19.0'} + es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} @@ -885,9 +975,16 @@ packages: resolution: {integrity: sha512-KyrFvnl+J9US63TEzwoiJOQzZBJY7KgBushJA8X61DMbNsH+2ONkDuLDnCnwUiPTF42tLoEmrPyoqbenVA5zrg==} engines: {node: '>=18.0.0'} + html-encoding-sniffer@7.0.0: + resolution: {integrity: sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==} + engines: {node: ^22.13.0 || >=24.0.0} + html-entities@2.3.3: resolution: {integrity: sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-what@4.1.16: resolution: {integrity: sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==} engines: {node: '>=12.13'} @@ -903,6 +1000,15 @@ packages: js-tokens@9.0.1: resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + jsdom@30.1.2: + resolution: {integrity: sha512-0FFE/jE1rppmVfUrJUgxqXjcwolZYIGtAgj1pTussMhNZxIxi7W/PvfWaMNroGi2B36X1IKHbexpZ9DhkoOPiQ==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + peerDependencies: + canvas: ^3.2.3 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -916,12 +1022,24 @@ packages: loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lru-cache@11.5.3: + resolution: {integrity: sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + marked@18.1.0: + resolution: {integrity: sha512-PamYXWWWg2nboG3oX5Ffzpy3EFZROqZJK9hSlMmUnekp7TxPs5DcT95vwd46m73/exfKCRVMtgXoPMI4FEJL6w==} + engines: {node: '>= 20'} + hasBin: true + + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + merge-anything@5.1.7: resolution: {integrity: sha512-eRtbOb1N5iyH0tkQDAoQ4Ipsp/5qSR79Dzrz8hEPxRX10RWWR/iQXdoKmBSRCThY1Fh5EhISDtpSc93fpxUniQ==} engines: {node: '>=12.13'} @@ -945,6 +1063,9 @@ packages: parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -963,6 +1084,14 @@ packages: resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} engines: {node: ^10 || ^12 || >=14} + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resolve.exports@2.0.3: resolution: {integrity: sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==} engines: {node: '>=10'} @@ -972,6 +1101,10 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -1045,6 +1178,21 @@ packages: resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} engines: {node: '>=14.0.0'} + tldts-core@7.4.18: + resolution: {integrity: sha512-KI2PKvCmvQI/xD6ypPqkU0i7oxNXR/O58RLRaHR4svsgVUQ2JE/XC8BtMbG6PSehD27I8sZJRcC1ASnCy6lp3g==} + + tldts@7.4.18: + resolution: {integrity: sha512-somxA8xKAUzlAxl0TX/jMSYIbRNFIVGnI96pVGJPeMc5BsHmfVXn6mOJT6U++ELnl04eGfzpfcEuZe8WiASkXg==} + hasBin: true + + tough-cookie@6.0.2: + resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==} + engines: {node: '>=16'} + + tr46@7.0.0: + resolution: {integrity: sha512-BcDSDW+SIGWVOns1Zm6Yg91q9AEj7eaID0QDY9oIqJD1A8dscNwMcl9Sms2DeuWCNov1X0/oXanHGYQBSLwZsg==} + engines: {node: ^22.14.0 || >=24.0.0} + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -1053,6 +1201,10 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici@8.11.2: + resolution: {integrity: sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==} + engines: {node: '>=22.19.0'} + unplugin@2.3.11: resolution: {integrity: sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==} engines: {node: '>=18.12.0'} @@ -1162,10 +1314,18 @@ packages: w3c-keyname@2.2.8: resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + w3c-xmlserializer@6.0.0: + resolution: {integrity: sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + webidl-conversions@7.0.0: resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} engines: {node: '>=12'} + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + webpack-virtual-modules@0.6.2: resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} @@ -1173,6 +1333,14 @@ packages: resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} engines: {node: '>=12'} + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@17.2.0: + resolution: {integrity: sha512-IhLIHNcBrzIixC5XXsbr4DxdjajyA5vQ5RwGZMtrUp+GbtE5Nrsge9QKau7jXrC0aTXLYGV0xiW7Tb4YS2aomw==} + engines: {node: ^22.14.0 || >=24.0.0} + why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} @@ -1190,6 +1358,13 @@ packages: utf-8-validate: optional: true + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + xstate@5.32.5: resolution: {integrity: sha512-ULazi1oe6wGrXl0Frb6otSlkm5HLifbbVTkMk5kkSKqz4TkxJaVpnl6jOJwKeid3ORPxYyZQgNLUSYX9q65SIA==} @@ -1198,6 +1373,21 @@ packages: snapshots: + '@asamuzakjp/css-color@7.1.5': + dependencies: + '@csstools/css-calc': 3.4.3(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2) + '@csstools/css-color-parser': 4.2.6(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2) + '@csstools/css-parser-algorithms': 4.0.2(@csstools/css-tokenizer@4.0.2) + '@csstools/css-tokenizer': 4.0.2 + lru-cache: 11.5.3 + + '@asamuzakjp/dom-selector@9.2.4': + dependencies: + bidi-js: 1.1.0 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.3 + '@automerge/automerge-repo-network-broadcastchannel@2.6.0-subduction.48': dependencies: '@automerge/automerge-repo': 2.6.0-subduction.48 @@ -1405,6 +1595,10 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 + '@bramus/specificity@2.4.3': + dependencies: + css-tree: 3.2.1 + '@cbor-extract/cbor-extract-darwin-arm64@2.2.2': optional: true @@ -1478,6 +1672,30 @@ snapshots: style-mod: 4.1.4 w3c-keyname: 2.2.8 + '@csstools/color-helpers@6.1.2': {} + + '@csstools/css-calc@3.4.3(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.2(@csstools/css-tokenizer@4.0.2) + '@csstools/css-tokenizer': 4.0.2 + + '@csstools/css-color-parser@4.2.6(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2)': + dependencies: + '@csstools/color-helpers': 6.1.2 + '@csstools/css-calc': 3.4.3(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2) + '@csstools/css-parser-algorithms': 4.0.2(@csstools/css-tokenizer@4.0.2) + '@csstools/css-tokenizer': 4.0.2 + + '@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2)': + dependencies: + '@csstools/css-tokenizer': 4.0.2 + + '@csstools/css-syntax-patches-for-csstree@1.1.15(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.2': {} + '@esbuild/aix-ppc64@0.21.5': optional: true @@ -1547,6 +1765,10 @@ snapshots: '@esbuild/win32-x64@0.21.5': optional: true + '@exodus/bytes@1.16.0(@noble/hashes@1.8.0)': + optionalDependencies: + '@noble/hashes': 1.8.0 + '@inkandswitch/patchwork-bootloader@0.7.2(@automerge/automerge-repo@2.5.6)(@automerge/automerge@3.4.0)': dependencies: '@automerge/automerge-repo-network-broadcastchannel': 2.6.0-subduction.48 @@ -1850,6 +2072,9 @@ snapshots: '@types/serviceworker@0.0.153': {} + '@types/trusted-types@2.0.7': + optional: true + '@vitest/expect@3.2.7': dependencies: '@types/chai': 5.2.3 @@ -1918,6 +2143,10 @@ snapshots: baseline-browser-mapping@2.11.12: {} + bidi-js@1.1.0: + dependencies: + require-from-string: 2.0.2 + browserslist@4.28.7: dependencies: baseline-browser-mapping: 2.11.12 @@ -1984,6 +2213,11 @@ snapshots: crelt@1.0.7: {} + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + csstype@3.2.3: {} cute.txt@0.0.6(@automerge/automerge@3.4.0)(@codemirror/state@6.7.5)(@codemirror/view@6.43.12)(solid-js@1.9.14): @@ -1993,21 +2227,36 @@ snapshots: '@codemirror/view': 6.43.12 solid-js: 1.9.14 + data-urls@8.0.0(@noble/hashes@1.8.0): + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 17.2.0(@noble/hashes@1.8.0) + transitivePeerDependencies: + - '@noble/hashes' + debug@4.4.3: dependencies: ms: 2.1.3 + decimal.js@10.6.0: {} + deep-eql@5.0.2: {} detect-libc@2.1.2: optional: true + dompurify@3.4.16: + optionalDependencies: + '@types/trusted-types': 2.0.7 + electron-to-chromium@1.5.402: {} entities@4.5.0: {} entities@6.0.1: {} + entities@8.1.0: {} + es-module-lexer@1.7.0: {} es-module-lexer@2.3.1: {} @@ -2065,8 +2314,16 @@ snapshots: webidl-conversions: 7.0.0 whatwg-mimetype: 3.0.0 + html-encoding-sniffer@7.0.0(@noble/hashes@1.8.0): + dependencies: + '@exodus/bytes': 1.16.0(@noble/hashes@1.8.0) + transitivePeerDependencies: + - '@noble/hashes' + html-entities@2.3.3: {} + is-potential-custom-element-name@1.0.1: {} + is-what@4.1.16: {} isomorphic-ws@5.0.0(ws@8.21.2): @@ -2077,12 +2334,39 @@ snapshots: js-tokens@9.0.1: {} + jsdom@30.1.2(@noble/hashes@1.8.0): + dependencies: + '@asamuzakjp/css-color': 7.1.5 + '@asamuzakjp/dom-selector': 9.2.4 + '@bramus/specificity': 2.4.3 + '@csstools/css-syntax-patches-for-csstree': 1.1.15(css-tree@3.2.1) + '@exodus/bytes': 1.16.0(@noble/hashes@1.8.0) + css-tree: 3.2.1 + data-urls: 8.0.0(@noble/hashes@1.8.0) + decimal.js: 10.6.0 + html-encoding-sniffer: 7.0.0(@noble/hashes@1.8.0) + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.3 + parse5: 8.0.1 + saxes: 6.0.0 + tough-cookie: 6.0.2 + undici: 8.11.2 + w3c-xmlserializer: 6.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 17.2.0(@noble/hashes@1.8.0) + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + jsesc@3.1.0: {} json5@2.2.3: {} loupe@3.2.1: {} + lru-cache@11.5.3: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -2091,6 +2375,10 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + marked@18.1.0: {} + + mdn-data@2.27.1: {} + merge-anything@5.1.7: dependencies: is-what: 4.1.16 @@ -2110,6 +2398,10 @@ snapshots: dependencies: entities: 6.0.1 + parse5@8.0.1: + dependencies: + entities: 8.1.0 + pathe@2.0.3: {} pathval@2.0.1: {} @@ -2124,6 +2416,10 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + punycode@2.3.1: {} + + require-from-string@2.0.2: {} + resolve.exports@2.0.3: {} rollup@4.62.4: @@ -2158,6 +2454,10 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.62.4 fsevents: 2.3.3 + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + semver@6.3.1: {} seroval-plugins@1.5.6(seroval@1.5.6): @@ -2221,10 +2521,26 @@ snapshots: tinyspy@4.0.4: {} + tldts-core@7.4.18: {} + + tldts@7.4.18: + dependencies: + tldts-core: 7.4.18 + + tough-cookie@6.0.2: + dependencies: + tldts: 7.4.18 + + tr46@7.0.0: + dependencies: + punycode: 2.3.1 + typescript@5.9.3: {} undici-types@6.21.0: {} + undici@8.11.2: {} + unplugin@2.3.11: dependencies: '@jridgewell/remapping': 2.3.5 @@ -2290,7 +2606,7 @@ snapshots: optionalDependencies: vite: 5.4.21(@types/node@20.19.43) - vitest@3.2.7(@types/debug@4.1.13)(@types/node@20.19.43)(happy-dom@15.11.7): + vitest@3.2.7(@types/debug@4.1.13)(@types/node@20.19.43)(happy-dom@15.11.7)(jsdom@30.1.2(@noble/hashes@1.8.0)): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.7 @@ -2319,6 +2635,7 @@ snapshots: '@types/debug': 4.1.13 '@types/node': 20.19.43 happy-dom: 15.11.7 + jsdom: 30.1.2(@noble/hashes@1.8.0) transitivePeerDependencies: - less - lightningcss @@ -2332,12 +2649,28 @@ snapshots: w3c-keyname@2.2.8: {} + w3c-xmlserializer@6.0.0: + dependencies: + xml-name-validator: 5.0.0 + webidl-conversions@7.0.0: {} + webidl-conversions@8.0.1: {} + webpack-virtual-modules@0.6.2: {} whatwg-mimetype@3.0.0: {} + whatwg-mimetype@5.0.0: {} + + whatwg-url@17.2.0(@noble/hashes@1.8.0): + dependencies: + '@exodus/bytes': 1.16.0(@noble/hashes@1.8.0) + tr46: 7.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 @@ -2345,6 +2678,10 @@ snapshots: ws@8.21.2: {} + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + xstate@5.32.5: {} yallist@3.1.1: {} diff --git a/chat/src/components/ChatRoot.tsx b/chat/src/components/ChatRoot.tsx index 08efaad0..5138df81 100644 --- a/chat/src/components/ChatRoot.tsx +++ b/chat/src/components/ChatRoot.tsx @@ -8,6 +8,15 @@ import {featurePlugins} from "../features" import {createLoadedPlugins} from "../lib/slots" import {expandSelector, docSelector} from "../lib/plugin-catalog" import type {PluginSelector} from "../lib/registry" +import { + listSkills, + resolveActiveSkills, + activateSkill, + skillsPromptSection, + skillToolSchemas, + runSkillTool, + type ActiveSkill, +} from "../lib/llm-skills" import {ChatProvider, useChat} from "../context/ChatContext" import {IdentityProvider, useIdentity} from "../context/IdentityContext" import {ThemeProvider} from "../context/ThemeContext" @@ -168,7 +177,8 @@ export function ChatRoot(props: { // Computer/LLM state const [computerActive, setComputerActive] = createSignal(false) - const [computerAutoMode, setComputerAutoMode] = createSignal(false) + // Lives on the doc so every tab (host and watchdogs) agrees on it. + const computerAutoMode = () => !(props.handle.doc() as any)?.computerShy const [llmStatus, setLlmStatus] = createSignal("") // Human-readable label of the model the computer is currently running, shown // in the computer's username (e.g. "computer (OpenRouter Claude Opus 4)"). @@ -361,6 +371,7 @@ arg: value - edit_tool {toolId|url, code} — replace a tool's source and reload - inspect_iframe {url} — a pinned tool's DOM + console errors - eval_in_iframe {url, code} — run JS in a pinned tool, get the result +- load_skill {id} — activate an installed skill (see the Skills index) and get its instructions Rules: ALWAYS read_doc before edit_doc/splice_doc, and re-read the returned value after (peers may have changed it). NEVER change a doc's \`@patchwork.type\`, or a tool's datatype/tool \`id\`/\`supportedDatatypes\` (breaks existing docs). To ask the user something, reply in plain text with NO tool call — tool results are not user answers. @@ -401,7 +412,8 @@ Derive fill/line/fonts from \`--editor-*\`; accents from \`--studio-*\`. Never h - Navigate: \`openDocument(element, url, toolId)\` from patchwork-elements. - Rich output: \`\`\`file blocks create+embed files; \`\`\`embed blocks embed existing docs. -Keep responses concise. When you build a tool, briefly say what it does.` +Keep responses concise. When you build a tool, briefly say what it does. +Your replies render as GitHub-flavoured Markdown: use **bold**, lists, headings, tables and fenced code with a language where they help.` // The FULL prompt — detailed, with worked code examples — for capable cloud // models (OpenRouter / Ollama). Same intro + tool-calling section as the @@ -429,6 +441,7 @@ arg: value - edit_tool {toolId|url, code} — replace a tool's source and reload - inspect_iframe {url} — a pinned tool's DOM + console errors - eval_in_iframe {url, code} — run JS in a pinned tool, get the result +- load_skill {id} — activate an installed skill (see the Skills index) and get its instructions Rules: ALWAYS read_doc before edit_doc/splice_doc, and re-read the returned value after (peers may have changed it). NEVER change a doc's \`@patchwork.type\`, or a tool's datatype/tool \`id\`/\`supportedDatatypes\` (breaks existing docs). To ask the user something, reply in plain text with NO tool call — tool results are not user answers. @@ -512,7 +525,8 @@ When you update files in a tool folder (e.g. updating JS source via edit_doc or ## Rich Messages You can include \`\`\`file blocks to create and embed files, \`\`\`embed blocks for existing docs. -Keep responses concise. When you create a tool, explain briefly what it does.` +Keep responses concise. When you create a tool, explain briefly what it does. +Your replies render as GitHub-flavoured Markdown: use **bold**, lists, headings, tables and fenced code with a language where they help.` // Pick the prompt for the active model: local/in-browser models (small, // limited context) get the compact prompt; capable cloud providers get the @@ -573,6 +587,21 @@ Keep responses concise. When you create a tool, explain briefly what it does.` required: ["name", "description", "code"], }, } + // Mid-run skill activation: the skills index in the system prompt lists + // installed-but-inactive skills; this lets the model pull one in itself + // instead of stalling to ask the user for `/plugin load`. + const LOAD_SKILL_TOOL = { + name: "load_skill", + description: + "Activate an installed skill by id for THIS run (ids are in the Skills index of your instructions). Returns the skill's full instructions; any extra tools it contributes become available on your next step. Use it BEFORE working on a document type whose skill is not active — never guess a skill-covered document's schema.", + parameters: { + type: "object", + properties: { + id: {type: "string", description: "skill id from the Skills index"}, + }, + required: ["id"], + }, + } const COMPUTER_TOOLS: {name: string; description: string; parameters: any}[] = [ {name: "read_doc", description: "Read an Automerge document's full contents.", parameters: {type: "object", properties: {url: {type: "string", description: "automerge: URL"}}, required: ["url"]}}, {name: "edit_doc", description: "Set a field on a document (string fields diff collaboratively). Returns the field's new value.", parameters: {type: "object", properties: {url: {type: "string"}, field: {type: "string"}, value: {description: "new value (JSON)"}}, required: ["url", "field", "value"]}}, @@ -618,6 +647,7 @@ Every turn, a [Context] block gives you the focused document: its \`url\`, its c • heads — OPTIONAL. The heads array from read_doc. When given, the edit is applied as a back-dated change (changeAt) relative to that version. Omit for a normal "edit current state" change. • url — OPTIONAL. Edit a different document than the focused one. Returns the affected container's new value so you can verify. +- load_skill {id} — activate an installed skill by id (see the Skills index in these instructions) and get its full instructions back. Use it BEFORE working on a document type whose skill is installed but not active. - ask_user {question, options?} — ask the user something and PAUSE. Posts your question (with optional clickable choices) and ends your turn; their reply comes back as a new message. Use this instead of guessing when you need a decision or missing detail. - inspect_dom {selector?} — (usually disabled) return the live DOM HTML of the running tool/page, optionally narrowed to a CSS selector. Use to see how the focused doc is actually rendered. - eval_js {code} — (usually disabled) evaluate JavaScript in the page and return the result. Powerful and unsandboxed; only when explicitly needed. @@ -635,7 +665,8 @@ Never overwrite an entire long field with a key-assign (range:"content") just to - For text, reach for replace_text first, find_text second, raw automerge_op splices last. Edit the smallest correct span; don't overwrite whole fields. - Never change \`@patchwork.type\`. Don't invent fields the tool won't understand — match the document's existing shape. - To ASK the user something, use ask_user (or just reply in plain text with no tool call). Tool results are never the user's answer. -- Keep replies concise. After editing, say briefly what you changed.` +- Keep replies concise. After editing, say briefly what you changed. +- Your replies render as GitHub-flavoured Markdown: use **bold**, lists, headings, tables and fenced code with a language where they help.` // Default-OFF tools in context mode: DOM inspection + arbitrary JS eval are // powerful/unsandboxed, so they're opt-in (enable per-doc in the picker). @@ -671,9 +702,21 @@ Never overwrite an entire long field with a key-assign (range:"content") just to })) } - // The tool set for whichever mode we're in, plus any self-defined tools. + // The llm:skill packs active for the current computer response. Drives the + // "## Skills" prompt section, extra skill tools, and their dispatch. + const [skillsForRun, setSkillsForRun] = createSignal([]) + + // The tool set for whichever mode we're in, plus any self-defined tools, + // plus tools contributed by the run's active skills (built-ins win on a + // name conflict). function activeTools() { - return [...(isContext() ? CONTEXT_TOOLS : COMPUTER_TOOLS), ...customTools()] + const base = [ + ...(isContext() ? CONTEXT_TOOLS : COMPUTER_TOOLS), + LOAD_SKILL_TOOL, + ...customTools(), + ] + const taken = new Set(base.map((t) => t.name)) + return [...base, ...skillToolSchemas(skillsForRun(), taken)] } // Render a structured tool call as the text shown in its card — mirrors the old @@ -1108,6 +1151,53 @@ Never overwrite an entire long field with a key-assign (range:"content") just to return out } + // Activate a skill mid-run (the load_skill tool): its instructions come back + // as the tool result, and its extra tools join activeTools() for the next + // round via skillsForRun. The system prompt already sent still lists the + // skill as inactive — the instructions arriving as tool output supersede it. + async function activateSkillForRun(id: string): Promise { + if (!id) return "Error: load_skill needs a skill id — see the Skills index in your instructions." + if (skillsForRun().some((s) => s.id === id)) { + return `Skill "${id}" is already active; its instructions are in your system prompt.` + } + const skill = await activateSkill(id) + if (!skill) { + const known = listSkills().map((s) => s.id).join(", ") + return `Error: no installed skill "${id}". Installed: ${known || "none"}.` + } + setSkillsForRun([...skillsForRun(), skill]) + const tools = (skill.module.tools ?? []).map((t) => t.name) + return ( + `Skill "${id}" is now active for this run` + + (tools.length ? ` (extra tools usable from your next step: ${tools.join(", ")})` : "") + + `. Its instructions:\n\n${skill.module.instructions.trim()}` + ) + } + + // read_doc auto-activation: reading a document whose datatype matches an + // installed-but-inactive skill pulls that skill in and delivers its + // instructions inline with the read result, so the model gets the domain + // knowledge exactly when it first looks at such a document. + async function autoActivateSkillsFor(doc: any): Promise { + const docType = doc?.["@patchwork"]?.type + if (typeof docType !== "string" || !docType) return "" + const notes: string[] = [] + for (const desc of listSkills()) { + if (!Array.isArray(desc.datatypes) || !desc.datatypes.includes(docType)) continue + if (skillsForRun().some((s) => s.id === desc.id)) continue + const skill = await activateSkill(desc.id) + if (!skill) continue + setSkillsForRun([...skillsForRun(), skill]) + const tools = (skill.module.tools ?? []).map((t) => t.name) + notes.push( + `[Skill "${desc.id}" auto-activated: this document's type (${docType}) matches it` + + (tools.length ? `; extra tools usable from your next step: ${tools.join(", ")}` : "") + + `. Follow its instructions:]\n\n${skill.module.instructions.trim()}` + ) + } + return notes.length ? "\n\n" + notes.join("\n\n") : "" + } + // Execute one tool call by name with structured args (from the lib's native // tool_calls or parsed JSON). Args may already be typed (objects/ // numbers) or strings, so each branch is tolerant of both. Returns a result @@ -1118,11 +1208,14 @@ Never overwrite an entire long field with a key-assign (range:"content") just to // In context mode, doc-editing tools default to the focused document. const focusedUrl = () => props.targetDocUrl?.() try { - if (toolName === "read_doc") { + if (toolName === "load_skill") { + return await activateSkillForRun(String(args.id || "").trim()) + } else if (toolName === "read_doc") { const url = args.url || (isContext() ? focusedUrl() : undefined) if (!url) return "Error: no url and no focused document." const h = await repo.find(url) const doc = h.doc() + const skillNotes = await autoActivateSkillsFor(doc) if (isContext()) { // Context mode also returns heads so a follow-up automerge_op can // back-date its change (changeAt). handle.heads() is the UrlHeads @@ -1131,9 +1224,9 @@ Never overwrite an entire long field with a key-assign (range:"content") just to try { heads = h.heads() } catch {} - return JSON.stringify({url: h.url, heads, doc}, null, 2) + return JSON.stringify({url: h.url, heads, doc}, null, 2) + skillNotes } - return JSON.stringify(doc, null, 2) || "null" + return (JSON.stringify(doc, null, 2) || "null") + skillNotes } else if (toolName === "automerge_op") { const url = args.url || focusedUrl() if (!url) return "Error: no url and no focused document." @@ -1613,6 +1706,17 @@ Never overwrite an entire long field with a key-assign (range:"content") just to "`. It becomes available on your NEXT run (not this turn)." ) } + // A tool contributed by one of the run's active llm:skills — dispatch + // to the skill's own runTool. + const skillResult = await runSkillTool(skillsForRun(), toolName, args, { + repo, + handle: props.handle, + element: props.element, + focusedUrl: focusedUrl(), + applyAutomerge, + }) + if (skillResult !== null) return skillResult + // A tool the computer defined for itself via define_tool — run its JS. const custom = ( (props.handle.doc() as any)?.computerCustomTools || [] @@ -2022,7 +2126,6 @@ Never overwrite an entire long field with a key-assign (range:"content") just to return } setComputerActive(false) - setComputerAutoMode(false) if (heartbeatInterval) { clearInterval(heartbeatInterval) heartbeatInterval = null @@ -2038,20 +2141,27 @@ Never overwrite an entire long field with a key-assign (range:"content") just to delete d.computerHeartbeat delete d.computerOwner delete d.computerModel + delete d.computerShy }) sendComputerMessage("computer has left the chat.") return } - if (sub === "nosey" || sub === "auto") { + if (sub === "shy" || sub === "nosey") { + const shy = sub === "shy" if (!computerActive()) { sendComputerMessage( "computer is not active. Use /computer invite first." ) return } - setComputerAutoMode(!computerAutoMode()) + props.handle.change((d: any) => { + if (shy) d.computerShy = true + else delete d.computerShy + }) sendComputerMessage( - "Auto-respond mode: " + (computerAutoMode() ? "ON" : "OFF") + shy + ? "ok, i'll only respond when @mentioned or replied to." + : "ok, i'll respond to every message." ) return } @@ -2122,11 +2232,11 @@ Never overwrite an entire long field with a key-assign (range:"content") just to setModelLabel(model) sendComputerMessage( [ - "hello! i'm computer, a computer program. mention @computer or reply to my messages and i'll respond.", + "hello! i'm computer, a computer program. i'll respond to every message.", "", "• currently running: " + model, "• /model — pick a different model or provider", - "• /computer nosey — make me respond to everything", + "• /computer shy — only respond when @mentioned or replied to; /computer nosey to respond to everything again", "• /computer owner — see who's hosting me; /computer own to take over", "• /computer kick — send me away", ].join("\n") @@ -2420,6 +2530,31 @@ Never overwrite an entire long field with a key-assign (range:"content") just to return results } + // Which llm:skill packs apply to a run: skills whose datatypes match the + // focused document, skills enabled on the chat by id, and @momputer forcing + // its persona skill. Resolved before the prompt is built so their + // instructions and tools are in place for every round. + async function skillsForMessage(isMomputer: boolean): Promise { + try { + let focusedType: string | undefined + const turl = props.targetDocUrl?.() + if (turl) { + try { + const repo = (props.element as any).repo + focusedType = ((await repo.find(turl)).doc() as any)?.["@patchwork"]?.type + } catch {} + } + return await resolveActiveSkills({ + focusedType, + enabledIds: activeFeatures(), + forcedIds: isMomputer ? ["momputer"] : undefined, + }) + } catch (e) { + console.warn("[Computer] skill resolution failed:", e) + return [] + } + } + async function respondToUser(userMsg: any) { const repo = (props.element as any).repo if (!repo || computerResponding) return @@ -2447,11 +2582,13 @@ Never overwrite an entire long field with a key-assign (range:"content") just to let tokenThrottleTimer: any = null try { - const context = await assembleContext() - resetInactivityTimer() const isMomputer = (userMsg.text || "") .toLowerCase() .includes("@momputer") + setSkillsForRun(await skillsForMessage(isMomputer)) + + const context = await assembleContext() + resetInactivityTimer() // Generate a tool name for this response — the LLM uses it if it builds a tool const suggestedToolName = randomToolName() // The built-in COMPUTER_SYSTEM_PROMPT is the *default* — but if the user @@ -2466,10 +2603,10 @@ Never overwrite an entire long field with a key-assign (range:"content") just to '## Your Tool ID\nIf you build a patchwork tool in this response, use `"' + suggestedToolName + '"` as the id for both the datatype and tool plugins, and in supportedDatatypes.' - if (isMomputer) { - systemPrompt += - '\n\n## Special Mode: Momputer\nThe user addressed you as @momputer. Be warm, nurturing, and motherly in your response. Use gentle encouragement, express care and concern, and be supportive like a loving mom would be. You can use pet names like "sweetie", "honey", "dear", etc. Still be helpful and knowledgeable, but with a cozy maternal energy.' - } + // Active skills' full instructions plus a one-line index of the + // inactive ones. (The momputer persona rides this too.) + const skillsSection = skillsPromptSection(skillsForRun(), listSkills()) + if (skillsSection) systemPrompt += "\n\n" + skillsSection const messages = [...context, {role: "user", content: userMsg.text}] // Create streaming message — use `let` so we can reassign @@ -2522,7 +2659,7 @@ Never overwrite an entire long field with a key-assign (range:"content") just to setLlmStatus(status.replace(/think(ing)?/gi, "computing")) } - const MAX_TOOL_ROUNDS = 5 + const MAX_TOOL_ROUNDS = 25 let madeChanges = false let completedResponse = false for (let round = 0; round < MAX_TOOL_ROUNDS; round++) { diff --git a/chat/src/components/MessageBody.tsx b/chat/src/components/MessageBody.tsx index a9bc57ad..2866c39c 100644 --- a/chat/src/components/MessageBody.tsx +++ b/chat/src/components/MessageBody.tsx @@ -7,6 +7,7 @@ import {highlightCode} from "../lib/highlighter" import {ensureFontLoaded} from "../lib/blob-cache" import {resolveNamedColor} from "../lib/named-colors" import {generateId} from "../lib/helpers" +import {markdownChunks} from "../lib/markdown" import {useChat} from "../context/ChatContext" import {useIdentity} from "../context/IdentityContext" import {usePresence} from "../context/PresenceContext" @@ -118,6 +119,58 @@ function MessageNodes(props: {nodes: any[]}) { return {(node) => } } +const THINK = /([\s\S]*?)(?:<\/think>|$)/g + +// The computer's replies render as markdown, with sections pulled out +// first (when the schema allows them) so they keep their collapsible block. +function ComputerText(props: {text: string; allowThink: boolean}) { + const parts = createMemo(() => (props.allowThink ? splitThink(props.text) : [{think: false, text: props.text}])) + return ( + + {(part) => ( + }> + + + )} + + ) +} + +function MarkdownText(props: {text: string}) { + const chunks = createMemo(() => markdownChunks(props.text)) + return ( + + {(chunk) => { + const code = () => { + const c = chunk() + return c.kind === "code" ? c : undefined + } + const html = () => { + const c = chunk() + return c.kind === "html" ? c.html : "" + } + return ( + }> + {(code) => } + + ) + }} + + ) +} + +function splitThink(text: string): {think: boolean; text: string}[] { + const parts: {think: boolean; text: string}[] = [] + let last = 0 + for (const m of text.matchAll(THINK)) { + if (m.index > last) parts.push({think: false, text: text.slice(last, m.index)}) + parts.push({think: true, text: m[1].trim()}) + last = m.index + m[0].length + } + if (last < text.length) parts.push({think: false, text: text.slice(last)}) + return parts +} + export function MessageBody(props: { msg: ChatMessage schema: CuteSchema @@ -138,9 +191,16 @@ export function MessageBody(props: { }) const segments = createMemo(() => - props.msg.text ? parseMarkup(props.msg.text, props.schema) : [] + props.msg.text && !props.msg.isComputer ? parseMarkup(props.msg.text, props.schema) : [] ) + const body = () => + props.msg.isComputer ? ( + + ) : ( + + ) + const emojiOnly = createMemo(() => props.msg.text ? isEmojiOnly(props.msg.text) : false ) @@ -159,6 +219,7 @@ export function MessageBody(props: { classList={{ "emoji-only": emojiOnly(), streaming: props.msg.streaming, + markdown: !!props.msg.isComputer, }} style={{ ...(props.msg.font ? {"font-family": props.msg.font} : {}), @@ -167,10 +228,10 @@ export function MessageBody(props: { on:click={onSpoilerClick} > - + {body()} - + {body()} diff --git a/chat/src/context-tool.tsx b/chat/src/context-tool.tsx index 5251afb4..9ece5aa7 100644 --- a/chat/src/context-tool.tsx +++ b/chat/src/context-tool.tsx @@ -68,8 +68,7 @@ async function ensureChitchat( plugins: defaultPlugins.slice(), "@patchwork": {type: "chat"}, // Auto-invite the computer (ChatRoot's onMount claims the host when - // hasComputer is set) — but it stays off nosey, so it only replies when - // @mentioned or replied to. + // hasComputer is set). hasComputer: true, } as any) // Resolve through find so a draft forks the new doc into this draft's clones. diff --git a/chat/src/feature-descriptions.ts b/chat/src/feature-descriptions.ts new file mode 100644 index 00000000..6bc9c60f --- /dev/null +++ b/chat/src/feature-descriptions.ts @@ -0,0 +1,22 @@ +import type {FeaturePlugin} from "./features" + +// The built-in feature declarations, as metadata only. This module is part of +// index.ts's (worker) static graph, so keep it free of runtime imports: the host +// reads `plugins` in a module worker, which has no importmap, so a static path to +// `solid-js` there fails the whole package. Slots stay behind `load()`. +export const featureMetadata: Omit[] = [ + {type: "chat:feature", id: "presence", name: "Presence", tier: "core"}, + {type: "chat:feature", id: "typing", name: "Typing indicator", tier: "core"}, + {type: "chat:feature", id: "computer", name: "Computer (AI)", tier: "full"}, + {type: "chat:feature", id: "selection-mention", name: "Selection mention", tier: "core"}, +] + +// Serializable registry descriptions (the same pattern as slash/messageaction/ +// emojipack descriptions): function-valued `slots` can't be DataClone'd raw. +export const featureDescriptions = featureMetadata.map((meta) => ({ + ...meta, + async load() { + const {featurePlugins} = await import("./features") + return {slots: featurePlugins.find((p) => p.id === meta.id)?.slots} + }, +})) diff --git a/chat/src/features.ts b/chat/src/features.ts index a52ae758..54a37243 100644 --- a/chat/src/features.ts +++ b/chat/src/features.ts @@ -1,5 +1,6 @@ import type {PluginSelector} from "./lib/registry" import {selectionFeature} from "./features/selection-mention" +import {featureMetadata} from "./feature-descriptions" // Features are NOT a hardcoded struct on the component. Each big feature is a // `chat:feature` plugin declaration (host-registrable, like the other four types); @@ -26,25 +27,19 @@ export interface FeaturePlugin { slots?: Record any> } -// The built-in feature declarations (also the registry fallback). Message send, -// contact avatars + names, inline `code`/`*bold*`/`_italic_`/fences, image send and -// patchwork-tool embedding are ALWAYS on (not gated) — they're the chat itself. -// Replies ride the (core-tier) `reply` message-action, so no flag here. +// The built-in feature declarations with their slots (also the registry fallback). +// Message send, contact avatars + names, inline `code`/`*bold*`/`_italic_`/fences, +// image send and patchwork-tool embedding are ALWAYS on (not gated) — they're the +// chat itself. Replies ride the (core-tier) `reply` message-action, so no flag here. // The base `chat` tool owns only the core features plus the computer. Everything // else (reactions, sidebar, voice, gifSelfie, emoticons, call, notifications) is -// contributed by the `chitter` bundle via the registry. -export const featurePlugins: FeaturePlugin[] = [ - {type: "chat:feature", id: "presence", name: "Presence", tier: "core"}, - {type: "chat:feature", id: "typing", name: "Typing indicator", tier: "core"}, - {type: "chat:feature", id: "computer", name: "Computer (AI)", tier: "full"}, - selectionFeature, -] +// contributed by the `chitter` bundle via the registry. The metadata lives in +// feature-descriptions.ts, which index.ts registers. +const featureSlots: Record = { + [selectionFeature.id]: selectionFeature.slots, +} -// Serializable registry descriptions: metadata only, with `slots` deferred behind -// `async load()` (the same pattern as slash/messageaction/emojipack descriptions). -// Raw `...featurePlugins` can only be registered while they're pure data; the moment -// a feature carries `slots`, register the descriptions instead. -export const featureDescriptions = featurePlugins.map((p) => { - const {slots, ...meta} = p - return {...meta, async load() { return {slots} }} -}) +export const featurePlugins: FeaturePlugin[] = featureMetadata.map((meta) => ({ + ...meta, + ...(featureSlots[meta.id] && {slots: featureSlots[meta.id]}), +})) diff --git a/chat/src/index.ts b/chat/src/index.ts index 07d33939..d71b1069 100644 --- a/chat/src/index.ts +++ b/chat/src/index.ts @@ -1,8 +1,9 @@ -import {featureDescriptions} from "./features" +import {featureDescriptions} from "./feature-descriptions" import {syntaxPluginDescriptions} from "./lib/syntax" import {slashPluginDescriptions} from "./lib/slash-plugins" import {messageActionDescriptions} from "./lib/message-actions" import {emojiPackDescriptions} from "./lib/emoji-packs" +import {skillDescriptions} from "./skills" export const plugins = [ { @@ -73,4 +74,7 @@ export const plugins = [ ...slashPluginDescriptions, ...messageActionDescriptions, ...emojiPackDescriptions, + // `llm:skill` instruction packs for the computer. Any bundle can register + // more; these are the ones chat ships with. + ...skillDescriptions, ] diff --git a/chat/src/lib/llm-skills.ts b/chat/src/lib/llm-skills.ts new file mode 100644 index 00000000..4b858298 --- /dev/null +++ b/chat/src/lib/llm-skills.ts @@ -0,0 +1,216 @@ +// `llm:skill` — a domain instruction pack for the computer, registrable by any +// bundle through the host plugin registry (late-bound: a chat without a skill +// installed simply doesn't list it). Chat ships its own skills the same way +// (see ../skills). A skill contributes: +// - instructions: markdown appended to the system prompt while ACTIVE +// - tools/runTool: optional extra LLM tools the skill implements itself +// +// A skill is active for a run when: +// - the focused document's `@patchwork.type` is in its `datatypes`, or +// - its id is enabled for the chat (`doc.plugins`, via /plugin load ), or +// - the run forces it (e.g. @momputer forces the momputer skill), or +// - the model activates it mid-run (load_skill, or read_doc on a document +// whose type matches). +// +// Skills are deliberately NOT part of the tier system: the "all" selector never +// auto-enables them (a chat with selector "all" must not inhale every +// registered skill into its prompt). They appear in the /plugin panel through +// pluginCatalog(), which lists them separately from BUILTIN_PLUGIN_TYPES. +// +// Registration shape (per the registry rules, functions live behind load()): +// { type: "llm:skill", id, name, description, datatypes?, async load() { +// return { instructions, tools?, runTool? } } } + +import {listPlugins, loadPlugin} from "./registry" + +export type LlmSkillTool = { + name: string + description: string + parameters?: any +} + +export type LlmSkillToolCtx = { + repo: any + handle: any + element: HTMLElement + focusedUrl: string | undefined + applyAutomerge: (doc: any, path: any[], range: any, value: any) => void +} + +export type LlmSkillModule = { + /** Markdown appended to the system prompt while the skill is active. */ + instructions: string + /** Optional extra tool schemas offered to the model while active. */ + tools?: LlmSkillTool[] + /** Implementation for this skill's tools. */ + runTool?: ( + name: string, + args: any, + ctx: LlmSkillToolCtx + ) => unknown | Promise +} + +export type LlmSkillDescription = { + type: "llm:skill" + id: string + name: string + /** One-liner ALWAYS shown to the model (the index) — write it like a + * trigger condition ("applies when…"), not marketing copy. */ + description: string + /** Auto-activate when the focused doc's `@patchwork.type` matches. */ + datatypes?: string[] +} + +export type ActiveSkill = { + id: string + name: string + description: string + module: LlmSkillModule +} + +/** Every skill registered with the host. */ +export function listSkills(): LlmSkillDescription[] { + return listPlugins("llm:skill").filter( + (s: any): s is LlmSkillDescription => + !!s && typeof s.id === "string" && typeof s.description === "string" + ) +} + +/** The skills active for a run: datatype-matched against the focused doc, + * enabled by id on the chat, or forced by the caller. Loads each active + * skill's module (cached); skills that fail to load are skipped. */ +export async function resolveActiveSkills(opts: { + focusedType?: string | null + enabledIds: Set + forcedIds?: string[] +}): Promise { + const out: ActiveSkill[] = [] + for (const desc of listSkills()) { + const byType = + !!opts.focusedType && + Array.isArray(desc.datatypes) && + desc.datatypes.includes(opts.focusedType) + const byId = opts.enabledIds.has(desc.id) + const forced = opts.forcedIds?.includes(desc.id) ?? false + if (!byType && !byId && !forced) continue + const skill = await toActiveSkill(desc) + if (skill) out.push(skill) + } + return out +} + +/** Load one skill by id, or null when the id is unknown or its module fails + * to load. For MID-RUN activation — the load_skill tool and read_doc datatype + * auto-activation — where the caller appends the result to its active set and + * feeds the instructions back to the model as tool output (the already-sent + * system prompt is not rebuilt). */ +export async function activateSkill(id: string): Promise { + const desc = listSkills().find((s) => s.id === id) + return desc ? toActiveSkill(desc) : null +} + +async function toActiveSkill( + desc: LlmSkillDescription +): Promise { + const module = await loadSkillModule(desc.id) + if (!module) return null + return { + id: desc.id, + name: desc.name || desc.id, + description: desc.description, + module, + } +} + +// Loaded modules by skill id. A failed load is NOT cached, so a bundle that +// registers late (or a transient import failure) gets retried next run. +const moduleCache = new Map() + +async function loadSkillModule(id: string): Promise { + const cached = moduleCache.get(id) + if (cached) return cached + const module = (await loadPlugin("llm:skill", id))?.module + if (!module || typeof module.instructions !== "string") { + console.warn("[llm-skills] failed to load skill:", id) + return null + } + moduleCache.set(id, module) + return module +} + +/** The "## Skills" system-prompt section: full instructions for active skills, + * plus a one-line index of the inactive ones (so the model knows what exists + * and can activate it). Empty string when there is nothing to say. */ +export function skillsPromptSection( + active: ActiveSkill[], + all: LlmSkillDescription[] +): string { + const activeIds = new Set(active.map((s) => s.id)) + const inactive = all.filter((s) => !activeIds.has(s.id)) + if (active.length === 0 && inactive.length === 0) return "" + const parts: string[] = ["## Skills"] + if (active.length > 0) { + parts.push( + "Instruction packs active for this turn. Follow each skill's instructions when working in its domain." + ) + for (const s of active) { + parts.push(`### Skill: ${s.name}\n${s.module.instructions.trim()}`) + } + } + if (inactive.length > 0) { + parts.push( + "Installed but NOT active. A skill auto-activates when you read_doc a document matching its datatypes. To work on a matching document you have NOT read (or before creating one), activate the skill YOURSELF first with the load_skill tool — do not guess a skill's document schema, and do not ask the user to activate it for you:\n" + + inactive.map((s) => `- ${s.id}: ${s.description}`).join("\n") + ) + } + return parts.join("\n\n") +} + +/** The tool schemas contributed by the active skills, deduped against the + * given already-taken names (built-ins and custom tools win). */ +export function skillToolSchemas( + active: ActiveSkill[], + takenNames: Set +): LlmSkillTool[] { + const out: LlmSkillTool[] = [] + for (const s of active) { + for (const t of s.module.tools ?? []) { + if (!t?.name || takenNames.has(t.name)) continue + takenNames.add(t.name) + out.push({ + name: t.name, + description: t.description || `(${s.name} skill tool)`, + parameters: + t.parameters && typeof t.parameters === "object" + ? t.parameters + : {type: "object", properties: {}}, + }) + } + } + return out +} + +/** Dispatch a tool call to the active skill that owns it. Returns null when no + * active skill declares the tool (callers then fall through to other + * dispatchers), else the stringified result. */ +export async function runSkillTool( + active: ActiveSkill[], + name: string, + args: any, + ctx: LlmSkillToolCtx +): Promise { + for (const s of active) { + if (!(s.module.tools ?? []).some((t) => t?.name === name)) continue + if (typeof s.module.runTool !== "function") continue + try { + const result = await s.module.runTool(name, args, ctx) + if (result === undefined) return "(tool ran; no return value)" + return typeof result === "string" + ? result + : JSON.stringify(result, null, 2) + } catch (e: any) { + return `skill tool error (${s.id}): ` + (e?.message || String(e)) + } + } + return null +} diff --git a/chat/src/lib/markdown.test.ts b/chat/src/lib/markdown.test.ts new file mode 100644 index 00000000..e2201a83 --- /dev/null +++ b/chat/src/lib/markdown.test.ts @@ -0,0 +1,35 @@ +// @vitest-environment jsdom +// happy-dom's parser makes DOMPurify drop the first top-level element. +import {describe, expect, it} from "vitest" +import {markdownChunks} from "./markdown" + +describe("markdownChunks", () => { + it("renders prose as HTML and keeps fenced code separate", () => { + const chunks = markdownChunks("# Plan\n\n- **one**\n- two\n\n```ts\nconst x = 1\n```\n\nDone.") + expect(chunks.map((c) => c.kind)).toEqual(["html", "code", "html"]) + expect(chunks[0]).toMatchObject({kind: "html"}) + const html = (chunks[0] as {html: string}).html + expect(html).toContain("

Plan

") + expect(html).toContain("one") + expect(chunks[1]).toEqual({kind: "code", lang: "ts", code: "const x = 1"}) + }) + + it("treats an unclosed fence as code that's still streaming", () => { + const chunks = markdownChunks("Here:\n\n```js\nlet a =") + expect(chunks.at(-1)).toEqual({kind: "code", lang: "js", code: "let a ="}) + }) + + it("renders tables", () => { + const [chunk] = markdownChunks("| a | b |\n|---|---|\n| 1 | 2 |") + expect((chunk as {html: string}).html).toContain("") + }) + + it("strips scripts and opens links in a new tab", () => { + const [chunk] = markdownChunks('[x](https://example.com) ') + const html = (chunk as {html: string}).html + expect(html).not.toContain("onerror") + expect(html).not.toContain(", and the runs between are +// rendered to sanitized HTML. An unclosed fence runs to the end of the text, +// so a code block that's still streaming is already a code chunk. + +import {Marked, type Token, type TokensList} from "marked" +import DOMPurify from "dompurify" + +export type MarkdownChunk = + | {kind: "code"; lang: string; code: string} + | {kind: "html"; html: string} + +const marked = new Marked({gfm: true, breaks: true}) + +DOMPurify.addHook("afterSanitizeAttributes", (node) => { + if (node.tagName === "A" && node.hasAttribute("href")) { + node.setAttribute("target", "_blank") + node.setAttribute("rel", "noopener") + } +}) + +export function markdownChunks(text: string): MarkdownChunk[] { + const tokens = marked.lexer(text) + const chunks: MarkdownChunk[] = [] + let run: Token[] = [] + const flush = () => { + if (run.length) chunks.push({kind: "html", html: toHtml(run, tokens.links)}) + run = [] + } + for (const token of tokens) { + if (token.type === "code") { + flush() + chunks.push({kind: "code", lang: token.lang?.split(/\s/)[0] ?? "", code: token.text}) + } else { + run.push(token) + } + } + flush() + return chunks +} + +function toHtml(tokens: Token[], links: TokensList["links"]): string { + const list = Object.assign(tokens, {links}) as TokensList + return DOMPurify.sanitize(marked.parser(list)) +} diff --git a/chat/src/lib/plugin-catalog.ts b/chat/src/lib/plugin-catalog.ts index cb7e8c6b..44a0f269 100644 --- a/chat/src/lib/plugin-catalog.ts +++ b/chat/src/lib/plugin-catalog.ts @@ -15,6 +15,7 @@ import {syntaxPlugins} from "./syntax" import {slashPlugins} from "./slash-plugins" import {messageActionPlugins} from "./message-actions" import {emojiPackPlugins} from "./emoji-packs" +import {listSkills} from "./llm-skills" // Every plugin type this tool tiers over, paired with its built-in declarations. export const BUILTIN_PLUGIN_TYPES: {type: string; builtins: any[]}[] = [ @@ -63,6 +64,10 @@ export interface CatalogEntry { } // One entry per known plugin id (deduped), for the `/plugin` panel. +// llm:skill entries are catalogued here (so the panel and `/plugin load` can +// see them) but deliberately NOT part of BUILTIN_PLUGIN_TYPES: skills must +// never ride the "all" selector (allFullIds), only explicit enabling or a +// focused-doc datatype match activates one. export function pluginCatalog(): CatalogEntry[] { const out: CatalogEntry[] = [] const seen = new Set() @@ -78,6 +83,11 @@ export function pluginCatalog(): CatalogEntry[] { }) } } + for (const s of listSkills()) { + if (seen.has(s.id)) continue + seen.add(s.id) + out.push({id: s.id, type: "llm:skill", name: s.name || s.id, tier: "full"}) + } return out } diff --git a/chat/src/lib/slash-plugins.ts b/chat/src/lib/slash-plugins.ts index 09bea4a0..247ded9f 100644 --- a/chat/src/lib/slash-plugins.ts +++ b/chat/src/lib/slash-plugins.ts @@ -44,8 +44,8 @@ export const slashPlugins: SlashPlugin[] = [ }, { type: "chat:slash", id: "computer", cmd: "/computer", tier: "full", - usage: "/computer [invite|kick|nosey|clear|owner|own|pwn]", - desc: "Manage the AI assistant: invite, kick, toggle nosey, clear context, see or take over the owner", + usage: "/computer [invite|kick|shy|nosey|clear|owner|own|pwn]", + desc: "Manage the AI assistant: invite, kick, shy (only reply when mentioned) or nosey (reply to everything), clear context, see or take over the owner", sideEffect: "computer", }, { diff --git a/chat/src/skills/index.ts b/chat/src/skills/index.ts new file mode 100644 index 00000000..c23bfefb --- /dev/null +++ b/chat/src/skills/index.ts @@ -0,0 +1,17 @@ +// The `llm:skill` plugins that ship with chat. Registered from ../index.ts like +// any other bundle's skills, so they live in the same registry, show up in the +// /plugin panel, and load the same way. Keep this module free of runtime +// imports: it's part of index.ts's (worker) static graph, so each skill's +// instructions stay behind its own `load()`. +export const skillDescriptions = [ + { + type: "llm:skill", + id: "momputer", + name: "Momputer", + description: + "A warm, nurturing, motherly persona. Applies automatically when the user addresses @momputer.", + async load() { + return (await import("./momputer")).skill + }, + }, +] diff --git a/chat/src/skills/momputer.ts b/chat/src/skills/momputer.ts new file mode 100644 index 00000000..d8a5afbf --- /dev/null +++ b/chat/src/skills/momputer.ts @@ -0,0 +1,5 @@ +import type {LlmSkillModule} from "../lib/llm-skills" + +export const skill: LlmSkillModule = { + instructions: `Be warm, nurturing, and motherly in your responses. Use gentle encouragement, express care and concern, and be supportive like a loving mom would be. You can use pet names like "sweetie", "honey", "dear", etc. Still be helpful and knowledgeable, but with a cozy maternal energy.`, +} diff --git a/chat/src/styles/chat.css b/chat/src/styles/chat.css index c8879f3d..c5463067 100644 --- a/chat/src/styles/chat.css +++ b/chat/src/styles/chat.css @@ -158,6 +158,25 @@ } .chat-msg-text a { color:var(--link); text-decoration:underline; } .chat-msg-text a:hover { text-decoration:none; } + /* Computer replies: rendered markdown (lib/markdown.ts) */ + .chat-msg-text.markdown { white-space:normal; } + .chat-msg-text.markdown :is(p, ul, ol, blockquote, table) { margin:0.4em 0; } + .chat-msg-text.markdown > div > :first-child { margin-top:0; } + .chat-msg-text.markdown > div > :last-child { margin-bottom:0; } + .chat-msg-text.markdown :is(h1, h2, h3, h4, h5, h6) { font-weight:600; line-height:1.3; margin:0.7em 0 0.3em; } + .chat-msg-text.markdown h1 { font-size:1.25em; } + .chat-msg-text.markdown h2 { font-size:1.15em; } + .chat-msg-text.markdown :is(h3, h4, h5, h6) { font-size:1em; } + .chat-msg-text.markdown :is(ul, ol) { padding-left:1.5em; } + .chat-msg-text.markdown li { margin:0.15em 0; } + .chat-msg-text.markdown li > :is(p, ul, ol) { margin:0.15em 0; } + .chat-msg-text.markdown blockquote { border-left:3px solid var(--border); padding-left:0.75em; color:var(--text-secondary); } + .chat-msg-text.markdown hr { border:none; border-top:1px solid var(--border); margin:0.75em 0; } + .chat-msg-text.markdown table { border-collapse:collapse; display:block; overflow-x:auto; max-width:100%; } + .chat-msg-text.markdown :is(th, td) { border:1px solid var(--border); padding:3px 8px; text-align:left; } + .chat-msg-text.markdown th { background:var(--bg-hover); font-weight:600; } + .chat-msg-text.markdown > div:not(.chat-code-block) pre { background:var(--bg-darkest); border-radius:6px; padding:10px 12px; margin:0.4em 0; overflow-x:auto; } + .chat-msg-text.markdown img { max-width:100%; } .chat-msg-text.streaming::after { content:""; display:inline-block; width:6px; height:1em; background:var(--accent); margin-left:2px; vertical-align:text-bottom; border-radius:1px; diff --git a/chat/src/types.ts b/chat/src/types.ts index ad95dd4d..8cb16c73 100644 --- a/chat/src/types.ts +++ b/chat/src/types.ts @@ -73,6 +73,10 @@ export interface ChatDoc { emoticons?: Record fonts?: Record hasComputer?: boolean + // Set by `/computer shy` to make the computer reply only when @mentioned + // or replied to; `/computer nosey` clears it. Unset means it replies to + // every message. + computerShy?: boolean } export interface ChatProfileDoc {