From 053763bf8921ca888f2d7346a9c70b46483ecf62 Mon Sep 17 00:00:00 2001 From: Cameron Date: Sun, 1 Feb 2026 20:07:57 -0800 Subject: [PATCH] Add voice message transcription support (#54) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add voice message transcription support (all channels) Adds OpenAI Whisper transcription for voice messages across all channels: - Telegram: ctx.message.voice - WhatsApp: audioMessage via downloadMediaMessage - Signal: audio attachments from local files - Slack: audio files via url_private_download - Discord: audio attachments Voice messages sent to agent as "[Voice message]: " Configuration (config takes priority over env): - lettabot.yaml: transcription.apiKey, transcription.model - Env: OPENAI_API_KEY, TRANSCRIPTION_MODEL Closes #47 Written by Cameron ◯ Letta Code "The best interface is no interface - just talk." * Add voice message documentation to README - Add Voice Messages to features list - Add configuration section for transcription - Document supported channels Written by Cameron ◯ Letta Code * Notify users when voice transcription is not configured Instead of silently ignoring voice messages, send a helpful message linking to the documentation. Written by Cameron ◯ Letta Code * feat: upgrade to letta-code-sdk main + fix Signal voice transcription - Switch from published SDK (v0.0.3) to local main branch (file:../letta-code-sdk) - Update bot.ts for new SDK API: createSession(agentId?, options) signature - Add conversationId tracking to store for proper conversation persistence - Fix Signal voice transcription: read attachments from ~/.local/share/signal-cli/attachments/ - Fix Telegram markdown ESM issue: make markdownToTelegramV2 async with dynamic import - Add transcription config to lettabot.yaml - Add extensive debug logging for queue and session processing Signal voice messages now properly transcribe and send to agent. 🐾 Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta * fix: update Signal CLI message sender to use daemon JSON-RPC API - Switch from signal-cli-rest-api to signal-cli daemon (port 8090) - Use JSON-RPC send method instead of REST /v2/send - Support group IDs with group: prefix - Handle 201 responses and empty bodies correctly 🐾 Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta * Add placeholder for untranscribed voice messages on Signal If a voice-only message arrives and transcription fails or is disabled, forward a placeholder so the user knows the message was received. Written by Cameron ◯ Letta Code --------- Co-authored-by: Letta --- README.md | 26 ++ package-lock.json | 788 +++----------------------------- package.json | 3 +- src/channels/discord.ts | 27 +- src/channels/signal.ts | 77 +++- src/channels/slack.ts | 32 +- src/channels/telegram-format.ts | 8 +- src/channels/telegram.ts | 54 ++- src/channels/whatsapp.ts | 37 +- src/cli/message.ts | 63 ++- src/config/types.ts | 9 + src/core/bot.ts | 71 ++- src/core/store.ts | 12 +- src/core/types.ts | 1 + src/transcription/index.ts | 7 + src/transcription/openai.ts | 69 +++ 16 files changed, 511 insertions(+), 773 deletions(-) create mode 100644 src/transcription/index.ts create mode 100644 src/transcription/openai.ts diff --git a/README.md b/README.md index 04cb434..f4f7c95 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ Your personal AI assistant that remembers everything across **Telegram, Slack, D - **Unified Memory** - Single agent remembers everything from all channels - **Persistent Memory** - Agent remembers conversations across sessions (days/weeks/months) - **Local Tool Execution** - Agent can read files, search code, run commands on your machine +- **Voice Messages** - Automatic transcription via OpenAI Whisper - **Heartbeat** - Periodic check-ins where the agent reviews tasks - **Scheduling** - Agent can create one-off reminders and recurring tasks - **Streaming Responses** - Real-time message updates as the agent thinks @@ -97,6 +98,31 @@ That's it! Message your bot on Telegram. > **Note:** For detailed environment variable reference and multi-channel setup, see [SKILL.md](./SKILL.md) +## Voice Messages + +LettaBot can transcribe voice messages using OpenAI Whisper. Voice messages are automatically converted to text and sent to the agent with a `[Voice message]:` prefix. + +**Supported channels:** Telegram, WhatsApp, Signal, Slack, Discord + +### Configuration + +Add your OpenAI API key to `lettabot.config.yaml`: + +```yaml +transcription: + provider: openai + apiKey: sk-... + model: whisper-1 # optional, defaults to whisper-1 +``` + +Or set via environment variable: + +```bash +export OPENAI_API_KEY=sk-... +``` + +If no API key is configured, voice messages are silently ignored. + ## Skills LettaBot is compatible with [skills.sh](https://skills.sh) and [Clawdhub](https://clawdhub.com/). diff --git a/package-lock.json b/package-lock.json index 020f7cc..1bc2404 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "@clack/prompts": "^0.11.0", "@hapi/boom": "^10.0.1", "@letta-ai/letta-client": "^1.7.6", - "@letta-ai/letta-code-sdk": "^0.0.3", + "@letta-ai/letta-code-sdk": "file:../letta-code-sdk", "@types/express": "^5.0.6", "@types/node": "^25.0.10", "@types/node-schedule": "^2.1.8", @@ -23,6 +23,7 @@ "gray-matter": "^4.0.3", "node-schedule": "^2.1.1", "open": "^11.0.0", + "openai": "^6.17.0", "qrcode-terminal": "^0.12.0", "telegram-markdown-v2": "^0.0.4", "tsx": "^4.21.0", @@ -45,39 +46,22 @@ "discord.js": "^14.25.1" } }, + "../letta-code-sdk": { + "name": "@letta-ai/letta-code-sdk", + "version": "0.0.3", + "license": "Apache-2.0", + "dependencies": { + "@letta-ai/letta-code": "latest" + }, + "devDependencies": { + "@types/bun": "latest", + "@types/node": "^22.0.0", + "typescript": "^5.7.0" + } + }, "letta-code": { "extraneous": true }, - "node_modules/@alcalzone/ansi-tokenize": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.2.4.tgz", - "integrity": "sha512-HTgrrTgZ9Jgeo6Z3oqbQ7lifOVvRR14vaDuBGPPUxk9Thm+vObaO4QfYYYWw4Zo5CWQDBEfsinFA6Gre+AqwNQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "ansi-styles": "^6.2.1", - "is-fullwidth-code-point": "^5.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@alcalzone/ansi-tokenize/node_modules/is-fullwidth-code-point": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", - "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "get-east-asian-width": "^1.3.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/@borewit/text-codec": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.1.tgz", @@ -293,6 +277,7 @@ "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -739,6 +724,8 @@ "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=18" } @@ -755,6 +742,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -777,6 +765,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -799,6 +788,7 @@ "os": [ "darwin" ], + "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -815,6 +805,7 @@ "os": [ "darwin" ], + "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -831,6 +822,7 @@ "os": [ "linux" ], + "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -847,6 +839,7 @@ "os": [ "linux" ], + "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -863,6 +856,7 @@ "os": [ "linux" ], + "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -879,6 +873,7 @@ "os": [ "linux" ], + "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -895,6 +890,7 @@ "os": [ "linux" ], + "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -911,6 +907,7 @@ "os": [ "linux" ], + "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -927,6 +924,7 @@ "os": [ "linux" ], + "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -943,6 +941,7 @@ "os": [ "linux" ], + "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -959,6 +958,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -981,6 +981,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -1003,6 +1004,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -1025,6 +1027,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -1047,6 +1050,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -1069,6 +1073,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -1091,6 +1096,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -1113,6 +1119,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -1132,6 +1139,7 @@ ], "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, + "peer": true, "dependencies": { "@emnapi/runtime": "^1.7.0" }, @@ -1154,6 +1162,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -1173,6 +1182,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -1192,6 +1202,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -1199,27 +1210,6 @@ "url": "https://opencollective.com/libvips" } }, - "node_modules/@isaacs/balanced-match": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", - "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", - "license": "MIT", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@isaacs/brace-expansion": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", - "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", - "license": "MIT", - "dependencies": { - "@isaacs/balanced-match": "^4.0.1" - }, - "engines": { - "node": "20 || >=22" - } - }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -1267,124 +1257,9 @@ "integrity": "sha512-C/f03uE3TJdgfHk/8rRBxzWvY0YHCYAlrePHcTd0CRHMo++0TA1OTcgiCF+EFVDVYGzfPSeMpqgAZTNvD9r9GQ==", "license": "Apache-2.0" }, - "node_modules/@letta-ai/letta-code": { - "version": "0.14.1", - "resolved": "https://registry.npmjs.org/@letta-ai/letta-code/-/letta-code-0.14.1.tgz", - "integrity": "sha512-4XQQxqDUlFNo7uKBilyIJ4KKHC8QrFoeMfZXmr9LgtNMtXYqB+I5AYuCnG9BBueeZgO1hlDN2ekJZELyJUqrPQ==", - "hasInstallScript": true, - "license": "Apache-2.0", - "dependencies": { - "@letta-ai/letta-client": "^1.7.6", - "glob": "^13.0.0", - "ink-link": "^5.0.0", - "open": "^10.2.0", - "sharp": "^0.34.5" - }, - "bin": { - "letta": "letta.js" - }, - "optionalDependencies": { - "@vscode/ripgrep": "^1.17.0" - } - }, "node_modules/@letta-ai/letta-code-sdk": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/@letta-ai/letta-code-sdk/-/letta-code-sdk-0.0.3.tgz", - "integrity": "sha512-lal4bEGspmPcy0fxTNovgjyev5oOOdHEIkQXXLSzusVdi1yKOgYn3pyfRj/A/h+WgYjr3O/rWvp3yjOXRjf0TA==", - "license": "Apache-2.0", - "dependencies": { - "@letta-ai/letta-code": "latest" - } - }, - "node_modules/@letta-ai/letta-code/node_modules/glob": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.0.tgz", - "integrity": "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==", - "license": "BlueOak-1.0.0", - "dependencies": { - "minimatch": "^10.1.1", - "minipass": "^7.1.2", - "path-scurry": "^2.0.0" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@letta-ai/letta-code/node_modules/lru-cache": { - "version": "11.2.5", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.5.tgz", - "integrity": "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==", - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@letta-ai/letta-code/node_modules/minimatch": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", - "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/brace-expansion": "^5.0.0" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@letta-ai/letta-code/node_modules/open": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", - "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", - "license": "MIT", - "dependencies": { - "default-browser": "^5.2.1", - "define-lazy-prop": "^3.0.0", - "is-inside-container": "^1.0.0", - "wsl-utils": "^0.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@letta-ai/letta-code/node_modules/path-scurry": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz", - "integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==", - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^11.0.0", - "minipass": "^7.1.2" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@letta-ai/letta-code/node_modules/wsl-utils": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", - "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", - "license": "MIT", - "dependencies": { - "is-wsl": "^3.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } + "resolved": "../letta-code-sdk", + "link": true }, "node_modules/@pinojs/redact": { "version": "0.4.0", @@ -1948,19 +1823,6 @@ "npm": ">=7.0.0" } }, - "node_modules/@vscode/ripgrep": { - "version": "1.17.0", - "resolved": "https://registry.npmjs.org/@vscode/ripgrep/-/ripgrep-1.17.0.tgz", - "integrity": "sha512-mBRKm+ASPkUcw4o9aAgfbusIu6H4Sdhw09bjeP1YOBFTJEZAnrnk6WZwzv8NEjgC82f7ILvhmb1WIElSugea6g==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "dependencies": { - "https-proxy-agent": "^7.0.2", - "proxy-from-env": "^1.1.0", - "yauzl": "^2.9.2" - } - }, "node_modules/@whiskeysockets/baileys": { "version": "6.7.21", "resolved": "https://registry.npmjs.org/@whiskeysockets/baileys/-/baileys-6.7.21.tgz", @@ -2101,21 +1963,6 @@ "node": ">=8" } }, - "node_modules/ansi-escapes": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.2.0.tgz", - "integrity": "sha512-g6LhBsl+GBPRWGWsBtutpzBYuIIdBkLEvad5C/va/74Db018+5TZiyA26cZJAr3Rft5lprVqOIPxf5Vid6tqAw==", - "license": "MIT", - "dependencies": { - "environment": "^1.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/ansi-regex": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", @@ -2186,19 +2033,6 @@ "when-exit": "^2.1.4" } }, - "node_modules/auto-bind": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-5.0.1.tgz", - "integrity": "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==", - "license": "MIT", - "peer": true, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/axios": { "version": "1.13.3", "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.3.tgz", @@ -2351,16 +2185,6 @@ "balanced-match": "^1.0.0" } }, - "node_modules/buffer-crc32": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", - "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", - "license": "MIT", - "optional": true, - "engines": { - "node": "*" - } - }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -2490,69 +2314,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/cli-cursor": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-4.0.0.tgz", - "integrity": "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==", - "license": "MIT", - "peer": true, - "dependencies": { - "restore-cursor": "^4.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-truncate": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.1.1.tgz", - "integrity": "sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A==", - "license": "MIT", - "peer": true, - "dependencies": { - "slice-ansi": "^7.1.0", - "string-width": "^8.0.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-truncate/node_modules/string-width": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.1.tgz", - "integrity": "sha512-KpqHIdDL9KwYk22wEOg/VIqYbrnLeSApsKT/bSj6Ez7pn3CftUiLAv2Lccpq1ALcpLV9UX1Ppn92npZWu2w/aw==", - "license": "MIT", - "peer": true, - "dependencies": { - "get-east-asian-width": "^1.3.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/code-excerpt": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/code-excerpt/-/code-excerpt-4.0.0.tgz", - "integrity": "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==", - "license": "MIT", - "peer": true, - "dependencies": { - "convert-to-spaces": "^2.0.1" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - } - }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2640,16 +2401,6 @@ "node": ">= 0.6" } }, - "node_modules/convert-to-spaces": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz", - "integrity": "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - } - }, "node_modules/cookie": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", @@ -2822,6 +2573,8 @@ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "license": "Apache-2.0", + "optional": true, + "peer": true, "engines": { "node": ">=8" } @@ -2954,18 +2707,6 @@ "node": ">= 0.8" } }, - "node_modules/environment": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", - "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -3012,17 +2753,6 @@ "node": ">= 0.4" } }, - "node_modules/es-toolkit": { - "version": "1.44.0", - "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.44.0.tgz", - "integrity": "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==", - "license": "MIT", - "peer": true, - "workspaces": [ - "docs", - "benchmarks" - ] - }, "node_modules/esbuild": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", @@ -3082,16 +2812,6 @@ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "license": "MIT" }, - "node_modules/escape-string-regexp": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", - "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=8" - } - }, "node_modules/esprima": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", @@ -3198,16 +2918,6 @@ "license": "MIT", "optional": true }, - "node_modules/fd-slicer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", - "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", - "license": "MIT", - "optional": true, - "dependencies": { - "pend": "~1.2.0" - } - }, "node_modules/fetch-blob": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", @@ -3661,18 +3371,6 @@ "node": ">=18" } }, - "node_modules/has-flag": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-5.0.1.tgz", - "integrity": "sha512-CsNUt5x9LUdx6hnk/E2SZLsDyvfqANZSUq4+D3D8RzDJ2M+HDTIkF60ibS1vHaK55vzgiZw1bEPFG9yH7l33wA==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -3809,19 +3507,6 @@ "license": "BSD-3-Clause", "optional": true }, - "node_modules/indent-string": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", - "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -3837,139 +3522,6 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, - "node_modules/ink": { - "version": "6.6.0", - "resolved": "https://registry.npmjs.org/ink/-/ink-6.6.0.tgz", - "integrity": "sha512-QDt6FgJxgmSxAelcOvOHUvFxbIUjVpCH5bx+Slvc5m7IEcpGt3dYwbz/L+oRnqEGeRvwy1tineKK4ect3nW1vQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "@alcalzone/ansi-tokenize": "^0.2.1", - "ansi-escapes": "^7.2.0", - "ansi-styles": "^6.2.1", - "auto-bind": "^5.0.1", - "chalk": "^5.6.0", - "cli-boxes": "^3.0.0", - "cli-cursor": "^4.0.0", - "cli-truncate": "^5.1.1", - "code-excerpt": "^4.0.0", - "es-toolkit": "^1.39.10", - "indent-string": "^5.0.0", - "is-in-ci": "^2.0.0", - "patch-console": "^2.0.0", - "react-reconciler": "^0.33.0", - "signal-exit": "^3.0.7", - "slice-ansi": "^7.1.0", - "stack-utils": "^2.0.6", - "string-width": "^8.1.0", - "type-fest": "^4.27.0", - "widest-line": "^5.0.0", - "wrap-ansi": "^9.0.0", - "ws": "^8.18.0", - "yoga-layout": "~3.2.1" - }, - "engines": { - "node": ">=20" - }, - "peerDependencies": { - "@types/react": ">=19.0.0", - "react": ">=19.0.0", - "react-devtools-core": "^6.1.2" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "react-devtools-core": { - "optional": true - } - } - }, - "node_modules/ink-link": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/ink-link/-/ink-link-5.0.0.tgz", - "integrity": "sha512-TFDXc/0mwUW7LMjsr0/LeLxPVV5BnHDuDQff9RCgP4rb3R+V/4dIwGBZbCevcJZtQnVcW+Iz1LUrUbpq+UDwYA==", - "license": "MIT", - "dependencies": { - "terminal-link": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - }, - "peerDependencies": { - "ink": ">=6" - } - }, - "node_modules/ink/node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "license": "MIT", - "peer": true - }, - "node_modules/ink/node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "license": "ISC", - "peer": true - }, - "node_modules/ink/node_modules/string-width": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.1.tgz", - "integrity": "sha512-KpqHIdDL9KwYk22wEOg/VIqYbrnLeSApsKT/bSj6Ez7pn3CftUiLAv2Lccpq1ALcpLV9UX1Ppn92npZWu2w/aw==", - "license": "MIT", - "peer": true, - "dependencies": { - "get-east-asian-width": "^1.3.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ink/node_modules/wrap-ansi": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", - "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", - "license": "MIT", - "peer": true, - "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/ink/node_modules/wrap-ansi/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -4019,22 +3571,6 @@ "node": ">=8" } }, - "node_modules/is-in-ci": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-in-ci/-/is-in-ci-2.0.0.tgz", - "integrity": "sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w==", - "license": "MIT", - "peer": true, - "bin": { - "is-in-ci": "cli.js" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-in-ssh": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-in-ssh/-/is-in-ssh-1.0.0.tgz", @@ -5273,16 +4809,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6" - } - }, "node_modules/minimatch": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", @@ -5460,22 +4986,6 @@ "wrappy": "1" } }, - "node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "license": "MIT", - "peer": true, - "dependencies": { - "mimic-fn": "^2.1.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/open": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/open/-/open-11.0.0.tgz", @@ -5496,6 +5006,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/openai": { + "version": "6.17.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.17.0.tgz", + "integrity": "sha512-NHRpPEUPzAvFOAFs9+9pC6+HCw/iWsYsKCMPXH5Kw7BpMxqd8g/A07/1o7Gx2TWtCnzevVRyKMRFqyiHyAlqcA==", + "license": "Apache-2.0", + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, "node_modules/p-finally": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", @@ -5590,16 +5121,6 @@ "node": ">= 0.8" } }, - "node_modules/patch-console": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/patch-console/-/patch-console-2.0.0.tgz", - "integrity": "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==", - "license": "MIT", - "peer": true, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - } - }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -5635,13 +5156,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/pend": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", - "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", - "license": "MIT", - "optional": true - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -5871,32 +5385,6 @@ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "license": "ISC" }, - "node_modules/react": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", - "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-reconciler": { - "version": "0.33.0", - "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.33.0.tgz", - "integrity": "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==", - "license": "MIT", - "peer": true, - "dependencies": { - "scheduler": "^0.27.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "peerDependencies": { - "react": "^19.2.0" - } - }, "node_modules/real-require": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", @@ -6019,30 +5507,6 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, - "node_modules/restore-cursor": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz", - "integrity": "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==", - "license": "MIT", - "peer": true, - "dependencies": { - "onetime": "^5.1.0", - "signal-exit": "^3.0.2" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/restore-cursor/node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "license": "ISC", - "peer": true - }, "node_modules/retry": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", @@ -6132,13 +5596,6 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, - "node_modules/scheduler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", - "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "license": "MIT", - "peer": true - }, "node_modules/section-matter": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz", @@ -6221,6 +5678,8 @@ "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", "hasInstallScript": true, "license": "Apache-2.0", + "optional": true, + "peer": true, "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", @@ -6370,39 +5829,6 @@ "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", "license": "MIT" }, - "node_modules/slice-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", - "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", - "license": "MIT", - "peer": true, - "dependencies": { - "ansi-styles": "^6.2.1", - "is-fullwidth-code-point": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" - } - }, - "node_modules/slice-ansi/node_modules/is-fullwidth-code-point": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", - "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "get-east-asian-width": "^1.3.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/sonic-boom": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.0.tgz", @@ -6435,19 +5861,6 @@ "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", "license": "BSD-3-Clause" }, - "node_modules/stack-utils": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", - "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "escape-string-regexp": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -6603,34 +6016,6 @@ "integrity": "sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg==", "license": "MIT" }, - "node_modules/supports-color": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", - "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, - "node_modules/supports-hyperlinks": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-4.4.0.tgz", - "integrity": "sha512-UKbpT93hN5Nr9go5UY7bopIB9YQlMz9nm/ct4IXt/irb5YRkn9WaqrOBJGZ5Pwvsd5FQzSVeYlGdXoCAPQZrPg==", - "license": "MIT", - "dependencies": { - "has-flag": "^5.0.1", - "supports-color": "^10.2.2" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/chalk/supports-hyperlinks?sponsor=1" - } - }, "node_modules/telegram-markdown-v2": { "version": "0.0.4", "resolved": "https://registry.npmjs.org/telegram-markdown-v2/-/telegram-markdown-v2-0.0.4.tgz", @@ -6656,22 +6041,6 @@ "node": ">=18.0.0" } }, - "node_modules/terminal-link": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-5.0.0.tgz", - "integrity": "sha512-qFAy10MTMwjzjU8U16YS4YoZD+NQLHzLssFMNqgravjbvIPNiqkGFR4yjhJfmY9R5OFU7+yHxc6y+uGHkKwLRA==", - "license": "MIT", - "dependencies": { - "ansi-escapes": "^7.0.0", - "supports-hyperlinks": "^4.1.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/thread-stream": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz", @@ -7210,6 +6579,7 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", "license": "MIT", + "optional": true, "engines": { "node": ">=10.0.0" }, @@ -7269,24 +6639,6 @@ "url": "https://github.com/sponsors/eemeli" } }, - "node_modules/yauzl": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", - "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", - "license": "MIT", - "optional": true, - "dependencies": { - "buffer-crc32": "~0.2.3", - "fd-slicer": "~1.1.0" - } - }, - "node_modules/yoga-layout": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz", - "integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==", - "license": "MIT", - "peer": true - }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/package.json b/package.json index 58bae12..09849dc 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "@clack/prompts": "^0.11.0", "@hapi/boom": "^10.0.1", "@letta-ai/letta-client": "^1.7.6", - "@letta-ai/letta-code-sdk": "^0.0.3", + "@letta-ai/letta-code-sdk": "file:../letta-code-sdk", "@types/express": "^5.0.6", "@types/node": "^25.0.10", "@types/node-schedule": "^2.1.8", @@ -50,6 +50,7 @@ "gray-matter": "^4.0.3", "node-schedule": "^2.1.1", "open": "^11.0.0", + "openai": "^6.17.0", "qrcode-terminal": "^0.12.0", "telegram-markdown-v2": "^0.0.4", "tsx": "^4.21.0", diff --git a/src/channels/discord.ts b/src/channels/discord.ts index 9194502..f4ce9ac 100644 --- a/src/channels/discord.ts +++ b/src/channels/discord.ts @@ -132,9 +132,34 @@ Ask the bot owner to approve with: this.client.on('messageCreate', async (message) => { if (message.author?.bot) return; - const content = (message.content || '').trim(); + let content = (message.content || '').trim(); const userId = message.author?.id; if (!userId) return; + + // Handle audio attachments + const audioAttachment = message.attachments.find(a => a.contentType?.startsWith('audio/')); + if (audioAttachment?.url) { + try { + const { loadConfig } = await import('../config/index.js'); + const config = loadConfig(); + if (!config.transcription?.apiKey && !process.env.OPENAI_API_KEY) { + await message.reply('Voice messages require OpenAI API key for transcription. See: https://github.com/letta-ai/lettabot#voice-messages'); + } else { + // Download audio + const response = await fetch(audioAttachment.url); + const buffer = Buffer.from(await response.arrayBuffer()); + + const { transcribeAudio } = await import('../transcription/index.js'); + const ext = audioAttachment.contentType?.split('/')[1] || 'mp3'; + const transcript = await transcribeAudio(buffer, audioAttachment.name || `audio.${ext}`); + + console.log(`[Discord] Transcribed audio: "${transcript.slice(0, 50)}..."`); + content = (content ? content + '\n' : '') + `[Voice message]: ${transcript}`; + } + } catch (error) { + console.error('[Discord] Error transcribing audio:', error); + } + } const access = await this.checkAccess(userId); if (access === 'blocked') { diff --git a/src/channels/signal.ts b/src/channels/signal.ts index 2501173..1d52138 100644 --- a/src/channels/signal.ts +++ b/src/channels/signal.ts @@ -46,6 +46,11 @@ type SignalSseEvent = { groupId?: string; groupName?: string; }; + attachments?: Array<{ + contentType?: string; + filename?: string; + id?: string; + }>; }; syncMessage?: { sentMessage?: { @@ -57,6 +62,11 @@ type SignalSseEvent = { groupId?: string; groupName?: string; }; + attachments?: Array<{ + contentType?: string; + filename?: string; + id?: string; + }>; }; }; typingMessage?: { @@ -444,6 +454,11 @@ This code expires in 1 hour.`; if (!envelope) return; + // Debug: log when we receive any message + if (envelope.dataMessage || envelope.syncMessage) { + console.log('[Signal] Received envelope:', JSON.stringify(envelope, null, 2)); + } + // Handle incoming data messages (from others) const dataMessage = envelope.dataMessage; @@ -455,23 +470,26 @@ This code expires in 1 hour.`; let source: string | undefined; let chatId: string | undefined; let groupInfo: { groupId?: string; groupName?: string } | undefined; + let attachments: Array<{ contentType?: string; filename?: string; id?: string }> | undefined; - if (dataMessage?.message) { + if (dataMessage?.message || dataMessage?.attachments?.length) { // Regular incoming message messageText = dataMessage.message; source = envelope.source || envelope.sourceUuid; groupInfo = dataMessage.groupInfo; + attachments = dataMessage.attachments; if (groupInfo?.groupId) { chatId = `group:${groupInfo.groupId}`; } else { chatId = source; } - } else if (syncMessage?.message) { + } else if (syncMessage?.message || syncMessage?.attachments?.length) { // Sync message (Note to Self or sent from another device) messageText = syncMessage.message; source = syncMessage.destination || syncMessage.destinationUuid; groupInfo = syncMessage.groupInfo; + attachments = syncMessage.attachments; // For Note to Self, destination is our own number const isNoteToSelf = source === this.config.phoneNumber || @@ -487,20 +505,73 @@ This code expires in 1 hour.`; } } - if (!messageText || !source || !chatId) { + // Check if we have a valid message before attachment processing + if (!source || !chatId) { + return; + } + + // Handle voice message attachments + const voiceAttachment = attachments?.find(a => a.contentType?.startsWith('audio/')); + if (voiceAttachment?.id) { + console.log(`[Signal] Voice attachment detected: ${voiceAttachment.contentType}, id: ${voiceAttachment.id}`); + try { + const { loadConfig } = await import('../config/index.js'); + const config = loadConfig(); + if (!config.transcription?.apiKey && !process.env.OPENAI_API_KEY) { + if (chatId) { + await this.sendMessage({ + chatId, + text: 'Voice messages require OpenAI API key for transcription. See: https://github.com/letta-ai/lettabot#voice-messages' + }); + } + } else { + // Read attachment from signal-cli attachments directory + const { readFileSync } = await import('node:fs'); + const { homedir } = await import('node:os'); + const { join } = await import('node:path'); + + const attachmentPath = join(homedir(), '.local/share/signal-cli/attachments', voiceAttachment.id); + console.log(`[Signal] Reading attachment from: ${attachmentPath}`); + const buffer = readFileSync(attachmentPath); + console.log(`[Signal] Read ${buffer.length} bytes`); + + const { transcribeAudio } = await import('../transcription/index.js'); + const ext = voiceAttachment.contentType?.split('/')[1] || 'ogg'; + const transcript = await transcribeAudio(buffer, `voice.${ext}`); + + console.log(`[Signal] Transcribed voice message: "${transcript.slice(0, 50)}..."`); + messageText = (messageText ? messageText + '\n' : '') + `[Voice message]: ${transcript}`; + } + } catch (error) { + console.error('[Signal] Error transcribing voice message:', error); + } + } + + // After processing attachments, check if we have any message content. + // If this was a voice-only message and transcription failed/was disabled, + // still forward a placeholder so the user knows we got it. + if (!messageText && voiceAttachment?.id) { + messageText = '[Voice message received]'; + } + if (!messageText) { return; } // Handle Note to Self - check selfChatMode + console.log(`[Signal] Processing message: chatId=${chatId}, source=${source}, selfChatMode=${this.config.selfChatMode}`); if (chatId === 'note-to-self') { if (!this.config.selfChatMode) { // selfChatMode disabled - ignore Note to Self messages + console.log('[Signal] Note to Self ignored (selfChatMode disabled)'); return; } // selfChatMode enabled - allow the message through + console.log('[Signal] Note to Self allowed (selfChatMode enabled)'); } else { // External message - check access control + console.log('[Signal] Checking access for external message'); const access = await this.checkAccess(source); + console.log(`[Signal] Access result: ${access}`); if (access === 'blocked') { console.log(`[Signal] Blocked message from unauthorized user: ${source}`); diff --git a/src/channels/slack.ts b/src/channels/slack.ts index 76cee96..2ed9337 100644 --- a/src/channels/slack.ts +++ b/src/channels/slack.ts @@ -44,16 +44,44 @@ export class SlackAdapter implements ChannelAdapter { }); // Handle messages - this.app.message(async ({ message, say }) => { + this.app.message(async ({ message, say, client }) => { // Type guard for regular messages if (message.subtype !== undefined) return; if (!('user' in message) || !('text' in message)) return; const userId = message.user; - const text = message.text || ''; + let text = message.text || ''; const channelId = message.channel; const threadTs = message.thread_ts || message.ts; // Reply in thread if applicable + // Handle audio file attachments + const files = (message as any).files as Array<{ mimetype?: string; url_private_download?: string; name?: string }> | undefined; + const audioFile = files?.find(f => f.mimetype?.startsWith('audio/')); + if (audioFile?.url_private_download) { + try { + const { loadConfig } = await import('../config/index.js'); + const config = loadConfig(); + if (!config.transcription?.apiKey && !process.env.OPENAI_API_KEY) { + await say('Voice messages require OpenAI API key for transcription. See: https://github.com/letta-ai/lettabot#voice-messages'); + } else { + // Download file (requires bot token for auth) + const response = await fetch(audioFile.url_private_download, { + headers: { 'Authorization': `Bearer ${this.config.botToken}` } + }); + const buffer = Buffer.from(await response.arrayBuffer()); + + const { transcribeAudio } = await import('../transcription/index.js'); + const ext = audioFile.mimetype?.split('/')[1] || 'mp3'; + const transcript = await transcribeAudio(buffer, audioFile.name || `audio.${ext}`); + + console.log(`[Slack] Transcribed audio: "${transcript.slice(0, 50)}..."`); + text = (text ? text + '\n' : '') + `[Voice message]: ${transcript}`; + } + } catch (error) { + console.error('[Slack] Error transcribing audio:', error); + } + } + // Check allowed users if (this.config.allowedUsers && this.config.allowedUsers.length > 0) { if (!this.config.allowedUsers.includes(userId)) { diff --git a/src/channels/telegram-format.ts b/src/channels/telegram-format.ts index 93fca7a..b3fa29c 100644 --- a/src/channels/telegram-format.ts +++ b/src/channels/telegram-format.ts @@ -5,18 +5,18 @@ * Supports: headers, bold, italic, code, links, blockquotes, lists, etc. */ -import { convert } from 'telegram-markdown-v2'; - /** * Convert markdown to Telegram MarkdownV2 format. * Handles proper escaping of special characters. */ -export function markdownToTelegramV2(markdown: string): string { +export async function markdownToTelegramV2(markdown: string): Promise { try { + // Dynamic import to avoid ESM/CommonJS compatibility issues + const { convert } = await import('telegram-markdown-v2'); // Use 'keep' strategy to preserve blockquotes (>) and other elements return convert(markdown, 'keep'); } catch (e) { - console.error('[Telegram] Markdown conversion failed:', e); + console.error('[Telegram] Markdown conversion failed, using fallback:', e); // Fallback: escape special characters manually return escapeMarkdownV2(markdown); } diff --git a/src/channels/telegram.ts b/src/channels/telegram.ts index 43e509a..c5364a9 100644 --- a/src/channels/telegram.ts +++ b/src/channels/telegram.ts @@ -167,6 +167,56 @@ export class TelegramAdapter implements ChannelAdapter { } }); + // Handle voice messages + this.bot.on('message:voice', async (ctx) => { + const userId = ctx.from?.id; + const chatId = ctx.chat.id; + + if (!userId) return; + + // Check if transcription is configured (config or env) + const { loadConfig } = await import('../config/index.js'); + const config = loadConfig(); + if (!config.transcription?.apiKey && !process.env.OPENAI_API_KEY) { + await ctx.reply('Voice messages require OpenAI API key for transcription. See: https://github.com/letta-ai/lettabot#voice-messages'); + return; + } + + try { + // Get file link + const voice = ctx.message.voice; + const file = await ctx.api.getFile(voice.file_id); + const fileUrl = `https://api.telegram.org/file/bot${this.config.token}/${file.file_path}`; + + // Download audio + const response = await fetch(fileUrl); + const buffer = Buffer.from(await response.arrayBuffer()); + + // Transcribe + const { transcribeAudio } = await import('../transcription/index.js'); + const transcript = await transcribeAudio(buffer, 'voice.ogg'); + + console.log(`[Telegram] Transcribed voice message: "${transcript.slice(0, 50)}..."`); + + // Send to agent as text with prefix + if (this.onMessage) { + await this.onMessage({ + channel: 'telegram', + chatId: String(chatId), + userId: String(userId), + userName: ctx.from.username || ctx.from.first_name, + messageId: String(ctx.message.message_id), + text: `[Voice message]: ${transcript}`, + timestamp: new Date(), + }); + } + } catch (error) { + console.error('[Telegram] Error processing voice message:', error); + // Optionally notify user + await ctx.reply('Sorry, I could not transcribe that voice message.'); + } + }); + // Error handler this.bot.catch((err) => { console.error('[Telegram] Bot error:', err); @@ -199,7 +249,7 @@ export class TelegramAdapter implements ChannelAdapter { const { markdownToTelegramV2 } = await import('./telegram-format.js'); // Convert markdown to Telegram MarkdownV2 format - const formatted = markdownToTelegramV2(msg.text); + const formatted = await markdownToTelegramV2(msg.text); const result = await this.bot.api.sendMessage(msg.chatId, formatted, { parse_mode: 'MarkdownV2', @@ -210,7 +260,7 @@ export class TelegramAdapter implements ChannelAdapter { async editMessage(chatId: string, messageId: string, text: string): Promise { const { markdownToTelegramV2 } = await import('./telegram-format.js'); - const formatted = markdownToTelegramV2(text); + const formatted = await markdownToTelegramV2(text); await this.bot.api.editMessageText(chatId, Number(messageId), formatted, { parse_mode: 'MarkdownV2' }); } diff --git a/src/channels/whatsapp.ts b/src/channels/whatsapp.ts index 87a633a..0b50830 100644 --- a/src/channels/whatsapp.ts +++ b/src/channels/whatsapp.ts @@ -142,6 +142,7 @@ Ask the bot owner to approve with: DisconnectReason, fetchLatestBaileysVersion, makeCacheableSignalKeyStore, + downloadMediaMessage, } = await import('@whiskeysockets/baileys'); // Load auth state @@ -253,10 +254,38 @@ Ask the bot owner to approve with: this.lidToJid.set(remoteJid, (m.key as any).senderPn); } - // Get message text - const text = m.message?.conversation || - m.message?.extendedTextMessage?.text || - ''; + // Get message text or audio + let text = m.message?.conversation || + m.message?.extendedTextMessage?.text || + ''; + + // Handle audio/voice messages + const audioMessage = m.message?.audioMessage; + if (audioMessage) { + try { + const { loadConfig } = await import('../config/index.js'); + const config = loadConfig(); + if (!config.transcription?.apiKey && !process.env.OPENAI_API_KEY) { + await this.sock!.sendMessage(remoteJid, { + text: 'Voice messages require OpenAI API key for transcription. See: https://github.com/letta-ai/lettabot#voice-messages' + }); + continue; + } + + // Download audio + const buffer = await downloadMediaMessage(m, 'buffer', {}); + + // Transcribe + const { transcribeAudio } = await import('../transcription/index.js'); + const transcript = await transcribeAudio(buffer as Buffer, 'voice.ogg'); + + console.log(`[WhatsApp] Transcribed voice message: "${transcript.slice(0, 50)}..."`); + text = `[Voice message]: ${transcript}`; + } catch (error) { + console.error('[WhatsApp] Error transcribing voice message:', error); + continue; + } + } if (!text) continue; diff --git a/src/cli/message.ts b/src/cli/message.ts index cfa4bbe..8cd5bb6 100644 --- a/src/cli/message.ts +++ b/src/cli/message.ts @@ -100,28 +100,61 @@ async function sendSlack(chatId: string, text: string): Promise { } async function sendSignal(chatId: string, text: string): Promise { - const apiUrl = process.env.SIGNAL_CLI_REST_API_URL || 'http://localhost:8080'; + // We talk to the signal-cli daemon JSON-RPC API (the same daemon the Signal adapter uses). + // This is *not* the signal-cli-rest-api container. + const apiUrl = process.env.SIGNAL_CLI_REST_API_URL || 'http://127.0.0.1:8090'; const phoneNumber = process.env.SIGNAL_PHONE_NUMBER; - + if (!phoneNumber) { throw new Error('SIGNAL_PHONE_NUMBER not set'); } - - const response = await fetch(`${apiUrl}/v2/send`, { + + // Support group IDs in the same format we use everywhere else. + const params: Record = { + account: phoneNumber, + message: text, + }; + + if (chatId.startsWith('group:')) { + params.groupId = chatId.slice('group:'.length); + } else { + params.recipient = [chatId]; + } + + const body = JSON.stringify({ + jsonrpc: '2.0', + method: 'send', + params, + id: Date.now(), + }); + + const response = await fetch(`${apiUrl}/api/v1/rpc`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - message: text, - number: phoneNumber, - recipients: [chatId], - }), + body, }); - - if (!response.ok) { - const error = await response.text(); - throw new Error(`Signal API error: ${error}`); + + // signal-cli returns status 201 with empty body sometimes. + if (response.status === 201) { + console.log(`✓ Sent to signal:${chatId}`); + return; } - + + const textBody = await response.text(); + if (!response.ok) { + throw new Error(`Signal API error: ${textBody}`); + } + + if (!textBody.trim()) { + console.log(`✓ Sent to signal:${chatId}`); + return; + } + + const parsed = JSON.parse(textBody) as { result?: unknown; error?: { code?: number; message?: string } }; + if (parsed.error) { + throw new Error(`Signal RPC ${parsed.error.code ?? 'unknown'}: ${parsed.error.message ?? 'unknown error'}`); + } + console.log(`✓ Sent to signal:${chatId}`); } @@ -257,7 +290,7 @@ Environment variables: SLACK_BOT_TOKEN Required for Slack DISCORD_BOT_TOKEN Required for Discord SIGNAL_PHONE_NUMBER Required for Signal - SIGNAL_CLI_REST_API_URL Signal API URL (default: http://localhost:8080) + SIGNAL_CLI_REST_API_URL Signal daemon URL (default: http://127.0.0.1:8090) `); } diff --git a/src/config/types.ts b/src/config/types.ts index e69167e..1ee7077 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -49,6 +49,15 @@ export interface LettaBotConfig { integrations?: { google?: GoogleConfig; }; + + // Transcription (voice messages) + transcription?: TranscriptionConfig; +} + +export interface TranscriptionConfig { + provider: 'openai'; // Only OpenAI supported currently + apiKey?: string; // Falls back to OPENAI_API_KEY env var + model?: string; // Defaults to 'whisper-1' } export interface ProviderConfig { diff --git a/src/core/bot.ts b/src/core/bot.ts index a0855e8..8e2bb9e 100644 --- a/src/core/bot.ts +++ b/src/core/bot.ts @@ -121,10 +121,14 @@ export class LettaBot { // Add to queue this.messageQueue.push({ msg, adapter }); + console.log(`[Queue] Added to queue, length: ${this.messageQueue.length}, processing: ${this.processing}`); // Process queue if not already processing if (!this.processing) { - this.processQueue(); + console.log('[Queue] Starting queue processing'); + this.processQueue().catch(err => console.error('[Queue] Fatal error in processQueue:', err)); + } else { + console.log('[Queue] Already processing, will process when current message finishes'); } } @@ -132,12 +136,18 @@ export class LettaBot { * Process messages one at a time */ private async processQueue(): Promise { - if (this.processing || this.messageQueue.length === 0) return; + console.log(`[Queue] processQueue called: processing=${this.processing}, queueLength=${this.messageQueue.length}`); + if (this.processing || this.messageQueue.length === 0) { + console.log('[Queue] Exiting early: already processing or empty queue'); + return; + } this.processing = true; + console.log('[Queue] Started processing'); while (this.messageQueue.length > 0) { const { msg, adapter } = this.messageQueue.shift()!; + console.log(`[Queue] Processing message from ${msg.userId} (${this.messageQueue.length} remaining)`); try { await this.processMessage(msg, adapter); } catch (error) { @@ -145,6 +155,7 @@ export class LettaBot { } } + console.log('[Queue] Finished processing all messages'); this.processing = false; } @@ -152,6 +163,7 @@ export class LettaBot { * Process a single message */ private async processMessage(msg: InboundMessage, adapter: ChannelAdapter): Promise { + console.log('[Bot] Starting processMessage'); // Track when user last sent a message (for heartbeat skip logic) this.lastUserMessageTime = new Date(); @@ -163,32 +175,39 @@ export class LettaBot { updatedAt: new Date().toISOString(), }; + console.log('[Bot] Sending typing indicator'); // Start typing indicator await adapter.sendTypingIndicator(msg.chatId); + console.log('[Bot] Typing indicator sent'); // Create or resume session let session: Session; // Base options for all sessions (model only included for new agents) - // Note: canUseTool workaround for SDK v0.0.3 bug - can be removed after letta-ai/letta-code-sdk#10 is released const baseOptions = { permissionMode: 'bypassPermissions' as const, allowedTools: this.config.allowedTools, cwd: this.config.workingDir, systemPrompt: SYSTEM_PROMPT, - canUseTool: () => ({ allow: true }), }; + console.log('[Bot] Creating/resuming session'); try { - if (this.store.agentId) { + if (this.store.conversationId) { + // Resume the specific conversation we've been using + console.log(`[Bot] Resuming conversation: ${this.store.conversationId}`); + process.env.LETTA_AGENT_ID = this.store.agentId || undefined; + session = resumeSession(this.store.conversationId, baseOptions); + } else if (this.store.agentId) { + // Agent exists but no conversation - try default conversation + console.log(`[Bot] Resuming agent default conversation: ${this.store.agentId}`); process.env.LETTA_AGENT_ID = this.store.agentId; - - // Don't pass model when resuming - agent already has its model configured session = resumeSession(this.store.agentId, baseOptions); } else { - - // Only pass model when creating a new agent - session = createSession({ ...baseOptions, model: this.config.model, memory: loadMemoryBlocks(this.config.agentName) }); + // Create new agent with default conversation + console.log('[Bot] Creating new agent'); + session = createSession(undefined, { ...baseOptions, model: this.config.model, memory: loadMemoryBlocks(this.config.agentName) }); } + console.log('[Bot] Session created/resumed'); const initTimeoutMs = 30000; // 30s timeout const withTimeout = async (promise: Promise, label: string): Promise => { @@ -301,21 +320,26 @@ export class LettaBot { } if (streamMsg.type === 'result') { - // Save agent ID and attach ignore tool (only on first message) + // Save agent ID and conversation ID if (session.agentId && session.agentId !== this.store.agentId) { const isNewAgent = !this.store.agentId; // Save agent ID along with the current server URL const currentBaseUrl = process.env.LETTA_BASE_URL || 'https://api.letta.com'; - this.store.setAgent(session.agentId, currentBaseUrl); - console.log('Saved agent ID:', session.agentId, 'on server:', currentBaseUrl); + this.store.setAgent(session.agentId, currentBaseUrl, session.conversationId || undefined); + console.log('Saved agent ID:', session.agentId, 'conversation ID:', session.conversationId, 'on server:', currentBaseUrl); // Setup new agents: set name, install skills if (isNewAgent) { - if (this.config.agentName) { + if (this.config.agentName && session.agentId) { updateAgentName(session.agentId, this.config.agentName).catch(() => {}); } - installSkillsToAgent(session.agentId); + if (session.agentId) { + installSkillsToAgent(session.agentId); + } } + } else if (session.conversationId && session.conversationId !== this.store.conversationId) { + // Update conversation ID if it changed + this.store.conversationId = session.conversationId; } break; } @@ -376,22 +400,23 @@ export class LettaBot { _context?: TriggerContext ): Promise { // Base options (model only for new agents) - // Note: canUseTool workaround for SDK v0.0.3 bug - can be removed after letta-ai/letta-code-sdk#10 is released const baseOptions = { permissionMode: 'bypassPermissions' as const, allowedTools: this.config.allowedTools, cwd: this.config.workingDir, systemPrompt: SYSTEM_PROMPT, - canUseTool: () => ({ allow: true }), }; let session: Session; - if (this.store.agentId) { - // Don't pass model when resuming - agent already has its model configured + if (this.store.conversationId) { + // Resume the specific conversation we've been using + session = resumeSession(this.store.conversationId, baseOptions); + } else if (this.store.agentId) { + // Agent exists but no conversation - try default conversation session = resumeSession(this.store.agentId, baseOptions); } else { - // Only pass model when creating a new agent - session = createSession({ ...baseOptions, model: this.config.model, memory: loadMemoryBlocks(this.config.agentName) }); + // Create new agent with default conversation + session = createSession(undefined, { ...baseOptions, model: this.config.model, memory: loadMemoryBlocks(this.config.agentName) }); } try { @@ -406,7 +431,9 @@ export class LettaBot { if (msg.type === 'result') { if (session.agentId && session.agentId !== this.store.agentId) { const currentBaseUrl = process.env.LETTA_BASE_URL || 'https://api.letta.com'; - this.store.setAgent(session.agentId, currentBaseUrl); + this.store.setAgent(session.agentId, currentBaseUrl, session.conversationId || undefined); + } else if (session.conversationId && session.conversationId !== this.store.conversationId) { + this.store.conversationId = session.conversationId; } break; } diff --git a/src/core/store.ts b/src/core/store.ts index 0522cd9..e0c2347 100644 --- a/src/core/store.ts +++ b/src/core/store.ts @@ -53,6 +53,15 @@ export class Store { this.save(); } + get conversationId(): string | null { + return this.data.conversationId || null; + } + + set conversationId(id: string | null) { + this.data.conversationId = id; + this.save(); + } + get baseUrl(): string | undefined { return this.data.baseUrl; } @@ -65,9 +74,10 @@ export class Store { /** * Set agent ID and associated server URL together */ - setAgent(id: string | null, baseUrl?: string): void { + setAgent(id: string | null, baseUrl?: string, conversationId?: string): void { this.data.agentId = id; this.data.baseUrl = baseUrl; + this.data.conversationId = conversationId || this.data.conversationId; this.data.lastUsedAt = new Date().toISOString(); if (id && !this.data.createdAt) { this.data.createdAt = new Date().toISOString(); diff --git a/src/core/types.ts b/src/core/types.ts index c3a10d3..446ad98 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -101,6 +101,7 @@ export interface LastMessageTarget { */ export interface AgentStore { agentId: string | null; + conversationId?: string | null; // Current conversation ID baseUrl?: string; // Server URL this agent belongs to createdAt?: string; lastUsedAt?: string; diff --git a/src/transcription/index.ts b/src/transcription/index.ts new file mode 100644 index 0000000..dab5d69 --- /dev/null +++ b/src/transcription/index.ts @@ -0,0 +1,7 @@ +/** + * Transcription service + * + * Currently supports OpenAI Whisper. Future providers can be added here. + */ + +export { transcribeAudio } from './openai.js'; diff --git a/src/transcription/openai.ts b/src/transcription/openai.ts new file mode 100644 index 0000000..dff9952 --- /dev/null +++ b/src/transcription/openai.ts @@ -0,0 +1,69 @@ +/** + * OpenAI Whisper transcription service + */ + +import OpenAI from 'openai'; +import { loadConfig } from '../config/index.js'; + +let openaiClient: OpenAI | null = null; + +function getClient(): OpenAI { + if (!openaiClient) { + const config = loadConfig(); + // Config takes priority, then env var + const apiKey = config.transcription?.apiKey || process.env.OPENAI_API_KEY; + if (!apiKey) { + throw new Error('OpenAI API key required for transcription. Set in config (transcription.apiKey) or OPENAI_API_KEY env var.'); + } + openaiClient = new OpenAI({ apiKey }); + } + return openaiClient; +} + +function getModel(): string { + const config = loadConfig(); + return config.transcription?.model || process.env.TRANSCRIPTION_MODEL || 'whisper-1'; +} + +/** + * Transcribe audio using OpenAI Whisper API + * + * @param audioBuffer - The audio data as a Buffer + * @param filename - Filename with extension (e.g., 'voice.ogg') + * @returns The transcribed text + */ +export async function transcribeAudio(audioBuffer: Buffer, filename: string = 'audio.ogg'): Promise { + const client = getClient(); + + // Create a File object from the buffer + // OpenAI SDK expects a File-like object + // Convert Buffer to Uint8Array to satisfy BlobPart type + const file = new File([new Uint8Array(audioBuffer)], filename, { + type: getMimeType(filename) + }); + + const response = await client.audio.transcriptions.create({ + file, + model: getModel(), + }); + + return response.text; +} + +/** + * Get MIME type from filename extension + */ +function getMimeType(filename: string): string { + const ext = filename.split('.').pop()?.toLowerCase(); + const mimeTypes: Record = { + 'ogg': 'audio/ogg', + 'mp3': 'audio/mpeg', + 'mp4': 'audio/mp4', + 'm4a': 'audio/mp4', + 'wav': 'audio/wav', + 'webm': 'audio/webm', + 'mpeg': 'audio/mpeg', + 'mpga': 'audio/mpeg', + }; + return mimeTypes[ext || ''] || 'audio/ogg'; +}