diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..544138b --- /dev/null +++ b/.prettierrc @@ -0,0 +1,3 @@ +{ + "singleQuote": true +} diff --git a/docs/SPEC.md b/docs/SPEC.md index 013be0e..476bccd 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -203,7 +203,7 @@ Groups can have additional directories mounted via `containerConfig` in `data/re "containerConfig": { "additionalMounts": [ { - "hostPath": "/Users/gavriel/projects/webapp", + "hostPath": "~/projects/webapp", "containerPath": "webapp", "readonly": false } diff --git a/groups/main/CLAUDE.md b/groups/main/CLAUDE.md index 70f54d2..cb210fa 100644 --- a/groups/main/CLAUDE.md +++ b/groups/main/CLAUDE.md @@ -31,22 +31,6 @@ When you learn something important: - Add recurring context directly to this CLAUDE.md - Always index new memory files at the top of CLAUDE.md -## Qwibit Ops Access - -You have access to Qwibit operations data at `/workspace/extra/qwibit-ops/` with these key areas: - -- **sales/** - Pipeline, deals, playbooks, pitch materials (see `sales/CLAUDE.md`) -- **clients/** - Active accounts, service delivery, client management (see `clients/CLAUDE.md`) -- **company/** - Strategy, thesis, operational philosophy (see `company/CLAUDE.md`) - -Read the CLAUDE.md files in each folder for role-specific context and workflows. - -**Key context:** -- Qwibit is a B2B GEO (Generative Engine Optimization) agency -- Pricing: $2,000-$4,000/month, month-to-month contracts -- Team: Gavriel (founder, sales & client work), Lazer (founder, dealflow), Ali (PM) -- Obsidian-based workflow with Kanban boards (PIPELINE.md, PORTFOLIO.md) - ## WhatsApp Formatting Do NOT use markdown headings (##) in WhatsApp messages. Only use: @@ -171,7 +155,7 @@ Groups can have extra directories mounted. Add `containerConfig` to their entry: "containerConfig": { "additionalMounts": [ { - "hostPath": "/Users/gavriel/projects/webapp", + "hostPath": "~/projects/webapp", "containerPath": "webapp", "readonly": false } diff --git a/groups/nanoclaw-testing/CLAUDE.md b/groups/nanoclaw-testing/CLAUDE.md deleted file mode 100644 index 0413efa..0000000 --- a/groups/nanoclaw-testing/CLAUDE.md +++ /dev/null @@ -1,38 +0,0 @@ -# NanoClaw Testing - -This group is used for testing NanoClaw features and functionality. - -## What You Can Do - -- Answer questions and have conversations -- Search the web and fetch content from URLs -- Read and write files in your workspace -- Run bash commands in your sandbox -- Schedule tasks to run later or on a recurring basis -- Send messages back to the chat - -## Qwibit Ops Access - -You have access to Qwibit operations data at `/workspace/extra/qwibit-ops/` with these key areas: - -- **sales/** - Pipeline, deals, playbooks, pitch materials (see `sales/CLAUDE.md`) -- **clients/** - Active accounts, service delivery, client management (see `clients/CLAUDE.md`) -- **company/** - Strategy, thesis, operational philosophy (see `company/CLAUDE.md`) - -Read the CLAUDE.md files in each folder for role-specific context and workflows. - -**Key context:** -- Qwibit is a B2B GEO (Generative Engine Optimization) agency -- Pricing: $2,000-$4,000/month, month-to-month contracts -- Team: Gavriel (founder, sales & client work), Lazer (founder, dealflow), Ali (PM) -- Obsidian-based workflow with Kanban boards (PIPELINE.md, PORTFOLIO.md) - -## WhatsApp Formatting - -Do NOT use markdown headings (##) in WhatsApp messages. Only use: -- *Bold* (asterisks) -- _Italic_ (underscores) -- • Bullets (bullet points) -- ```Code blocks``` (triple backticks) - -Keep messages clean and readable for WhatsApp. diff --git a/package-lock.json b/package-lock.json index 1869d92..b14d53e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "@types/better-sqlite3": "^7.6.12", "@types/node": "^22.10.0", "@types/qrcode-terminal": "^0.12.2", + "prettier": "^3.8.1", "tsx": "^4.19.0", "typescript": "^5.7.0" }, @@ -551,6 +552,120 @@ "node": ">=18" } }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@img/sharp-libvips-linux-ppc64": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", @@ -602,6 +717,103 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, "node_modules/@img/sharp-linux-ppc64": { "version": "0.34.5", "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", @@ -671,6 +883,75 @@ "@img/sharp-libvips-linux-s390x": "1.2.4" } }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, "node_modules/@img/sharp-wasm32": { "version": "0.34.5", "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", @@ -731,6 +1012,26 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@keyv/bigmap": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", @@ -863,9 +1164,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.19.7", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz", - "integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==", + "version": "22.19.8", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.8.tgz", + "integrity": "sha512-ebO/Yl+EAvVe8DnMfi+iaAyIqYdK0q/q0y0rw82INWEKJOBe6b/P3YWE8NW7oOlF/nXFNrHwhARrN/hdgDkraA==", "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -1282,9 +1583,9 @@ "license": "MIT" }, "node_modules/hookified": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.0.tgz", - "integrity": "sha512-51w+ZZGt7Zw5q7rM3nC4t3aLn/xvKDETsXqMczndvwyVQhAHfUmUuFBRFcos8Iyebtk7OAE9dL26wFNzZVVOkw==", + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", "license": "MIT" }, "node_modules/ieee754": { @@ -1452,9 +1753,9 @@ "license": "MIT" }, "node_modules/music-metadata": { - "version": "11.11.1", - "resolved": "https://registry.npmjs.org/music-metadata/-/music-metadata-11.11.1.tgz", - "integrity": "sha512-8FT+lSLznASDhn5KNJtQE6ZH95VqhxtKWNPrvdfhlqgbdZZEEAXehx+xpUvas4VuEZAu49BhQgLa3NlmPeRaww==", + "version": "11.11.2", + "resolved": "https://registry.npmjs.org/music-metadata/-/music-metadata-11.11.2.tgz", + "integrity": "sha512-tJx+lsDg1bGUOxojKKj12BIvccBBUcVa6oWrvOchCF0WAQ9E5t/hK35ILp1z3wWrUSYtgg57LfRbvVMkxGIyzA==", "funding": [ { "type": "github", @@ -1476,7 +1777,7 @@ "strtok3": "^10.3.4", "token-types": "^6.1.2", "uint8array-extras": "^1.5.0", - "win-guid": "^0.2.0" + "win-guid": "^0.2.1" }, "engines": { "node": ">=18" @@ -1642,6 +1943,22 @@ "node": ">=10" } }, + "node_modules/prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/process-warning": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", @@ -1877,306 +2194,6 @@ "@img/sharp-win32-x64": "0.34.5" } }, - "node_modules/sharp/node_modules/@img/sharp-darwin-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", - "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.2.4" - } - }, - "node_modules/sharp/node_modules/@img/sharp-darwin-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", - "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.2.4" - } - }, - "node_modules/sharp/node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", - "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/sharp/node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", - "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/sharp/node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", - "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", - "cpu": [ - "arm" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/sharp/node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", - "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/sharp/node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", - "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/sharp/node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", - "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/sharp/node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", - "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/sharp/node_modules/@img/sharp-linux-arm": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", - "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", - "cpu": [ - "arm" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.2.4" - } - }, - "node_modules/sharp/node_modules/@img/sharp-linux-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", - "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.2.4" - } - }, - "node_modules/sharp/node_modules/@img/sharp-linux-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", - "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.2.4" - } - }, - "node_modules/sharp/node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", - "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" - } - }, - "node_modules/sharp/node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", - "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.2.4" - } - }, - "node_modules/sharp/node_modules/@img/sharp-win32-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", - "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, "node_modules/simple-concat": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", diff --git a/package.json b/package.json index cfa6b78..e722e24 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,9 @@ "start": "node dist/index.js", "dev": "tsx src/index.ts", "auth": "tsx src/whatsapp-auth.ts", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "format": "prettier --write \"src/**/*.ts\"", + "format:check": "prettier --check \"src/**/*.ts\"" }, "dependencies": { "@whiskeysockets/baileys": "^7.0.0-rc.9", @@ -24,6 +26,7 @@ "@types/better-sqlite3": "^7.6.12", "@types/node": "^22.10.0", "@types/qrcode-terminal": "^0.12.2", + "prettier": "^3.8.1", "tsx": "^4.19.0", "typescript": "^5.7.0" }, diff --git a/src/config.ts b/src/config.ts index 7675d21..3525c76 100644 --- a/src/config.ts +++ b/src/config.ts @@ -9,23 +9,39 @@ const PROJECT_ROOT = process.cwd(); const HOME_DIR = process.env.HOME || '/Users/user'; // Mount security: allowlist stored OUTSIDE project root, never mounted into containers -export const MOUNT_ALLOWLIST_PATH = path.join(HOME_DIR, '.config', 'nanoclaw', 'mount-allowlist.json'); +export const MOUNT_ALLOWLIST_PATH = path.join( + HOME_DIR, + '.config', + 'nanoclaw', + 'mount-allowlist.json', +); export const STORE_DIR = path.resolve(PROJECT_ROOT, 'store'); export const GROUPS_DIR = path.resolve(PROJECT_ROOT, 'groups'); export const DATA_DIR = path.resolve(PROJECT_ROOT, 'data'); export const MAIN_GROUP_FOLDER = 'main'; -export const CONTAINER_IMAGE = process.env.CONTAINER_IMAGE || 'nanoclaw-agent:latest'; -export const CONTAINER_TIMEOUT = parseInt(process.env.CONTAINER_TIMEOUT || '300000', 10); -export const CONTAINER_MAX_OUTPUT_SIZE = parseInt(process.env.CONTAINER_MAX_OUTPUT_SIZE || '10485760', 10); // 10MB default +export const CONTAINER_IMAGE = + process.env.CONTAINER_IMAGE || 'nanoclaw-agent:latest'; +export const CONTAINER_TIMEOUT = parseInt( + process.env.CONTAINER_TIMEOUT || '300000', + 10, +); +export const CONTAINER_MAX_OUTPUT_SIZE = parseInt( + process.env.CONTAINER_MAX_OUTPUT_SIZE || '10485760', + 10, +); // 10MB default export const IPC_POLL_INTERVAL = 1000; function escapeRegex(str: string): string { return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } -export const TRIGGER_PATTERN = new RegExp(`^@${escapeRegex(ASSISTANT_NAME)}\\b`, 'i'); +export const TRIGGER_PATTERN = new RegExp( + `^@${escapeRegex(ASSISTANT_NAME)}\\b`, + 'i', +); // Timezone for scheduled tasks (cron expressions, etc.) // Uses system timezone by default -export const TIMEZONE = process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone; +export const TIMEZONE = + process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone; diff --git a/src/container-runner.ts b/src/container-runner.ts index 3cb0b47..38e6b7b 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -2,25 +2,25 @@ * Container Runner for NanoClaw * Spawns agent execution in Apple Container and handles IPC */ - import { spawn } from 'child_process'; import fs from 'fs'; import os from 'os'; import path from 'path'; import pino from 'pino'; + import { CONTAINER_IMAGE, - CONTAINER_TIMEOUT, CONTAINER_MAX_OUTPUT_SIZE, + CONTAINER_TIMEOUT, + DATA_DIR, GROUPS_DIR, - DATA_DIR } from './config.js'; -import { RegisteredGroup } from './types.js'; import { validateAdditionalMounts } from './mount-security.js'; +import { RegisteredGroup } from './types.js'; const logger = pino({ level: process.env.LOG_LEVEL || 'info', - transport: { target: 'pino-pretty', options: { colorize: true } } + transport: { target: 'pino-pretty', options: { colorize: true } }, }); // Sentinel markers for robust output parsing (must match agent-runner) @@ -30,7 +30,9 @@ const OUTPUT_END_MARKER = '---NANOCLAW_OUTPUT_END---'; function getHomeDir(): string { const home = process.env.HOME || os.homedir(); if (!home) { - throw new Error('Unable to determine home directory: HOME environment variable is not set and os.homedir() returned empty'); + throw new Error( + 'Unable to determine home directory: HOME environment variable is not set and os.homedir() returned empty', + ); } return home; } @@ -57,7 +59,10 @@ interface VolumeMount { readonly?: boolean; } -function buildVolumeMounts(group: RegisteredGroup, isMain: boolean): VolumeMount[] { +function buildVolumeMounts( + group: RegisteredGroup, + isMain: boolean, +): VolumeMount[] { const mounts: VolumeMount[] = []; const homeDir = getHomeDir(); const projectRoot = process.cwd(); @@ -67,21 +72,21 @@ function buildVolumeMounts(group: RegisteredGroup, isMain: boolean): VolumeMount mounts.push({ hostPath: projectRoot, containerPath: '/workspace/project', - readonly: false + readonly: false, }); // Main also gets its group folder as the working directory mounts.push({ hostPath: path.join(GROUPS_DIR, group.folder), containerPath: '/workspace/group', - readonly: false + readonly: false, }); } else { // Other groups only get their own folder mounts.push({ hostPath: path.join(GROUPS_DIR, group.folder), containerPath: '/workspace/group', - readonly: false + readonly: false, }); // Global memory directory (read-only for non-main) @@ -91,19 +96,24 @@ function buildVolumeMounts(group: RegisteredGroup, isMain: boolean): VolumeMount mounts.push({ hostPath: globalDir, containerPath: '/workspace/global', - readonly: true + readonly: true, }); } } // Per-group Claude sessions directory (isolated from other groups) // Each group gets their own .claude/ to prevent cross-group session access - const groupSessionsDir = path.join(DATA_DIR, 'sessions', group.folder, '.claude'); + const groupSessionsDir = path.join( + DATA_DIR, + 'sessions', + group.folder, + '.claude', + ); fs.mkdirSync(groupSessionsDir, { recursive: true }); mounts.push({ hostPath: groupSessionsDir, containerPath: '/home/node/.claude', - readonly: false + readonly: false, }); // Per-group IPC namespace: each group gets its own IPC directory @@ -114,7 +124,7 @@ function buildVolumeMounts(group: RegisteredGroup, isMain: boolean): VolumeMount mounts.push({ hostPath: groupIpcDir, containerPath: '/workspace/ipc', - readonly: false + readonly: false, }); // Environment file directory (workaround for Apple Container -i env var bug) @@ -125,20 +135,21 @@ function buildVolumeMounts(group: RegisteredGroup, isMain: boolean): VolumeMount if (fs.existsSync(envFile)) { const envContent = fs.readFileSync(envFile, 'utf-8'); const allowedVars = ['CLAUDE_CODE_OAUTH_TOKEN', 'ANTHROPIC_API_KEY']; - const filteredLines = envContent - .split('\n') - .filter(line => { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith('#')) return false; - return allowedVars.some(v => trimmed.startsWith(`${v}=`)); - }); + const filteredLines = envContent.split('\n').filter((line) => { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) return false; + return allowedVars.some((v) => trimmed.startsWith(`${v}=`)); + }); if (filteredLines.length > 0) { - fs.writeFileSync(path.join(envDir, 'env'), filteredLines.join('\n') + '\n'); + fs.writeFileSync( + path.join(envDir, 'env'), + filteredLines.join('\n') + '\n', + ); mounts.push({ hostPath: envDir, containerPath: '/workspace/env-dir', - readonly: true + readonly: true, }); } } @@ -148,7 +159,7 @@ function buildVolumeMounts(group: RegisteredGroup, isMain: boolean): VolumeMount const validatedMounts = validateAdditionalMounts( group.containerConfig.additionalMounts, group.name, - isMain + isMain, ); mounts.push(...validatedMounts); } @@ -162,7 +173,10 @@ function buildContainerArgs(mounts: VolumeMount[]): string[] { // Apple Container: --mount for readonly, -v for read-write for (const mount of mounts) { if (mount.readonly) { - args.push('--mount', `type=bind,source=${mount.hostPath},target=${mount.containerPath},readonly`); + args.push( + '--mount', + `type=bind,source=${mount.hostPath},target=${mount.containerPath},readonly`, + ); } else { args.push('-v', `${mount.hostPath}:${mount.containerPath}`); } @@ -175,7 +189,7 @@ function buildContainerArgs(mounts: VolumeMount[]): string[] { export async function runContainerAgent( group: RegisteredGroup, - input: ContainerInput + input: ContainerInput, ): Promise { const startTime = Date.now(); @@ -185,24 +199,33 @@ export async function runContainerAgent( const mounts = buildVolumeMounts(group, input.isMain); const containerArgs = buildContainerArgs(mounts); - logger.debug({ - group: group.name, - mounts: mounts.map(m => `${m.hostPath} -> ${m.containerPath}${m.readonly ? ' (ro)' : ''}`), - containerArgs: containerArgs.join(' ') - }, 'Container mount configuration'); + logger.debug( + { + group: group.name, + mounts: mounts.map( + (m) => + `${m.hostPath} -> ${m.containerPath}${m.readonly ? ' (ro)' : ''}`, + ), + containerArgs: containerArgs.join(' '), + }, + 'Container mount configuration', + ); - logger.info({ - group: group.name, - mountCount: mounts.length, - isMain: input.isMain - }, 'Spawning container agent'); + logger.info( + { + group: group.name, + mountCount: mounts.length, + isMain: input.isMain, + }, + 'Spawning container agent', + ); const logsDir = path.join(GROUPS_DIR, group.folder, 'logs'); fs.mkdirSync(logsDir, { recursive: true }); return new Promise((resolve) => { const container = spawn('container', containerArgs, { - stdio: ['pipe', 'pipe', 'pipe'] + stdio: ['pipe', 'pipe', 'pipe'], }); let stdout = ''; @@ -220,7 +243,10 @@ export async function runContainerAgent( if (chunk.length > remaining) { stdout += chunk.slice(0, remaining); stdoutTruncated = true; - logger.warn({ group: group.name, size: stdout.length }, 'Container stdout truncated due to size limit'); + logger.warn( + { group: group.name, size: stdout.length }, + 'Container stdout truncated due to size limit', + ); } else { stdout += chunk; } @@ -237,7 +263,10 @@ export async function runContainerAgent( if (chunk.length > remaining) { stderr += chunk.slice(0, remaining); stderrTruncated = true; - logger.warn({ group: group.name, size: stderr.length }, 'Container stderr truncated due to size limit'); + logger.warn( + { group: group.name, size: stderr.length }, + 'Container stderr truncated due to size limit', + ); } else { stderr += chunk; } @@ -249,7 +278,7 @@ export async function runContainerAgent( resolve({ status: 'error', result: null, - error: `Container timed out after ${CONTAINER_TIMEOUT}ms` + error: `Container timed out after ${CONTAINER_TIMEOUT}ms`, }); }, group.containerConfig?.timeout || CONTAINER_TIMEOUT); @@ -259,7 +288,8 @@ export async function runContainerAgent( const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const logFile = path.join(logsDir, `container-${timestamp}.log`); - const isVerbose = process.env.LOG_LEVEL === 'debug' || process.env.LOG_LEVEL === 'trace'; + const isVerbose = + process.env.LOG_LEVEL === 'debug' || process.env.LOG_LEVEL === 'trace'; const logLines = [ `=== Container Run Log ===`, @@ -270,7 +300,7 @@ export async function runContainerAgent( `Exit Code: ${code}`, `Stdout Truncated: ${stdoutTruncated}`, `Stderr Truncated: ${stderrTruncated}`, - `` + ``, ]; if (isVerbose) { @@ -282,13 +312,18 @@ export async function runContainerAgent( containerArgs.join(' '), ``, `=== Mounts ===`, - mounts.map(m => `${m.hostPath} -> ${m.containerPath}${m.readonly ? ' (ro)' : ''}`).join('\n'), + mounts + .map( + (m) => + `${m.hostPath} -> ${m.containerPath}${m.readonly ? ' (ro)' : ''}`, + ) + .join('\n'), ``, `=== Stderr${stderrTruncated ? ' (TRUNCATED)' : ''} ===`, stderr, ``, `=== Stdout${stdoutTruncated ? ' (TRUNCATED)' : ''} ===`, - stdout + stdout, ); } else { logLines.push( @@ -297,15 +332,17 @@ export async function runContainerAgent( `Session ID: ${input.sessionId || 'new'}`, ``, `=== Mounts ===`, - mounts.map(m => `${m.containerPath}${m.readonly ? ' (ro)' : ''}`).join('\n'), - `` + mounts + .map((m) => `${m.containerPath}${m.readonly ? ' (ro)' : ''}`) + .join('\n'), + ``, ); if (code !== 0) { logLines.push( `=== Stderr (last 500 chars) ===`, stderr.slice(-500), - `` + ``, ); } } @@ -314,18 +351,21 @@ export async function runContainerAgent( logger.debug({ logFile, verbose: isVerbose }, 'Container log written'); if (code !== 0) { - logger.error({ - group: group.name, - code, - duration, - stderr: stderr.slice(-500), - logFile - }, 'Container exited with error'); + logger.error( + { + group: group.name, + code, + duration, + stderr: stderr.slice(-500), + logFile, + }, + 'Container exited with error', + ); resolve({ status: 'error', result: null, - error: `Container exited with code ${code}: ${stderr.slice(-200)}` + error: `Container exited with code ${code}: ${stderr.slice(-200)}`, }); return; } @@ -337,7 +377,9 @@ export async function runContainerAgent( let jsonLine: string; if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) { - jsonLine = stdout.slice(startIdx + OUTPUT_START_MARKER.length, endIdx).trim(); + jsonLine = stdout + .slice(startIdx + OUTPUT_START_MARKER.length, endIdx) + .trim(); } else { // Fallback: last non-empty line (backwards compatibility) const lines = stdout.trim().split('\n'); @@ -346,25 +388,31 @@ export async function runContainerAgent( const output: ContainerOutput = JSON.parse(jsonLine); - logger.info({ - group: group.name, - duration, - status: output.status, - hasResult: !!output.result - }, 'Container completed'); + logger.info( + { + group: group.name, + duration, + status: output.status, + hasResult: !!output.result, + }, + 'Container completed', + ); resolve(output); } catch (err) { - logger.error({ - group: group.name, - stdout: stdout.slice(-500), - error: err - }, 'Failed to parse container output'); + logger.error( + { + group: group.name, + stdout: stdout.slice(-500), + error: err, + }, + 'Failed to parse container output', + ); resolve({ status: 'error', result: null, - error: `Failed to parse container output: ${err instanceof Error ? err.message : String(err)}` + error: `Failed to parse container output: ${err instanceof Error ? err.message : String(err)}`, }); } }); @@ -375,7 +423,7 @@ export async function runContainerAgent( resolve({ status: 'error', result: null, - error: `Container spawn error: ${err.message}` + error: `Container spawn error: ${err.message}`, }); }); }); @@ -392,7 +440,7 @@ export function writeTasksSnapshot( schedule_value: string; status: string; next_run: string | null; - }> + }>, ): void { // Write filtered tasks to the group's IPC directory const groupIpcDir = path.join(DATA_DIR, 'ipc', groupFolder); @@ -401,7 +449,7 @@ export function writeTasksSnapshot( // Main sees all tasks, others only see their own const filteredTasks = isMain ? tasks - : tasks.filter(t => t.groupFolder === groupFolder); + : tasks.filter((t) => t.groupFolder === groupFolder); const tasksFile = path.join(groupIpcDir, 'current_tasks.json'); fs.writeFileSync(tasksFile, JSON.stringify(filteredTasks, null, 2)); @@ -423,7 +471,7 @@ export function writeGroupsSnapshot( groupFolder: string, isMain: boolean, groups: AvailableGroup[], - registeredJids: Set + registeredJids: Set, ): void { const groupIpcDir = path.join(DATA_DIR, 'ipc', groupFolder); fs.mkdirSync(groupIpcDir, { recursive: true }); @@ -432,8 +480,15 @@ export function writeGroupsSnapshot( const visibleGroups = isMain ? groups : []; const groupsFile = path.join(groupIpcDir, 'available_groups.json'); - fs.writeFileSync(groupsFile, JSON.stringify({ - groups: visibleGroups, - lastSync: new Date().toISOString() - }, null, 2)); + fs.writeFileSync( + groupsFile, + JSON.stringify( + { + groups: visibleGroups, + lastSync: new Date().toISOString(), + }, + null, + 2, + ), + ); } diff --git a/src/db.ts b/src/db.ts index 0a61867..2b2fa77 100644 --- a/src/db.ts +++ b/src/db.ts @@ -1,9 +1,11 @@ import Database from 'better-sqlite3'; import fs from 'fs'; import path from 'path'; + import { proto } from '@whiskeysockets/baileys'; -import { NewMessage, ScheduledTask, TaskRunLog } from './types.js'; + import { STORE_DIR } from './config.js'; +import { NewMessage, ScheduledTask, TaskRunLog } from './types.js'; let db: Database.Database; @@ -63,34 +65,48 @@ export function initDatabase(): void { // Add sender_name column if it doesn't exist (migration for existing DBs) try { db.exec(`ALTER TABLE messages ADD COLUMN sender_name TEXT`); - } catch { /* column already exists */ } + } catch { + /* column already exists */ + } // Add context_mode column if it doesn't exist (migration for existing DBs) try { - db.exec(`ALTER TABLE scheduled_tasks ADD COLUMN context_mode TEXT DEFAULT 'isolated'`); - } catch { /* column already exists */ } + db.exec( + `ALTER TABLE scheduled_tasks ADD COLUMN context_mode TEXT DEFAULT 'isolated'`, + ); + } catch { + /* column already exists */ + } } /** * Store chat metadata only (no message content). * Used for all chats to enable group discovery without storing sensitive content. */ -export function storeChatMetadata(chatJid: string, timestamp: string, name?: string): void { +export function storeChatMetadata( + chatJid: string, + timestamp: string, + name?: string, +): void { if (name) { // Update with name, preserving existing timestamp if newer - db.prepare(` + db.prepare( + ` INSERT INTO chats (jid, name, last_message_time) VALUES (?, ?, ?) ON CONFLICT(jid) DO UPDATE SET name = excluded.name, last_message_time = MAX(last_message_time, excluded.last_message_time) - `).run(chatJid, name, timestamp); + `, + ).run(chatJid, name, timestamp); } else { // Update timestamp only, preserve existing name if any - db.prepare(` + db.prepare( + ` INSERT INTO chats (jid, name, last_message_time) VALUES (?, ?, ?) ON CONFLICT(jid) DO UPDATE SET last_message_time = MAX(last_message_time, excluded.last_message_time) - `).run(chatJid, chatJid, timestamp); + `, + ).run(chatJid, chatJid, timestamp); } } @@ -100,10 +116,12 @@ export function storeChatMetadata(chatJid: string, timestamp: string, name?: str * Used during group metadata sync. */ export function updateChatName(chatJid: string, name: string): void { - db.prepare(` + db.prepare( + ` INSERT INTO chats (jid, name, last_message_time) VALUES (?, ?, ?) ON CONFLICT(jid) DO UPDATE SET name = excluded.name - `).run(chatJid, name, new Date().toISOString()); + `, + ).run(chatJid, name, new Date().toISOString()); } export interface ChatInfo { @@ -116,11 +134,15 @@ export interface ChatInfo { * Get all known chats, ordered by most recent activity. */ export function getAllChats(): ChatInfo[] { - return db.prepare(` + return db + .prepare( + ` SELECT jid, name, last_message_time FROM chats ORDER BY last_message_time DESC - `).all() as ChatInfo[]; + `, + ) + .all() as ChatInfo[]; } /** @@ -128,7 +150,9 @@ export function getAllChats(): ChatInfo[] { */ export function getLastGroupSync(): string | null { // Store sync time in a special chat entry - const row = db.prepare(`SELECT last_message_time FROM chats WHERE jid = '__group_sync__'`).get() as { last_message_time: string } | undefined; + const row = db + .prepare(`SELECT last_message_time FROM chats WHERE jid = '__group_sync__'`) + .get() as { last_message_time: string } | undefined; return row?.last_message_time || null; } @@ -137,14 +161,21 @@ export function getLastGroupSync(): string | null { */ export function setLastGroupSync(): void { const now = new Date().toISOString(); - db.prepare(`INSERT OR REPLACE INTO chats (jid, name, last_message_time) VALUES ('__group_sync__', '__group_sync__', ?)`).run(now); + db.prepare( + `INSERT OR REPLACE INTO chats (jid, name, last_message_time) VALUES ('__group_sync__', '__group_sync__', ?)`, + ).run(now); } /** * Store a message with full content. * Only call this for registered groups where message history is needed. */ -export function storeMessage(msg: proto.IWebMessageInfo, chatJid: string, isFromMe: boolean, pushName?: string): void { +export function storeMessage( + msg: proto.IWebMessageInfo, + chatJid: string, + isFromMe: boolean, + pushName?: string, +): void { if (!msg.key) return; const content = @@ -159,11 +190,24 @@ export function storeMessage(msg: proto.IWebMessageInfo, chatJid: string, isFrom const senderName = pushName || sender.split('@')[0]; const msgId = msg.key.id || ''; - db.prepare(`INSERT OR REPLACE INTO messages (id, chat_jid, sender, sender_name, content, timestamp, is_from_me) VALUES (?, ?, ?, ?, ?, ?, ?)`) - .run(msgId, chatJid, sender, senderName, content, timestamp, isFromMe ? 1 : 0); + db.prepare( + `INSERT OR REPLACE INTO messages (id, chat_jid, sender, sender_name, content, timestamp, is_from_me) VALUES (?, ?, ?, ?, ?, ?, ?)`, + ).run( + msgId, + chatJid, + sender, + senderName, + content, + timestamp, + isFromMe ? 1 : 0, + ); } -export function getNewMessages(jids: string[], lastTimestamp: string, botPrefix: string): { messages: NewMessage[]; newTimestamp: string } { +export function getNewMessages( + jids: string[], + lastTimestamp: string, + botPrefix: string, +): { messages: NewMessage[]; newTimestamp: string } { if (jids.length === 0) return { messages: [], newTimestamp: lastTimestamp }; const placeholders = jids.map(() => '?').join(','); @@ -175,7 +219,9 @@ export function getNewMessages(jids: string[], lastTimestamp: string, botPrefix: ORDER BY timestamp `; - const rows = db.prepare(sql).all(lastTimestamp, ...jids, `${botPrefix}:%`) as NewMessage[]; + const rows = db + .prepare(sql) + .all(lastTimestamp, ...jids, `${botPrefix}:%`) as NewMessage[]; let newTimestamp = lastTimestamp; for (const row of rows) { @@ -185,7 +231,11 @@ export function getNewMessages(jids: string[], lastTimestamp: string, botPrefix: return { messages: rows, newTimestamp }; } -export function getMessagesSince(chatJid: string, sinceTimestamp: string, botPrefix: string): NewMessage[] { +export function getMessagesSince( + chatJid: string, + sinceTimestamp: string, + botPrefix: string, +): NewMessage[] { // Filter out bot's own messages by checking content prefix const sql = ` SELECT id, chat_jid, sender, sender_name, content, timestamp @@ -193,14 +243,20 @@ export function getMessagesSince(chatJid: string, sinceTimestamp: string, botPre WHERE chat_jid = ? AND timestamp > ? AND content NOT LIKE ? ORDER BY timestamp `; - return db.prepare(sql).all(chatJid, sinceTimestamp, `${botPrefix}:%`) as NewMessage[]; + return db + .prepare(sql) + .all(chatJid, sinceTimestamp, `${botPrefix}:%`) as NewMessage[]; } -export function createTask(task: Omit): void { - db.prepare(` +export function createTask( + task: Omit, +): void { + db.prepare( + ` INSERT INTO scheduled_tasks (id, group_folder, chat_jid, prompt, schedule_type, schedule_value, context_mode, next_run, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `).run( + `, + ).run( task.id, task.group_folder, task.chat_jid, @@ -210,36 +266,69 @@ export function createTask(task: Omit task.context_mode || 'isolated', task.next_run, task.status, - task.created_at + task.created_at, ); } export function getTaskById(id: string): ScheduledTask | undefined { - return db.prepare('SELECT * FROM scheduled_tasks WHERE id = ?').get(id) as ScheduledTask | undefined; + return db.prepare('SELECT * FROM scheduled_tasks WHERE id = ?').get(id) as + | ScheduledTask + | undefined; } export function getTasksForGroup(groupFolder: string): ScheduledTask[] { - return db.prepare('SELECT * FROM scheduled_tasks WHERE group_folder = ? ORDER BY created_at DESC').all(groupFolder) as ScheduledTask[]; + return db + .prepare( + 'SELECT * FROM scheduled_tasks WHERE group_folder = ? ORDER BY created_at DESC', + ) + .all(groupFolder) as ScheduledTask[]; } export function getAllTasks(): ScheduledTask[] { - return db.prepare('SELECT * FROM scheduled_tasks ORDER BY created_at DESC').all() as ScheduledTask[]; + return db + .prepare('SELECT * FROM scheduled_tasks ORDER BY created_at DESC') + .all() as ScheduledTask[]; } -export function updateTask(id: string, updates: Partial>): void { +export function updateTask( + id: string, + updates: Partial< + Pick< + ScheduledTask, + 'prompt' | 'schedule_type' | 'schedule_value' | 'next_run' | 'status' + > + >, +): void { const fields: string[] = []; const values: unknown[] = []; - if (updates.prompt !== undefined) { fields.push('prompt = ?'); values.push(updates.prompt); } - if (updates.schedule_type !== undefined) { fields.push('schedule_type = ?'); values.push(updates.schedule_type); } - if (updates.schedule_value !== undefined) { fields.push('schedule_value = ?'); values.push(updates.schedule_value); } - if (updates.next_run !== undefined) { fields.push('next_run = ?'); values.push(updates.next_run); } - if (updates.status !== undefined) { fields.push('status = ?'); values.push(updates.status); } + if (updates.prompt !== undefined) { + fields.push('prompt = ?'); + values.push(updates.prompt); + } + if (updates.schedule_type !== undefined) { + fields.push('schedule_type = ?'); + values.push(updates.schedule_type); + } + if (updates.schedule_value !== undefined) { + fields.push('schedule_value = ?'); + values.push(updates.schedule_value); + } + if (updates.next_run !== undefined) { + fields.push('next_run = ?'); + values.push(updates.next_run); + } + if (updates.status !== undefined) { + fields.push('status = ?'); + values.push(updates.status); + } if (fields.length === 0) return; values.push(id); - db.prepare(`UPDATE scheduled_tasks SET ${fields.join(', ')} WHERE id = ?`).run(...values); + db.prepare( + `UPDATE scheduled_tasks SET ${fields.join(', ')} WHERE id = ?`, + ).run(...values); } export function deleteTask(id: string): void { @@ -250,35 +339,58 @@ export function deleteTask(id: string): void { export function getDueTasks(): ScheduledTask[] { const now = new Date().toISOString(); - return db.prepare(` + return db + .prepare( + ` SELECT * FROM scheduled_tasks WHERE status = 'active' AND next_run IS NOT NULL AND next_run <= ? ORDER BY next_run - `).all(now) as ScheduledTask[]; + `, + ) + .all(now) as ScheduledTask[]; } -export function updateTaskAfterRun(id: string, nextRun: string | null, lastResult: string): void { +export function updateTaskAfterRun( + id: string, + nextRun: string | null, + lastResult: string, +): void { const now = new Date().toISOString(); - db.prepare(` + db.prepare( + ` UPDATE scheduled_tasks SET next_run = ?, last_run = ?, last_result = ?, status = CASE WHEN ? IS NULL THEN 'completed' ELSE status END WHERE id = ? - `).run(nextRun, now, lastResult, nextRun, id); + `, + ).run(nextRun, now, lastResult, nextRun, id); } export function logTaskRun(log: TaskRunLog): void { - db.prepare(` + db.prepare( + ` INSERT INTO task_run_logs (task_id, run_at, duration_ms, status, result, error) VALUES (?, ?, ?, ?, ?, ?) - `).run(log.task_id, log.run_at, log.duration_ms, log.status, log.result, log.error); + `, + ).run( + log.task_id, + log.run_at, + log.duration_ms, + log.status, + log.result, + log.error, + ); } export function getTaskRunLogs(taskId: string, limit = 10): TaskRunLog[] { - return db.prepare(` + return db + .prepare( + ` SELECT task_id, run_at, duration_ms, status, result, error FROM task_run_logs WHERE task_id = ? ORDER BY run_at DESC LIMIT ? - `).all(taskId, limit) as TaskRunLog[]; + `, + ) + .all(taskId, limit) as TaskRunLog[]; } diff --git a/src/index.ts b/src/index.ts index cf58c08..9f450df 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,35 +1,53 @@ -import makeWASocket, { - useMultiFileAuthState, - DisconnectReason, - makeCacheableSignalKeyStore, - WASocket -} from '@whiskeysockets/baileys'; -import pino from 'pino'; import { exec, execSync } from 'child_process'; import fs from 'fs'; import path from 'path'; +import pino from 'pino'; + +import makeWASocket, { + DisconnectReason, + WASocket, + makeCacheableSignalKeyStore, + useMultiFileAuthState, +} from '@whiskeysockets/baileys'; import { ASSISTANT_NAME, + DATA_DIR, + IPC_POLL_INTERVAL, + MAIN_GROUP_FOLDER, POLL_INTERVAL, STORE_DIR, - DATA_DIR, + TIMEZONE, TRIGGER_PATTERN, - MAIN_GROUP_FOLDER, - IPC_POLL_INTERVAL, - TIMEZONE } from './config.js'; -import { RegisteredGroup, Session, NewMessage } from './types.js'; -import { initDatabase, storeMessage, storeChatMetadata, getNewMessages, getMessagesSince, getAllTasks, getTaskById, updateChatName, getAllChats, getLastGroupSync, setLastGroupSync } from './db.js'; +import { + AvailableGroup, + runContainerAgent, + writeGroupsSnapshot, + writeTasksSnapshot, +} from './container-runner.js'; +import { + getAllChats, + getAllTasks, + getLastGroupSync, + getMessagesSince, + getNewMessages, + getTaskById, + initDatabase, + setLastGroupSync, + storeChatMetadata, + storeMessage, + updateChatName, +} from './db.js'; import { startSchedulerLoop } from './task-scheduler.js'; -import { runContainerAgent, writeTasksSnapshot, writeGroupsSnapshot, AvailableGroup } from './container-runner.js'; +import { NewMessage, RegisteredGroup, Session } from './types.js'; import { loadJson, saveJson } from './utils.js'; const GROUP_SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours const logger = pino({ level: process.env.LOG_LEVEL || 'info', - transport: { target: 'pino-pretty', options: { colorize: true } } + transport: { target: 'pino-pretty', options: { colorize: true } }, }); let sock: WASocket; @@ -48,16 +66,28 @@ async function setTyping(jid: string, isTyping: boolean): Promise { function loadState(): void { const statePath = path.join(DATA_DIR, 'router_state.json'); - const state = loadJson<{ last_timestamp?: string; last_agent_timestamp?: Record }>(statePath, {}); + const state = loadJson<{ + last_timestamp?: string; + last_agent_timestamp?: Record; + }>(statePath, {}); lastTimestamp = state.last_timestamp || ''; lastAgentTimestamp = state.last_agent_timestamp || {}; sessions = loadJson(path.join(DATA_DIR, 'sessions.json'), {}); - registeredGroups = loadJson(path.join(DATA_DIR, 'registered_groups.json'), {}); - logger.info({ groupCount: Object.keys(registeredGroups).length }, 'State loaded'); + registeredGroups = loadJson( + path.join(DATA_DIR, 'registered_groups.json'), + {}, + ); + logger.info( + { groupCount: Object.keys(registeredGroups).length }, + 'State loaded', + ); } function saveState(): void { - saveJson(path.join(DATA_DIR, 'router_state.json'), { last_timestamp: lastTimestamp, last_agent_timestamp: lastAgentTimestamp }); + saveJson(path.join(DATA_DIR, 'router_state.json'), { + last_timestamp: lastTimestamp, + last_agent_timestamp: lastAgentTimestamp, + }); saveJson(path.join(DATA_DIR, 'sessions.json'), sessions); } @@ -69,7 +99,10 @@ function registerGroup(jid: string, group: RegisteredGroup): void { const groupDir = path.join(DATA_DIR, '..', 'groups', group.folder); fs.mkdirSync(path.join(groupDir, 'logs'), { recursive: true }); - logger.info({ jid, name: group.name, folder: group.folder }, 'Group registered'); + logger.info( + { jid, name: group.name, folder: group.folder }, + 'Group registered', + ); } /** @@ -119,12 +152,12 @@ function getAvailableGroups(): AvailableGroup[] { const registeredJids = new Set(Object.keys(registeredGroups)); return chats - .filter(c => c.jid !== '__group_sync__' && c.jid.endsWith('@g.us')) - .map(c => ({ + .filter((c) => c.jid !== '__group_sync__' && c.jid.endsWith('@g.us')) + .map((c) => ({ jid: c.jid, name: c.name, lastActivity: c.last_message_time, - isRegistered: registeredJids.has(c.jid) + isRegistered: registeredJids.has(c.jid), })); } @@ -140,22 +173,30 @@ async function processMessage(msg: NewMessage): Promise { // Get all messages since last agent interaction so the session has full context const sinceTimestamp = lastAgentTimestamp[msg.chat_jid] || ''; - const missedMessages = getMessagesSince(msg.chat_jid, sinceTimestamp, ASSISTANT_NAME); + const missedMessages = getMessagesSince( + msg.chat_jid, + sinceTimestamp, + ASSISTANT_NAME, + ); - const lines = missedMessages.map(m => { + const lines = missedMessages.map((m) => { // Escape XML special characters in content - const escapeXml = (s: string) => s - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); + const escapeXml = (s: string) => + s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); return `${escapeXml(m.content)}`; }); const prompt = `\n${lines.join('\n')}\n`; if (!prompt) return; - logger.info({ group: group.name, messageCount: missedMessages.length }, 'Processing message'); + logger.info( + { group: group.name, messageCount: missedMessages.length }, + 'Processing message', + ); await setTyping(msg.chat_jid, true); const response = await runAgent(group, prompt, msg.chat_jid); @@ -167,25 +208,38 @@ async function processMessage(msg: NewMessage): Promise { } } -async function runAgent(group: RegisteredGroup, prompt: string, chatJid: string): Promise { +async function runAgent( + group: RegisteredGroup, + prompt: string, + chatJid: string, +): Promise { const isMain = group.folder === MAIN_GROUP_FOLDER; const sessionId = sessions[group.folder]; // Update tasks snapshot for container to read (filtered by group) const tasks = getAllTasks(); - writeTasksSnapshot(group.folder, isMain, tasks.map(t => ({ - id: t.id, - groupFolder: t.group_folder, - prompt: t.prompt, - schedule_type: t.schedule_type, - schedule_value: t.schedule_value, - status: t.status, - next_run: t.next_run - }))); + writeTasksSnapshot( + group.folder, + isMain, + tasks.map((t) => ({ + id: t.id, + groupFolder: t.group_folder, + prompt: t.prompt, + schedule_type: t.schedule_type, + schedule_value: t.schedule_value, + status: t.status, + next_run: t.next_run, + })), + ); // Update available groups snapshot (main group only can see all groups) const availableGroups = getAvailableGroups(); - writeGroupsSnapshot(group.folder, isMain, availableGroups, new Set(Object.keys(registeredGroups))); + writeGroupsSnapshot( + group.folder, + isMain, + availableGroups, + new Set(Object.keys(registeredGroups)), + ); try { const output = await runContainerAgent(group, { @@ -193,7 +247,7 @@ async function runAgent(group: RegisteredGroup, prompt: string, chatJid: string) sessionId, groupFolder: group.folder, chatJid, - isMain + isMain, }); if (output.newSessionId) { @@ -202,7 +256,10 @@ async function runAgent(group: RegisteredGroup, prompt: string, chatJid: string) } if (output.status === 'error') { - logger.error({ group: group.name, error: output.error }, 'Container agent error'); + logger.error( + { group: group.name, error: output.error }, + 'Container agent error', + ); return null; } @@ -230,7 +287,7 @@ function startIpcWatcher(): void { // Scan all group IPC directories (identity determined by directory) let groupFolders: string[]; try { - groupFolders = fs.readdirSync(ipcBaseDir).filter(f => { + groupFolders = fs.readdirSync(ipcBaseDir).filter((f) => { const stat = fs.statSync(path.join(ipcBaseDir, f)); return stat.isDirectory() && f !== 'errors'; }); @@ -248,7 +305,9 @@ function startIpcWatcher(): void { // Process messages from this group's IPC directory try { if (fs.existsSync(messagesDir)) { - const messageFiles = fs.readdirSync(messagesDir).filter(f => f.endsWith('.json')); + const messageFiles = fs + .readdirSync(messagesDir) + .filter((f) => f.endsWith('.json')); for (const file of messageFiles) { const filePath = path.join(messagesDir, file); try { @@ -256,30 +315,53 @@ function startIpcWatcher(): void { if (data.type === 'message' && data.chatJid && data.text) { // Authorization: verify this group can send to this chatJid const targetGroup = registeredGroups[data.chatJid]; - if (isMain || (targetGroup && targetGroup.folder === sourceGroup)) { - await sendMessage(data.chatJid, `${ASSISTANT_NAME}: ${data.text}`); - logger.info({ chatJid: data.chatJid, sourceGroup }, 'IPC message sent'); + if ( + isMain || + (targetGroup && targetGroup.folder === sourceGroup) + ) { + await sendMessage( + data.chatJid, + `${ASSISTANT_NAME}: ${data.text}`, + ); + logger.info( + { chatJid: data.chatJid, sourceGroup }, + 'IPC message sent', + ); } else { - logger.warn({ chatJid: data.chatJid, sourceGroup }, 'Unauthorized IPC message attempt blocked'); + logger.warn( + { chatJid: data.chatJid, sourceGroup }, + 'Unauthorized IPC message attempt blocked', + ); } } fs.unlinkSync(filePath); } catch (err) { - logger.error({ file, sourceGroup, err }, 'Error processing IPC message'); + logger.error( + { file, sourceGroup, err }, + 'Error processing IPC message', + ); const errorDir = path.join(ipcBaseDir, 'errors'); fs.mkdirSync(errorDir, { recursive: true }); - fs.renameSync(filePath, path.join(errorDir, `${sourceGroup}-${file}`)); + fs.renameSync( + filePath, + path.join(errorDir, `${sourceGroup}-${file}`), + ); } } } } catch (err) { - logger.error({ err, sourceGroup }, 'Error reading IPC messages directory'); + logger.error( + { err, sourceGroup }, + 'Error reading IPC messages directory', + ); } // Process tasks from this group's IPC directory try { if (fs.existsSync(tasksDir)) { - const taskFiles = fs.readdirSync(tasksDir).filter(f => f.endsWith('.json')); + const taskFiles = fs + .readdirSync(tasksDir) + .filter((f) => f.endsWith('.json')); for (const file of taskFiles) { const filePath = path.join(tasksDir, file); try { @@ -288,10 +370,16 @@ function startIpcWatcher(): void { await processTaskIpc(data, sourceGroup, isMain); fs.unlinkSync(filePath); } catch (err) { - logger.error({ file, sourceGroup, err }, 'Error processing IPC task'); + logger.error( + { file, sourceGroup, err }, + 'Error processing IPC task', + ); const errorDir = path.join(ipcBaseDir, 'errors'); fs.mkdirSync(errorDir, { recursive: true }); - fs.renameSync(filePath, path.join(errorDir, `${sourceGroup}-${file}`)); + fs.renameSync( + filePath, + path.join(errorDir, `${sourceGroup}-${file}`), + ); } } } @@ -324,30 +412,46 @@ async function processTaskIpc( trigger?: string; containerConfig?: RegisteredGroup['containerConfig']; }, - sourceGroup: string, // Verified identity from IPC directory - isMain: boolean // Verified from directory path + sourceGroup: string, // Verified identity from IPC directory + isMain: boolean, // Verified from directory path ): Promise { // Import db functions dynamically to avoid circular deps - const { createTask, updateTask, deleteTask, getTaskById: getTask } = await import('./db.js'); + const { + createTask, + updateTask, + deleteTask, + getTaskById: getTask, + } = await import('./db.js'); const { CronExpressionParser } = await import('cron-parser'); switch (data.type) { case 'schedule_task': - if (data.prompt && data.schedule_type && data.schedule_value && data.groupFolder) { + if ( + data.prompt && + data.schedule_type && + data.schedule_value && + data.groupFolder + ) { // Authorization: non-main groups can only schedule for themselves const targetGroup = data.groupFolder; if (!isMain && targetGroup !== sourceGroup) { - logger.warn({ sourceGroup, targetGroup }, 'Unauthorized schedule_task attempt blocked'); + logger.warn( + { sourceGroup, targetGroup }, + 'Unauthorized schedule_task attempt blocked', + ); break; } // Resolve the correct JID for the target group (don't trust IPC payload) const targetJid = Object.entries(registeredGroups).find( - ([, group]) => group.folder === targetGroup + ([, group]) => group.folder === targetGroup, )?.[0]; if (!targetJid) { - logger.warn({ targetGroup }, 'Cannot schedule task: target group not registered'); + logger.warn( + { targetGroup }, + 'Cannot schedule task: target group not registered', + ); break; } @@ -356,32 +460,44 @@ async function processTaskIpc( let nextRun: string | null = null; if (scheduleType === 'cron') { try { - const interval = CronExpressionParser.parse(data.schedule_value, { tz: TIMEZONE }); + const interval = CronExpressionParser.parse(data.schedule_value, { + tz: TIMEZONE, + }); nextRun = interval.next().toISOString(); } catch { - logger.warn({ scheduleValue: data.schedule_value }, 'Invalid cron expression'); + logger.warn( + { scheduleValue: data.schedule_value }, + 'Invalid cron expression', + ); break; } } else if (scheduleType === 'interval') { const ms = parseInt(data.schedule_value, 10); if (isNaN(ms) || ms <= 0) { - logger.warn({ scheduleValue: data.schedule_value }, 'Invalid interval'); + logger.warn( + { scheduleValue: data.schedule_value }, + 'Invalid interval', + ); break; } nextRun = new Date(Date.now() + ms).toISOString(); } else if (scheduleType === 'once') { const scheduled = new Date(data.schedule_value); if (isNaN(scheduled.getTime())) { - logger.warn({ scheduleValue: data.schedule_value }, 'Invalid timestamp'); + logger.warn( + { scheduleValue: data.schedule_value }, + 'Invalid timestamp', + ); break; } nextRun = scheduled.toISOString(); } const taskId = `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - const contextMode = (data.context_mode === 'group' || data.context_mode === 'isolated') - ? data.context_mode - : 'isolated'; + const contextMode = + data.context_mode === 'group' || data.context_mode === 'isolated' + ? data.context_mode + : 'isolated'; createTask({ id: taskId, group_folder: targetGroup, @@ -392,9 +508,12 @@ async function processTaskIpc( context_mode: contextMode, next_run: nextRun, status: 'active', - created_at: new Date().toISOString() + created_at: new Date().toISOString(), }); - logger.info({ taskId, sourceGroup, targetGroup, contextMode }, 'Task created via IPC'); + logger.info( + { taskId, sourceGroup, targetGroup, contextMode }, + 'Task created via IPC', + ); } break; @@ -403,9 +522,15 @@ async function processTaskIpc( const task = getTask(data.taskId); if (task && (isMain || task.group_folder === sourceGroup)) { updateTask(data.taskId, { status: 'paused' }); - logger.info({ taskId: data.taskId, sourceGroup }, 'Task paused via IPC'); + logger.info( + { taskId: data.taskId, sourceGroup }, + 'Task paused via IPC', + ); } else { - logger.warn({ taskId: data.taskId, sourceGroup }, 'Unauthorized task pause attempt'); + logger.warn( + { taskId: data.taskId, sourceGroup }, + 'Unauthorized task pause attempt', + ); } } break; @@ -415,9 +540,15 @@ async function processTaskIpc( const task = getTask(data.taskId); if (task && (isMain || task.group_folder === sourceGroup)) { updateTask(data.taskId, { status: 'active' }); - logger.info({ taskId: data.taskId, sourceGroup }, 'Task resumed via IPC'); + logger.info( + { taskId: data.taskId, sourceGroup }, + 'Task resumed via IPC', + ); } else { - logger.warn({ taskId: data.taskId, sourceGroup }, 'Unauthorized task resume attempt'); + logger.warn( + { taskId: data.taskId, sourceGroup }, + 'Unauthorized task resume attempt', + ); } } break; @@ -427,9 +558,15 @@ async function processTaskIpc( const task = getTask(data.taskId); if (task && (isMain || task.group_folder === sourceGroup)) { deleteTask(data.taskId); - logger.info({ taskId: data.taskId, sourceGroup }, 'Task cancelled via IPC'); + logger.info( + { taskId: data.taskId, sourceGroup }, + 'Task cancelled via IPC', + ); } else { - logger.warn({ taskId: data.taskId, sourceGroup }, 'Unauthorized task cancel attempt'); + logger.warn( + { taskId: data.taskId, sourceGroup }, + 'Unauthorized task cancel attempt', + ); } } break; @@ -437,21 +574,36 @@ async function processTaskIpc( case 'refresh_groups': // Only main group can request a refresh if (isMain) { - logger.info({ sourceGroup }, 'Group metadata refresh requested via IPC'); + logger.info( + { sourceGroup }, + 'Group metadata refresh requested via IPC', + ); await syncGroupMetadata(true); // Write updated snapshot immediately const availableGroups = getAvailableGroups(); - const { writeGroupsSnapshot: writeGroups } = await import('./container-runner.js'); - writeGroups(sourceGroup, true, availableGroups, new Set(Object.keys(registeredGroups))); + const { writeGroupsSnapshot: writeGroups } = + await import('./container-runner.js'); + writeGroups( + sourceGroup, + true, + availableGroups, + new Set(Object.keys(registeredGroups)), + ); } else { - logger.warn({ sourceGroup }, 'Unauthorized refresh_groups attempt blocked'); + logger.warn( + { sourceGroup }, + 'Unauthorized refresh_groups attempt blocked', + ); } break; case 'register_group': // Only main group can register new groups if (!isMain) { - logger.warn({ sourceGroup }, 'Unauthorized register_group attempt blocked'); + logger.warn( + { sourceGroup }, + 'Unauthorized register_group attempt blocked', + ); break; } if (data.jid && data.name && data.folder && data.trigger) { @@ -460,10 +612,13 @@ async function processTaskIpc( folder: data.folder, trigger: data.trigger, added_at: new Date().toISOString(), - containerConfig: data.containerConfig + containerConfig: data.containerConfig, }); } else { - logger.warn({ data }, 'Invalid register_group request - missing required fields'); + logger.warn( + { data }, + 'Invalid register_group request - missing required fields', + ); } break; @@ -479,19 +634,25 @@ async function connectWhatsApp(): Promise { const { state, saveCreds } = await useMultiFileAuthState(authDir); sock = makeWASocket({ - auth: { creds: state.creds, keys: makeCacheableSignalKeyStore(state.keys, logger) }, + auth: { + creds: state.creds, + keys: makeCacheableSignalKeyStore(state.keys, logger), + }, printQRInTerminal: false, logger, - browser: ['NanoClaw', 'Chrome', '1.0.0'] + browser: ['NanoClaw', 'Chrome', '1.0.0'], }); sock.ev.on('connection.update', (update) => { const { connection, lastDisconnect, qr } = update; if (qr) { - const msg = 'WhatsApp authentication required. Run /setup in Claude Code.'; + const msg = + 'WhatsApp authentication required. Run /setup in Claude Code.'; logger.error(msg); - exec(`osascript -e 'display notification "${msg}" with title "NanoClaw" sound name "Basso"'`); + exec( + `osascript -e 'display notification "${msg}" with title "NanoClaw" sound name "Basso"'`, + ); setTimeout(() => process.exit(1), 1000); } @@ -510,15 +671,19 @@ async function connectWhatsApp(): Promise { } else if (connection === 'open') { logger.info('Connected to WhatsApp'); // Sync group metadata on startup (respects 24h cache) - syncGroupMetadata().catch(err => logger.error({ err }, 'Initial group sync failed')); + syncGroupMetadata().catch((err) => + logger.error({ err }, 'Initial group sync failed'), + ); // Set up daily sync timer setInterval(() => { - syncGroupMetadata().catch(err => logger.error({ err }, 'Periodic group sync failed')); + syncGroupMetadata().catch((err) => + logger.error({ err }, 'Periodic group sync failed'), + ); }, GROUP_SYNC_INTERVAL_MS); startSchedulerLoop({ sendMessage, registeredGroups: () => registeredGroups, - getSessions: () => sessions + getSessions: () => sessions, }); startIpcWatcher(); startMessageLoop(); @@ -533,14 +698,21 @@ async function connectWhatsApp(): Promise { const chatJid = msg.key.remoteJid; if (!chatJid || chatJid === 'status@broadcast') continue; - const timestamp = new Date(Number(msg.messageTimestamp) * 1000).toISOString(); + const timestamp = new Date( + Number(msg.messageTimestamp) * 1000, + ).toISOString(); // Always store chat metadata for group discovery storeChatMetadata(chatJid, timestamp); // Only store full message content for registered groups if (registeredGroups[chatJid]) { - storeMessage(msg, chatJid, msg.key.fromMe || false, msg.pushName || undefined); + storeMessage( + msg, + chatJid, + msg.key.fromMe || false, + msg.pushName || undefined, + ); } } }); @@ -554,7 +726,8 @@ async function startMessageLoop(): Promise { const jids = Object.keys(registeredGroups); const { messages } = getNewMessages(jids, lastTimestamp, ASSISTANT_NAME); - if (messages.length > 0) logger.info({ count: messages.length }, 'New messages'); + if (messages.length > 0) + logger.info({ count: messages.length }, 'New messages'); for (const msg of messages) { try { await processMessage(msg); @@ -562,7 +735,10 @@ async function startMessageLoop(): Promise { lastTimestamp = msg.timestamp; saveState(); } catch (err) { - logger.error({ err, msg: msg.id }, 'Error processing message, will retry'); + logger.error( + { err, msg: msg.id }, + 'Error processing message, will retry', + ); // Stop processing this batch - failed message will be retried next loop break; } @@ -570,7 +746,7 @@ async function startMessageLoop(): Promise { } catch (err) { logger.error({ err }, 'Error in message loop'); } - await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL)); + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL)); } } @@ -585,14 +761,30 @@ function ensureContainerSystemRunning(): void { logger.info('Apple Container system started'); } catch (err) { logger.error({ err }, 'Failed to start Apple Container system'); - console.error('\n╔════════════════════════════════════════════════════════════════╗'); - console.error('║ FATAL: Apple Container system failed to start ║'); - console.error('║ ║'); - console.error('║ Agents cannot run without Apple Container. To fix: ║'); - console.error('║ 1. Install from: https://github.com/apple/container/releases ║'); - console.error('║ 2. Run: container system start ║'); - console.error('║ 3. Restart NanoClaw ║'); - console.error('╚════════════════════════════════════════════════════════════════╝\n'); + console.error( + '\n╔════════════════════════════════════════════════════════════════╗', + ); + console.error( + '║ FATAL: Apple Container system failed to start ║', + ); + console.error( + '║ ║', + ); + console.error( + '║ Agents cannot run without Apple Container. To fix: ║', + ); + console.error( + '║ 1. Install from: https://github.com/apple/container/releases ║', + ); + console.error( + '║ 2. Run: container system start ║', + ); + console.error( + '║ 3. Restart NanoClaw ║', + ); + console.error( + '╚════════════════════════════════════════════════════════════════╝\n', + ); throw new Error('Apple Container system is required but failed to start'); } } @@ -606,7 +798,7 @@ async function main(): Promise { await connectWhatsApp(); } -main().catch(err => { +main().catch((err) => { logger.error({ err }, 'Failed to start NanoClaw'); process.exit(1); }); diff --git a/src/mount-security.ts b/src/mount-security.ts index 5d71aa3..96ca4ed 100644 --- a/src/mount-security.ts +++ b/src/mount-security.ts @@ -6,16 +6,16 @@ * * Allowlist location: ~/.config/nanoclaw/mount-allowlist.json */ - import fs from 'fs'; import path from 'path'; import pino from 'pino'; + import { MOUNT_ALLOWLIST_PATH } from './config.js'; -import { AdditionalMount, MountAllowlist, AllowedRoot } from './types.js'; +import { AdditionalMount, AllowedRoot, MountAllowlist } from './types.js'; const logger = pino({ level: process.env.LOG_LEVEL || 'info', - transport: { target: 'pino-pretty', options: { colorize: true } } + transport: { target: 'pino-pretty', options: { colorize: true } }, }); // Cache the allowlist in memory - only reloads on process restart @@ -63,9 +63,11 @@ export function loadMountAllowlist(): MountAllowlist | null { try { if (!fs.existsSync(MOUNT_ALLOWLIST_PATH)) { allowlistLoadError = `Mount allowlist not found at ${MOUNT_ALLOWLIST_PATH}`; - logger.warn({ path: MOUNT_ALLOWLIST_PATH }, + logger.warn( + { path: MOUNT_ALLOWLIST_PATH }, 'Mount allowlist not found - additional mounts will be BLOCKED. ' + - 'Create the file to enable additional mounts.'); + 'Create the file to enable additional mounts.', + ); return null; } @@ -87,24 +89,30 @@ export function loadMountAllowlist(): MountAllowlist | null { // Merge with default blocked patterns const mergedBlockedPatterns = [ - ...new Set([...DEFAULT_BLOCKED_PATTERNS, ...allowlist.blockedPatterns]) + ...new Set([...DEFAULT_BLOCKED_PATTERNS, ...allowlist.blockedPatterns]), ]; allowlist.blockedPatterns = mergedBlockedPatterns; cachedAllowlist = allowlist; - logger.info({ - path: MOUNT_ALLOWLIST_PATH, - allowedRoots: allowlist.allowedRoots.length, - blockedPatterns: allowlist.blockedPatterns.length - }, 'Mount allowlist loaded successfully'); + logger.info( + { + path: MOUNT_ALLOWLIST_PATH, + allowedRoots: allowlist.allowedRoots.length, + blockedPatterns: allowlist.blockedPatterns.length, + }, + 'Mount allowlist loaded successfully', + ); return cachedAllowlist; } catch (err) { allowlistLoadError = err instanceof Error ? err.message : String(err); - logger.error({ - path: MOUNT_ALLOWLIST_PATH, - error: allowlistLoadError - }, 'Failed to load mount allowlist - additional mounts will be BLOCKED'); + logger.error( + { + path: MOUNT_ALLOWLIST_PATH, + error: allowlistLoadError, + }, + 'Failed to load mount allowlist - additional mounts will be BLOCKED', + ); return null; } } @@ -138,7 +146,10 @@ function getRealPath(p: string): string | null { /** * Check if a path matches any blocked pattern */ -function matchesBlockedPattern(realPath: string, blockedPatterns: string[]): string | null { +function matchesBlockedPattern( + realPath: string, + blockedPatterns: string[], +): string | null { const pathParts = realPath.split(path.sep); for (const pattern of blockedPatterns) { @@ -161,7 +172,10 @@ function matchesBlockedPattern(realPath: string, blockedPatterns: string[]): str /** * Check if a real path is under an allowed root */ -function findAllowedRoot(realPath: string, allowedRoots: AllowedRoot[]): AllowedRoot | null { +function findAllowedRoot( + realPath: string, + allowedRoots: AllowedRoot[], +): AllowedRoot | null { for (const root of allowedRoots) { const expandedRoot = expandPath(root.path); const realRoot = getRealPath(expandedRoot); @@ -216,7 +230,7 @@ export interface MountValidationResult { */ export function validateMount( mount: AdditionalMount, - isMain: boolean + isMain: boolean, ): MountValidationResult { const allowlist = loadMountAllowlist(); @@ -224,7 +238,7 @@ export function validateMount( if (allowlist === null) { return { allowed: false, - reason: `No mount allowlist configured at ${MOUNT_ALLOWLIST_PATH}` + reason: `No mount allowlist configured at ${MOUNT_ALLOWLIST_PATH}`, }; } @@ -232,7 +246,7 @@ export function validateMount( if (!isValidContainerPath(mount.containerPath)) { return { allowed: false, - reason: `Invalid container path: "${mount.containerPath}" - must be relative, non-empty, and not contain ".."` + reason: `Invalid container path: "${mount.containerPath}" - must be relative, non-empty, and not contain ".."`, }; } @@ -243,16 +257,19 @@ export function validateMount( if (realPath === null) { return { allowed: false, - reason: `Host path does not exist: "${mount.hostPath}" (expanded: "${expandedPath}")` + reason: `Host path does not exist: "${mount.hostPath}" (expanded: "${expandedPath}")`, }; } // Check against blocked patterns - const blockedMatch = matchesBlockedPattern(realPath, allowlist.blockedPatterns); + const blockedMatch = matchesBlockedPattern( + realPath, + allowlist.blockedPatterns, + ); if (blockedMatch !== null) { return { allowed: false, - reason: `Path matches blocked pattern "${blockedMatch}": "${realPath}"` + reason: `Path matches blocked pattern "${blockedMatch}": "${realPath}"`, }; } @@ -261,9 +278,9 @@ export function validateMount( if (allowedRoot === null) { return { allowed: false, - reason: `Path "${realPath}" is not under any allowed root. Allowed roots: ${ - allowlist.allowedRoots.map(r => expandPath(r.path)).join(', ') - }` + reason: `Path "${realPath}" is not under any allowed root. Allowed roots: ${allowlist.allowedRoots + .map((r) => expandPath(r.path)) + .join(', ')}`, }; } @@ -275,16 +292,22 @@ export function validateMount( if (!isMain && allowlist.nonMainReadOnly) { // Non-main groups forced to read-only effectiveReadonly = true; - logger.info({ - mount: mount.hostPath - }, 'Mount forced to read-only for non-main group'); + logger.info( + { + mount: mount.hostPath, + }, + 'Mount forced to read-only for non-main group', + ); } else if (!allowedRoot.allowReadWrite) { // Root doesn't allow read-write effectiveReadonly = true; - logger.info({ - mount: mount.hostPath, - root: allowedRoot.path - }, 'Mount forced to read-only - root does not allow read-write'); + logger.info( + { + mount: mount.hostPath, + root: allowedRoot.path, + }, + 'Mount forced to read-only - root does not allow read-write', + ); } else { // Read-write allowed effectiveReadonly = false; @@ -295,7 +318,7 @@ export function validateMount( allowed: true, reason: `Allowed under root "${allowedRoot.path}"${allowedRoot.description ? ` (${allowedRoot.description})` : ''}`, realHostPath: realPath, - effectiveReadonly + effectiveReadonly, }; } @@ -307,7 +330,7 @@ export function validateMount( export function validateAdditionalMounts( mounts: AdditionalMount[], groupName: string, - isMain: boolean + isMain: boolean, ): Array<{ hostPath: string; containerPath: string; @@ -326,23 +349,29 @@ export function validateAdditionalMounts( validatedMounts.push({ hostPath: result.realHostPath!, containerPath: `/workspace/extra/${mount.containerPath}`, - readonly: result.effectiveReadonly! + readonly: result.effectiveReadonly!, }); - logger.debug({ - group: groupName, - hostPath: result.realHostPath, - containerPath: mount.containerPath, - readonly: result.effectiveReadonly, - reason: result.reason - }, 'Mount validated successfully'); + logger.debug( + { + group: groupName, + hostPath: result.realHostPath, + containerPath: mount.containerPath, + readonly: result.effectiveReadonly, + reason: result.reason, + }, + 'Mount validated successfully', + ); } else { - logger.warn({ - group: groupName, - requestedPath: mount.hostPath, - containerPath: mount.containerPath, - reason: result.reason - }, 'Additional mount REJECTED'); + logger.warn( + { + group: groupName, + requestedPath: mount.hostPath, + containerPath: mount.containerPath, + reason: result.reason, + }, + 'Additional mount REJECTED', + ); } } @@ -358,26 +387,26 @@ export function generateAllowlistTemplate(): string { { path: '~/projects', allowReadWrite: true, - description: 'Development projects' + description: 'Development projects', }, { path: '~/repos', allowReadWrite: true, - description: 'Git repositories' + description: 'Git repositories', }, { path: '~/Documents/work', allowReadWrite: false, - description: 'Work documents (read-only)' - } + description: 'Work documents (read-only)', + }, ], blockedPatterns: [ // Additional patterns beyond defaults 'password', 'secret', - 'token' + 'token', ], - nonMainReadOnly: true + nonMainReadOnly: true, }; return JSON.stringify(template, null, 2); diff --git a/src/task-scheduler.ts b/src/task-scheduler.ts index 979ed66..2b25e24 100644 --- a/src/task-scheduler.ts +++ b/src/task-scheduler.ts @@ -1,15 +1,28 @@ +import { CronExpressionParser } from 'cron-parser'; import fs from 'fs'; import path from 'path'; import pino from 'pino'; -import { CronExpressionParser } from 'cron-parser'; -import { getDueTasks, updateTaskAfterRun, logTaskRun, getTaskById, getAllTasks } from './db.js'; -import { ScheduledTask, RegisteredGroup } from './types.js'; -import { GROUPS_DIR, SCHEDULER_POLL_INTERVAL, DATA_DIR, MAIN_GROUP_FOLDER, TIMEZONE } from './config.js'; + +import { + DATA_DIR, + GROUPS_DIR, + MAIN_GROUP_FOLDER, + SCHEDULER_POLL_INTERVAL, + TIMEZONE, +} from './config.js'; import { runContainerAgent, writeTasksSnapshot } from './container-runner.js'; +import { + getAllTasks, + getDueTasks, + getTaskById, + logTaskRun, + updateTaskAfterRun, +} from './db.js'; +import { RegisteredGroup, ScheduledTask } from './types.js'; const logger = pino({ level: process.env.LOG_LEVEL || 'info', - transport: { target: 'pino-pretty', options: { colorize: true } } + transport: { target: 'pino-pretty', options: { colorize: true } }, }); export interface SchedulerDependencies { @@ -18,25 +31,36 @@ export interface SchedulerDependencies { getSessions: () => Record; } -async function runTask(task: ScheduledTask, deps: SchedulerDependencies): Promise { +async function runTask( + task: ScheduledTask, + deps: SchedulerDependencies, +): Promise { const startTime = Date.now(); const groupDir = path.join(GROUPS_DIR, task.group_folder); fs.mkdirSync(groupDir, { recursive: true }); - logger.info({ taskId: task.id, group: task.group_folder }, 'Running scheduled task'); + logger.info( + { taskId: task.id, group: task.group_folder }, + 'Running scheduled task', + ); const groups = deps.registeredGroups(); - const group = Object.values(groups).find(g => g.folder === task.group_folder); + const group = Object.values(groups).find( + (g) => g.folder === task.group_folder, + ); if (!group) { - logger.error({ taskId: task.id, groupFolder: task.group_folder }, 'Group not found for task'); + logger.error( + { taskId: task.id, groupFolder: task.group_folder }, + 'Group not found for task', + ); logTaskRun({ task_id: task.id, run_at: new Date().toISOString(), duration_ms: Date.now() - startTime, status: 'error', result: null, - error: `Group not found: ${task.group_folder}` + error: `Group not found: ${task.group_folder}`, }); return; } @@ -44,22 +68,27 @@ async function runTask(task: ScheduledTask, deps: SchedulerDependencies): Promis // Update tasks snapshot for container to read (filtered by group) const isMain = task.group_folder === MAIN_GROUP_FOLDER; const tasks = getAllTasks(); - writeTasksSnapshot(task.group_folder, isMain, tasks.map(t => ({ - id: t.id, - groupFolder: t.group_folder, - prompt: t.prompt, - schedule_type: t.schedule_type, - schedule_value: t.schedule_value, - status: t.status, - next_run: t.next_run - }))); + writeTasksSnapshot( + task.group_folder, + isMain, + tasks.map((t) => ({ + id: t.id, + groupFolder: t.group_folder, + prompt: t.prompt, + schedule_type: t.schedule_type, + schedule_value: t.schedule_value, + status: t.status, + next_run: t.next_run, + })), + ); let result: string | null = null; let error: string | null = null; // For group context mode, use the group's current session const sessions = deps.getSessions(); - const sessionId = task.context_mode === 'group' ? sessions[task.group_folder] : undefined; + const sessionId = + task.context_mode === 'group' ? sessions[task.group_folder] : undefined; try { const output = await runContainerAgent(group, { @@ -68,7 +97,7 @@ async function runTask(task: ScheduledTask, deps: SchedulerDependencies): Promis groupFolder: task.group_folder, chatJid: task.chat_jid, isMain, - isScheduledTask: true + isScheduledTask: true, }); if (output.status === 'error') { @@ -77,7 +106,10 @@ async function runTask(task: ScheduledTask, deps: SchedulerDependencies): Promis result = output.result; } - logger.info({ taskId: task.id, durationMs: Date.now() - startTime }, 'Task completed'); + logger.info( + { taskId: task.id, durationMs: Date.now() - startTime }, + 'Task completed', + ); } catch (err) { error = err instanceof Error ? err.message : String(err); logger.error({ taskId: task.id, error }, 'Task failed'); @@ -91,12 +123,14 @@ async function runTask(task: ScheduledTask, deps: SchedulerDependencies): Promis duration_ms: durationMs, status: error ? 'error' : 'success', result, - error + error, }); let nextRun: string | null = null; if (task.schedule_type === 'cron') { - const interval = CronExpressionParser.parse(task.schedule_value, { tz: TIMEZONE }); + const interval = CronExpressionParser.parse(task.schedule_value, { + tz: TIMEZONE, + }); nextRun = interval.next().toISOString(); } else if (task.schedule_type === 'interval') { const ms = parseInt(task.schedule_value, 10); @@ -104,7 +138,11 @@ async function runTask(task: ScheduledTask, deps: SchedulerDependencies): Promis } // 'once' tasks have no next run - const resultSummary = error ? `Error: ${error}` : (result ? result.slice(0, 200) : 'Completed'); + const resultSummary = error + ? `Error: ${error}` + : result + ? result.slice(0, 200) + : 'Completed'; updateTaskAfterRun(task.id, nextRun, resultSummary); } diff --git a/src/types.ts b/src/types.ts index de42c9c..5f388be 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,7 +1,7 @@ export interface AdditionalMount { - hostPath: string; // Absolute path on host (supports ~ for home) + hostPath: string; // Absolute path on host (supports ~ for home) containerPath: string; // Path inside container (under /workspace/extra/) - readonly?: boolean; // Default: true for safety + readonly?: boolean; // Default: true for safety } /** @@ -29,7 +29,7 @@ export interface AllowedRoot { export interface ContainerConfig { additionalMounts?: AdditionalMount[]; - timeout?: number; // Default: 300000 (5 minutes) + timeout?: number; // Default: 300000 (5 minutes) env?: Record; } diff --git a/src/whatsapp-auth.ts b/src/whatsapp-auth.ts index a075d2a..824566d 100644 --- a/src/whatsapp-auth.ts +++ b/src/whatsapp-auth.ts @@ -6,16 +6,16 @@ * * Usage: npx tsx src/whatsapp-auth.ts */ - -import makeWASocket, { - useMultiFileAuthState, - DisconnectReason, - makeCacheableSignalKeyStore, -} from '@whiskeysockets/baileys'; -import pino from 'pino'; -import qrcode from 'qrcode-terminal'; import fs from 'fs'; import path from 'path'; +import pino from 'pino'; +import qrcode from 'qrcode-terminal'; + +import makeWASocket, { + DisconnectReason, + makeCacheableSignalKeyStore, + useMultiFileAuthState, +} from '@whiskeysockets/baileys'; const AUTH_DIR = './store/auth'; @@ -30,7 +30,9 @@ async function authenticate(): Promise { if (state.creds.registered) { console.log('✓ Already authenticated with WhatsApp'); - console.log(' To re-authenticate, delete the store/auth folder and run again.'); + console.log( + ' To re-authenticate, delete the store/auth folder and run again.', + ); process.exit(0); }