diff --git a/.claudeignore b/.claudeignore new file mode 100644 index 0000000..b287cc3 --- /dev/null +++ b/.claudeignore @@ -0,0 +1,2 @@ +.env* +secrets/** \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..2625836 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,76 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Build & Run Commands + +```bash +npm run build # Compile main (CommonJS) + visualizer (ESM) +npm run build:main # Compile main only +npm run build:visualizer # Compile visualizer only + +npm run queue # Start queue processor (polls incoming/ every 1s) +npm run visualize # Start TUI dashboard (real-time event viewer) +npm run telegram # Start Telegram channel client +npm run discord # Start Discord channel client +npm run whatsapp # Start WhatsApp channel client + +npm run db:migrate # Apply dbmate migrations +npm run db:rollback # Revert last migration +``` + +There are no tests or linter configured in this project. + +## Architecture + +TinyClaw is a multi-agent AI assistant framework. Messages flow through a file-based queue: + +``` +Channel clients (telegram/discord/whatsapp) + → ~/.tinyclaw/queue/incoming/*.json + → queue-processor.ts (routes by @agent_id or @team_id prefix) + → invokeAgent() (spawns `claude` or `codex` CLI) + → ~/.tinyclaw/queue/outgoing/*.json + → Channel client sends response back +``` + +### Key Design Decisions + +- **File-based queue** (no Redis/RabbitMQ): Messages are JSON files moved atomically between `incoming/` → `processing/` → `outgoing/`. +- **Per-agent sequential, global parallel**: A `Map` chains promises by agent ID so messages to the same agent are sequential, but different agents process concurrently. +- **Two TypeScript builds**: Main code is CommonJS (`tsconfig.json`), the Ink/React TUI is ES modules (`tsconfig.visualizer.json`). The build writes `{"type":"module"}` into `dist/visualizer/package.json`. +- **Event-driven TUI**: The queue processor emits JSON event files to `~/.tinyclaw/events/`. The visualizer watches that directory with `fs.watch()` — it never queries the database. +- **Database is optional**: Uses dbmate for migrations if installed, otherwise falls back to `CREATE TABLE IF NOT EXISTS` via better-sqlite3. +- **Agent invocation is CLI-based**: Spawns `claude` (Anthropic) or `codex` (OpenAI) as child processes. Not using the API SDK directly for agent work — only for rate limit checks. +- **dotenv**: Loaded by channel clients and queue-processor. The `.env` file at project root holds `ANTHROPIC_API_KEY`, `TELEGRAM_BOT_TOKEN`, etc. + +### Source Layout + +- `src/queue-processor.ts` — Main loop: polls queue, routes messages, executes team chains (sequential handoff or parallel fan-out), writes responses. +- `src/lib/invoke.ts` — `invokeAgent()` spawns CLI processes. `checkAnthropicRateLimits()` makes a lightweight API call post-invocation to record rate limit headers. +- `src/lib/config.ts` — Reads `~/.tinyclaw/settings.json`. Exports all path constants (`TINYCLAW_HOME`, `QUEUE_INCOMING`, `DB_PATH`, etc.). `getAgents()` falls back to a single "default" agent from the legacy `models` section. +- `src/lib/routing.ts` — Parses `@agent` / `@team` prefixes. `extractTeammateMentions()` detects `[@agent: message]` tags (fan-out) or bare `@agent` mentions (single handoff) in agent responses. +- `src/lib/db.ts` — SQLite with WAL mode. Two tables: `token_usage` (per-invocation estimates) and `api_rate_limits` (Anthropic rate limit headers per agent). +- `src/lib/agent-setup.ts` — Creates agent working directories with template files (`.claude/`, `SOUL.md`, `AGENTS.md`, `heartbeat.md`). Updates `AGENTS.md` between `` markers. +- `src/lib/logging.ts` — `log()` writes to file + stdout. `emitEvent()` writes JSON files to events dir for the TUI. +- `src/channels/*.ts` — Bridge between messaging platforms and the file queue. Handle file uploads/downloads. No AI logic. +- `src/visualizer/team-visualizer.tsx` — React/Ink TUI. Displays agent status cards, chain flow arrows, rate limit bars, activity log. Consumes events via `fs.watch()`. +- `lib/*.sh` — Bash CLI: daemon management (tmux), agent/team CRUD, setup wizard, heartbeat cron. + +### Team Collaboration + +When a message is routed to a team's leader agent: +1. Leader responds. If the response mentions a teammate (`@agent_id`), the chain continues. +2. **Single mention** → sequential handoff (previous response becomes next agent's input). +3. **Multiple mentions** via `[@agent: message]` tags → parallel fan-out with `Promise.all()`. +4. Chain ends when an agent responds without mentioning a teammate. +5. All step responses are aggregated with `---` separators. + +### Providers + +- **Anthropic**: `claude --dangerously-skip-permissions --model {id} -c -p "{message}"`. Model aliases: `sonnet` → `claude-sonnet-4-5`, `opus` → `claude-opus-4-6`. +- **OpenAI**: `codex exec [resume --last] --model {id} --skip-git-repo-check --dangerously-bypass-approvals-and-sandbox --json {message}`. Parses JSONL output for `item.completed` events. + +### Configuration + +Central config: `~/.tinyclaw/settings.json` (or `.tinyclaw/settings.json` in project root if it exists there). Defines agents, teams, channels, workspace path. Types in `src/lib/types.ts`. diff --git a/db/migrations/20260213120000_create_token_usage.sql b/db/migrations/20260213120000_create_token_usage.sql new file mode 100644 index 0000000..27bd953 --- /dev/null +++ b/db/migrations/20260213120000_create_token_usage.sql @@ -0,0 +1,19 @@ +-- migrate:up +CREATE TABLE IF NOT EXISTS token_usage ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + agent_id TEXT NOT NULL, + provider TEXT NOT NULL, + model TEXT NOT NULL, + message_char_count INTEGER NOT NULL, + estimated_input_tokens INTEGER NOT NULL, + response_char_count INTEGER, + estimated_output_tokens INTEGER, + duration_ms INTEGER +); + +CREATE INDEX idx_token_usage_agent_id ON token_usage(agent_id); +CREATE INDEX idx_token_usage_created_at ON token_usage(created_at); + +-- migrate:down +DROP TABLE IF EXISTS token_usage; diff --git a/db/migrations/20260213120001_create_api_rate_limits.sql b/db/migrations/20260213120001_create_api_rate_limits.sql new file mode 100644 index 0000000..31b1523 --- /dev/null +++ b/db/migrations/20260213120001_create_api_rate_limits.sql @@ -0,0 +1,20 @@ +-- migrate:up +CREATE TABLE IF NOT EXISTS api_rate_limits ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + checked_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + agent_id TEXT NOT NULL DEFAULT '', + model TEXT NOT NULL, + requests_limit INTEGER, + requests_remaining INTEGER, + requests_reset TEXT, + input_tokens_limit INTEGER, + input_tokens_remaining INTEGER, + input_tokens_reset TEXT, + output_tokens_limit INTEGER, + output_tokens_remaining INTEGER, + output_tokens_reset TEXT, + inferred_tier TEXT +); + +-- migrate:down +DROP TABLE IF EXISTS api_rate_limits; diff --git a/lib/daemon.sh b/lib/daemon.sh index 0e2e5b5..e26b169 100644 --- a/lib/daemon.sh +++ b/lib/daemon.sh @@ -37,6 +37,12 @@ start_daemon() { npm run build fi + # Run dbmate migrations if available + if command -v dbmate &> /dev/null; then + local db_path="$TINYCLAW_HOME/tinyclaw.db" + DATABASE_URL="sqlite:$db_path" dbmate --migrations-dir "$SCRIPT_DIR/db/migrations" --no-dump-schema up 2>/dev/null || true + fi + # Load settings or run setup wizard load_settings local load_rc=$? diff --git a/package-lock.json b/package-lock.json index c1def9e..8300ca9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.5", "dependencies": { "@types/react": "^19.2.14", + "better-sqlite3": "^12.6.2", "discord.js": "^14.16.0", "dotenv": "^16.4.0", "ink": "^6.7.0", @@ -21,6 +22,7 @@ "whatsapp-web.js": "^1.34.6" }, "devDependencies": { + "@types/better-sqlite3": "^7.6.13", "@types/node": "^25.2.2", "@types/node-telegram-bot-api": "^0.64.13", "@types/qrcode-terminal": "^0.12.2", @@ -363,6 +365,16 @@ "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", "license": "MIT" }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/caseless": { "version": "0.12.5", "resolved": "https://registry.npmjs.org/@types/caseless/-/caseless-0.12.5.tgz", @@ -911,8 +923,7 @@ "url": "https://feross.org/support" } ], - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/basic-ftp": { "version": "5.1.0", @@ -932,6 +943,20 @@ "tweetnacl": "^0.14.3" } }, + "node_modules/better-sqlite3": { + "version": "12.6.2", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.6.2.tgz", + "integrity": "sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x" + } + }, "node_modules/big-integer": { "version": "1.6.52", "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", @@ -956,12 +981,20 @@ "node": "*" } }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, "node_modules/bl": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", "license": "MIT", - "optional": true, "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", @@ -1005,7 +1038,6 @@ } ], "license": "MIT", - "optional": true, "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" @@ -1126,6 +1158,12 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, "node_modules/chromium-bidi": { "version": "13.1.1", "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-13.1.1.tgz", @@ -1479,6 +1517,30 @@ } } }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/define-data-property": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", @@ -1536,6 +1598,15 @@ "node": ">=0.4.0" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/devtools-protocol": { "version": "0.0.1566079", "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1566079.tgz", @@ -1939,6 +2010,15 @@ "bare-events": "^2.7.0" } }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -2011,6 +2091,12 @@ "node": ">=0.10.0" } }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, "node_modules/fluent-ffmpeg": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/fluent-ffmpeg/-/fluent-ffmpeg-2.1.3.tgz", @@ -2074,8 +2160,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/fs-extra": { "version": "10.1.0", @@ -2276,6 +2361,12 @@ "assert-plus": "^1.0.0" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -2507,8 +2598,7 @@ "url": "https://feross.org/support" } ], - "license": "BSD-3-Clause", - "optional": true + "license": "BSD-3-Clause" }, "node_modules/import-fresh": { "version": "3.3.1", @@ -2556,6 +2646,12 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, "node_modules/ink": { "version": "6.7.0", "resolved": "https://registry.npmjs.org/ink/-/ink-6.7.0.tgz", @@ -3420,6 +3516,18 @@ "node": ">=6" } }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -3438,7 +3546,6 @@ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", "license": "MIT", - "optional": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -3462,12 +3569,24 @@ "mkdirp": "bin/cmd.js" } }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, "node_modules/netmask": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz", @@ -3477,6 +3596,18 @@ "node": ">= 0.4.0" } }, + "node_modules/node-abi": { + "version": "3.87.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.87.0.tgz", + "integrity": "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -3804,6 +3935,44 @@ "node": ">= 0.4" } }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/prebuild-install/node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", @@ -3943,6 +4112,21 @@ "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", "license": "MIT" }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, "node_modules/react": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", @@ -3972,7 +4156,6 @@ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", "license": "MIT", - "optional": true, "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", @@ -4480,6 +4663,51 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "license": "ISC" }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/slice-ansi": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", @@ -4646,7 +4874,6 @@ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", "license": "MIT", - "optional": true, "dependencies": { "safe-buffer": "~5.2.0" } @@ -4733,6 +4960,15 @@ "node": ">=8" } }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/tagged-tag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", @@ -4775,7 +5011,6 @@ "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", "license": "MIT", - "optional": true, "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", diff --git a/package.json b/package.json index eebef9f..9745d66 100644 --- a/package.json +++ b/package.json @@ -10,10 +10,13 @@ "discord": "node dist/channels/discord-client.js", "telegram": "node dist/channels/telegram-client.js", "queue": "node dist/queue-processor.js", - "visualize": "node dist/visualizer/team-visualizer.js" + "visualize": "node dist/visualizer/team-visualizer.js", + "db:migrate": "dbmate --migrations-dir db/migrations up", + "db:rollback": "dbmate --migrations-dir db/migrations down" }, "dependencies": { "@types/react": "^19.2.14", + "better-sqlite3": "^12.6.2", "discord.js": "^14.16.0", "dotenv": "^16.4.0", "ink": "^6.7.0", @@ -26,6 +29,7 @@ "whatsapp-web.js": "^1.34.6" }, "devDependencies": { + "@types/better-sqlite3": "^7.6.13", "@types/node": "^25.2.2", "@types/node-telegram-bot-api": "^0.64.13", "@types/qrcode-terminal": "^0.12.2", diff --git a/src/channels/telegram-client.ts b/src/channels/telegram-client.ts index 2af065e..8510ed8 100644 --- a/src/channels/telegram-client.ts +++ b/src/channels/telegram-client.ts @@ -14,6 +14,8 @@ import path from 'path'; import https from 'https'; import http from 'http'; import { ensureSenderPaired } from '../lib/pairing'; +import { transcribeAudio } from '../lib/transcribe'; +import { synthesizeSpeech } from '../lib/synthesize'; const SCRIPT_DIR = path.resolve(__dirname, '..', '..'); const _localTinyclaw = path.join(SCRIPT_DIR, '.tinyclaw'); @@ -35,6 +37,28 @@ const PAIRING_FILE = path.join(TINYCLAW_HOME, 'pairing.json'); } }); +// STT toggle state — persisted as a JSON array of chat ID strings +const STT_STATE_FILE = path.join(TINYCLAW_HOME, 'stt_enabled_chats.json'); +const sttEnabledChats = new Set( + (() => { + try { return JSON.parse(fs.readFileSync(STT_STATE_FILE, 'utf8')); } catch { return []; } + })() +); +function saveSttState(): void { + fs.writeFileSync(STT_STATE_FILE, JSON.stringify([...sttEnabledChats])); +} + +// TTS toggle state — persisted as a JSON array of chat ID strings +const TTS_STATE_FILE = path.join(TINYCLAW_HOME, 'tts_enabled_chats.json'); +const ttsEnabledChats = new Set( + (() => { + try { return JSON.parse(fs.readFileSync(TTS_STATE_FILE, 'utf8')); } catch { return []; } + })() +); +function saveTtsState(): void { + fs.writeFileSync(TTS_STATE_FILE, JSON.stringify([...ttsEnabledChats])); +} + // Validate bot token const TELEGRAM_BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN; if (!TELEGRAM_BOT_TOKEN || TELEGRAM_BOT_TOKEN === 'your_token_here') { @@ -42,6 +66,14 @@ if (!TELEGRAM_BOT_TOKEN || TELEGRAM_BOT_TOKEN === 'your_token_here') { process.exit(1); } +// Allowed Telegram user IDs whitelist (empty = allow all) +const ALLOWED_USER_IDS: Set = new Set( + (process.env.TELEGRAM_USERS_ENABLED || '') + .split(',') + .map(id => id.trim()) + .filter(id => id.length > 0) +); + interface PendingMessage { chatId: number; messageId: number; @@ -284,6 +316,16 @@ bot.on('message', async (msg: TelegramBot.Message) => { return; } + // Check user whitelist (if configured) + if (ALLOWED_USER_IDS.size > 0) { + const userId = msg.from?.id?.toString(); + if (!userId || !ALLOWED_USER_IDS.has(userId)) { + log('WARN', `Unauthorized user ${userId || 'unknown'} (${msg.from?.first_name || 'unknown'}) blocked`); + await bot.sendMessage(msg.chat.id, 'Access denied. You are not authorized to use this bot.'); + return; + } + } + // Determine message text and any media files let messageText = msg.text || msg.caption || ''; const downloadedFiles: string[] = []; @@ -310,14 +352,38 @@ bot.on('message', async (msg: TelegramBot.Message) => { if (msg.audio) { const ext = extFromMime(msg.audio.mime_type) || '.mp3'; const audioFileName = ('file_name' in msg.audio) ? (msg.audio as { file_name?: string }).file_name : undefined; - const filePath = await downloadTelegramFile(msg.audio.file_id, ext, queueMessageId, audioFileName); + const sttEnabled = sttEnabledChats.has(msg.chat.id.toString()); + const fileObj = sttEnabled ? await bot.getFile(msg.audio.file_id) : null; + const telegramUrl = fileObj?.file_path + ? `https://api.telegram.org/file/bot${TELEGRAM_BOT_TOKEN}/${fileObj.file_path}` + : null; + const [filePath, transcript] = await Promise.all([ + downloadTelegramFile(msg.audio.file_id, ext, queueMessageId, audioFileName), + telegramUrl ? transcribeAudio(telegramUrl) : Promise.resolve(null), + ]); if (filePath) downloadedFiles.push(filePath); + if (transcript) { + log('INFO', `Audio transcribed: ${transcript.substring(0, 80)}...`); + messageText = `[voice transcript: ${transcript}]${messageText ? '\n' + messageText : ''}`; + } } // Handle voice messages if (msg.voice) { - const filePath = await downloadTelegramFile(msg.voice.file_id, '.ogg', queueMessageId, `voice_${msg.message_id}.ogg`); + const sttEnabled = sttEnabledChats.has(msg.chat.id.toString()); + const fileObj = sttEnabled ? await bot.getFile(msg.voice.file_id) : null; + const telegramUrl = fileObj?.file_path + ? `https://api.telegram.org/file/bot${TELEGRAM_BOT_TOKEN}/${fileObj.file_path}` + : null; + const [filePath, transcript] = await Promise.all([ + downloadTelegramFile(msg.voice.file_id, '.ogg', queueMessageId, `voice_${msg.message_id}.ogg`), + telegramUrl ? transcribeAudio(telegramUrl) : Promise.resolve(null), + ]); if (filePath) downloadedFiles.push(filePath); + if (transcript) { + log('INFO', `Voice transcribed: ${transcript.substring(0, 80)}...`); + messageText = `[voice transcript: ${transcript}]${messageText ? '\n' + messageText : ''}`; + } } // Handle video messages @@ -367,6 +433,30 @@ bot.on('message', async (msg: TelegramBot.Message) => { return; } + // Help command + if (msg.text && msg.text.trim().match(/^[!/]help$/i)) { + const sttStatus = sttEnabledChats.has(msg.chat.id.toString()) ? 'on' : 'off'; + const ttsStatus = ttsEnabledChats.has(msg.chat.id.toString()) ? 'on' : 'off'; + const helpText = [ + 'Available commands:', + '', + '/agent — List configured agents', + '/team — List configured teams', + '/reset — Reset conversation history', + `/stton — Enable speech-to-text (currently ${sttStatus})`, + `/sttoff — Disable speech-to-text (currently ${sttStatus})`, + `/ttson — Enable text-to-speech (currently ${ttsStatus})`, + `/ttsoff — Disable text-to-speech (currently ${ttsStatus})`, + '/help — Show this message', + '', + 'Tip: Start your message with @agent_id or @team_id to route it.', + ].join('\n'); + await bot.sendMessage(msg.chat.id, helpText, { + reply_to_message_id: msg.message_id, + }); + return; + } + // Check for agent list command if (msg.text && msg.text.trim().match(/^[!/]agent$/i)) { log('INFO', 'Agent list command received'); @@ -425,6 +515,48 @@ bot.on('message', async (msg: TelegramBot.Message) => { return; } + // STT toggle commands + if (msg.text && msg.text.trim().match(/^[!/]stton$/i)) { + sttEnabledChats.add(msg.chat.id.toString()); + saveSttState(); + log('INFO', `STT enabled for chat ${msg.chat.id}`); + await bot.sendMessage(msg.chat.id, 'Speech-to-text enabled. Voice and audio messages will be transcribed.', { + reply_to_message_id: msg.message_id, + }); + return; + } + + if (msg.text && msg.text.trim().match(/^[!/]sttoff$/i)) { + sttEnabledChats.delete(msg.chat.id.toString()); + saveSttState(); + log('INFO', `STT disabled for chat ${msg.chat.id}`); + await bot.sendMessage(msg.chat.id, 'Speech-to-text disabled.', { + reply_to_message_id: msg.message_id, + }); + return; + } + + // TTS toggle commands + if (msg.text && msg.text.trim().match(/^[!/]ttson$/i)) { + ttsEnabledChats.add(msg.chat.id.toString()); + saveTtsState(); + log('INFO', `TTS enabled for chat ${msg.chat.id}`); + await bot.sendMessage(msg.chat.id, 'Text-to-speech enabled. Responses will include a voice message.', { + reply_to_message_id: msg.message_id, + }); + return; + } + + if (msg.text && msg.text.trim().match(/^[!/]ttsoff$/i)) { + ttsEnabledChats.delete(msg.chat.id.toString()); + saveTtsState(); + log('INFO', `TTS disabled for chat ${msg.chat.id}`); + await bot.sendMessage(msg.chat.id, 'Text-to-speech disabled.', { + reply_to_message_id: msg.message_id, + }); + return; + } + // Show typing indicator await bot.sendChatAction(msg.chat.id, 'typing'); @@ -517,6 +649,23 @@ async function checkOutgoingQueue(): Promise { } } + // TTS: synthesize and send voice message if enabled + if (responseText && pending && ttsEnabledChats.has(pending.chatId.toString())) { + try { + const ttsFile = path.join(FILES_DIR, `tts_${messageId}.mp3`); + const audioPath = await synthesizeSpeech(responseText, ttsFile); + if (audioPath) { + await bot.sendVoice(pending.chatId, audioPath, { + reply_to_message_id: pending.messageId, + }); + log('INFO', `Sent TTS voice message for ${messageId}`); + fs.unlink(audioPath, () => {}); + } + } catch (ttsErr) { + log('ERROR', `TTS failed: ${(ttsErr as Error).message}`); + } + } + // Split message if needed (Telegram 4096 char limit) if (responseText) { const chunks = splitMessage(responseText); @@ -583,4 +732,9 @@ process.on('SIGTERM', () => { }); // Start +if (ALLOWED_USER_IDS.size > 0) { + log('INFO', `User whitelist active: ${ALLOWED_USER_IDS.size} user(s) allowed`); +} else { + log('WARN', 'No TELEGRAM_USERS_ENABLED set — all users can access the bot'); +} log('INFO', 'Starting Telegram client...'); diff --git a/src/lib/config.ts b/src/lib/config.ts index de370c0..0913d08 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -17,6 +17,7 @@ export const SETTINGS_FILE = path.join(TINYCLAW_HOME, 'settings.json'); export const EVENTS_DIR = path.join(TINYCLAW_HOME, 'events'); export const CHATS_DIR = path.join(TINYCLAW_HOME, 'chats'); export const FILES_DIR = path.join(TINYCLAW_HOME, 'files'); +export const DB_PATH = path.join(TINYCLAW_HOME, 'tinyclaw.db'); export function getSettings(): Settings { try { diff --git a/src/lib/db.ts b/src/lib/db.ts new file mode 100644 index 0000000..a5f7091 --- /dev/null +++ b/src/lib/db.ts @@ -0,0 +1,214 @@ +import fs from 'fs'; +import path from 'path'; +import { execSync } from 'child_process'; +import Database from 'better-sqlite3'; +import { DB_PATH, SCRIPT_DIR } from './config'; +import { log } from './logging'; + +let db: Database.Database | null = null; + +/** + * Check whether dbmate is installed on the system. + */ +function isDbmateInstalled(): boolean { + try { + execSync('command -v dbmate', { stdio: 'ignore' }); + return true; + } catch { + return false; + } +} + +/** + * Run dbmate migrations against the database. + */ +function runDbmateMigrations(): void { + const migrationsDir = path.join(SCRIPT_DIR, 'db', 'migrations'); + if (!fs.existsSync(migrationsDir)) { + log('WARN', `Migrations directory not found: ${migrationsDir}`); + return; + } + + try { + execSync( + `DATABASE_URL="sqlite:${DB_PATH}" dbmate --migrations-dir "${migrationsDir}" --no-dump-schema up`, + { stdio: 'ignore' } + ); + log('INFO', 'Database migrations applied via dbmate'); + } catch (e) { + log('WARN', `dbmate migration failed: ${(e as Error).message}`); + } +} + +/** + * Create the table and indexes directly via SQL (fallback when dbmate is not available). + */ +function createSchemaFallback(database: Database.Database): void { + database.exec(` + CREATE TABLE IF NOT EXISTS token_usage ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + agent_id TEXT NOT NULL, + provider TEXT NOT NULL, + model TEXT NOT NULL, + message_char_count INTEGER NOT NULL, + estimated_input_tokens INTEGER NOT NULL, + response_char_count INTEGER, + estimated_output_tokens INTEGER, + duration_ms INTEGER + ); + `); + database.exec(`CREATE INDEX IF NOT EXISTS idx_token_usage_agent_id ON token_usage(agent_id);`); + database.exec(`CREATE INDEX IF NOT EXISTS idx_token_usage_created_at ON token_usage(created_at);`); + + database.exec(` + CREATE TABLE IF NOT EXISTS api_rate_limits ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + checked_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + agent_id TEXT NOT NULL DEFAULT '', + model TEXT NOT NULL, + requests_limit INTEGER, + requests_remaining INTEGER, + requests_reset TEXT, + input_tokens_limit INTEGER, + input_tokens_remaining INTEGER, + input_tokens_reset TEXT, + output_tokens_limit INTEGER, + output_tokens_remaining INTEGER, + output_tokens_reset TEXT, + inferred_tier TEXT + ); + `); + + log('INFO', 'Database schema created via fallback (dbmate not available)'); +} + +export function getDb(): Database.Database { + if (db) return db; + + const isNew = !fs.existsSync(DB_PATH); + + // Ensure parent directory exists + const dbDir = path.dirname(DB_PATH); + if (!fs.existsSync(dbDir)) { + fs.mkdirSync(dbDir, { recursive: true }); + } + + if (isNew) { + log('INFO', `Creating new database: ${DB_PATH}`); + + if (isDbmateInstalled()) { + // Let dbmate create the file and apply migrations + runDbmateMigrations(); + + // Open the file dbmate just created (or create it if migration had no effect) + db = new Database(DB_PATH); + } else { + // Create the file via better-sqlite3 and apply schema manually + db = new Database(DB_PATH); + createSchemaFallback(db); + } + } else { + // DB already exists — open it, then try to apply any pending migrations + db = new Database(DB_PATH); + + if (isDbmateInstalled()) { + runDbmateMigrations(); + } else { + // Ensure schema exists (idempotent thanks to IF NOT EXISTS) + createSchemaFallback(db); + } + } + + db.pragma('journal_mode = WAL'); + return db; +} + +export function estimateTokens(text: string): number { + return Math.ceil(text.length / 4); +} + +export interface TokenUsageRecord { + agentId: string; + provider: string; + model: string; + messageCharCount: number; + estimatedInputTokens: number; +} + +export function insertTokenUsage(record: TokenUsageRecord): number { + const stmt = getDb().prepare(` + INSERT INTO token_usage (agent_id, provider, model, message_char_count, estimated_input_tokens) + VALUES (?, ?, ?, ?, ?) + `); + const result = stmt.run( + record.agentId, + record.provider, + record.model, + record.messageCharCount, + record.estimatedInputTokens + ); + return Number(result.lastInsertRowid); +} + +export function updateTokenUsageResponse( + rowId: number, + responseCharCount: number, + estimatedOutputTokens: number, + durationMs: number +): void { + const stmt = getDb().prepare(` + UPDATE token_usage + SET response_char_count = ?, estimated_output_tokens = ?, duration_ms = ? + WHERE id = ? + `); + stmt.run(responseCharCount, estimatedOutputTokens, durationMs, rowId); +} + +export interface RateLimitRecord { + agentId: string; + model: string; + requestsLimit: number | null; + requestsRemaining: number | null; + requestsReset: string | null; + inputTokensLimit: number | null; + inputTokensRemaining: number | null; + inputTokensReset: string | null; + outputTokensLimit: number | null; + outputTokensRemaining: number | null; + outputTokensReset: string | null; + inferredTier: string | null; +} + +export function insertRateLimitCheck(record: RateLimitRecord): number { + const stmt = getDb().prepare(` + INSERT INTO api_rate_limits ( + agent_id, model, requests_limit, requests_remaining, requests_reset, + input_tokens_limit, input_tokens_remaining, input_tokens_reset, + output_tokens_limit, output_tokens_remaining, output_tokens_reset, + inferred_tier + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + const result = stmt.run( + record.agentId, + record.model, + record.requestsLimit, + record.requestsRemaining, + record.requestsReset, + record.inputTokensLimit, + record.inputTokensRemaining, + record.inputTokensReset, + record.outputTokensLimit, + record.outputTokensRemaining, + record.outputTokensReset, + record.inferredTier + ); + return Number(result.lastInsertRowid); +} + +export function closeDb(): void { + if (db) { + db.close(); + db = null; + } +} diff --git a/src/lib/invoke.ts b/src/lib/invoke.ts index 471eaf2..3c08575 100644 --- a/src/lib/invoke.ts +++ b/src/lib/invoke.ts @@ -1,16 +1,19 @@ import { spawn } from 'child_process'; +import https from 'https'; import fs from 'fs'; import path from 'path'; import { AgentConfig, TeamConfig } from './types'; import { SCRIPT_DIR, resolveClaudeModel, resolveCodexModel } from './config'; -import { log } from './logging'; +import { log, emitEvent } from './logging'; import { ensureAgentDirectory, updateAgentTeammates } from './agent-setup'; +import { estimateTokens, insertTokenUsage, updateTokenUsageResponse, insertRateLimitCheck } from './db'; -export async function runCommand(command: string, args: string[], cwd?: string): Promise { +export async function runCommand(command: string, args: string[], cwd?: string, env?: Record): Promise { return new Promise((resolve, reject) => { const child = spawn(command, args, { cwd: cwd || SCRIPT_DIR, stdio: ['ignore', 'pipe', 'pipe'], + env: env ? { ...process.env, ...env } : undefined, }); let stdout = ''; @@ -43,6 +46,103 @@ export async function runCommand(command: string, args: string[], cwd?: string): }); } +function inferTier(requestsLimit: number | null): string | null { + if (requestsLimit === null) return null; + if (requestsLimit <= 50) return 'Tier 1'; + if (requestsLimit <= 1000) return 'Tier 2'; + if (requestsLimit <= 2000) return 'Tier 3'; + if (requestsLimit <= 4000) return 'Tier 4'; + return `Unknown (RPM=${requestsLimit})`; +} + +function parseIntOrNull(value: string | undefined): number | null { + if (!value) return null; + const n = parseInt(value, 10); + return isNaN(n) ? null : n; +} + +export async function checkAnthropicRateLimits(apiKey: string, model: string, agentId: string): Promise { + const body = JSON.stringify({ + model, + max_tokens: 1, + messages: [{ role: 'user', content: 'hi' }], + }); + + const headers = await new Promise>((resolve, reject) => { + const req = https.request( + { + hostname: 'api.anthropic.com', + path: '/v1/messages', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': apiKey, + 'anthropic-version': '2023-06-01', + }, + }, + (res) => { + // Consume the body so the socket is freed + res.resume(); + const h: Record = {}; + for (const [key, val] of Object.entries(res.headers)) { + if (key.startsWith('anthropic-ratelimit-') && typeof val === 'string') { + h[key] = val; + } + } + resolve(h); + } + ); + + req.on('error', reject); + req.write(body); + req.end(); + }); + + const requestsLimit = parseIntOrNull(headers['anthropic-ratelimit-requests-limit']); + const requestsRemaining = parseIntOrNull(headers['anthropic-ratelimit-requests-remaining']); + const requestsReset = headers['anthropic-ratelimit-requests-reset'] || null; + const inputTokensLimit = parseIntOrNull(headers['anthropic-ratelimit-input-tokens-limit']); + const inputTokensRemaining = parseIntOrNull(headers['anthropic-ratelimit-input-tokens-remaining']); + const inputTokensReset = headers['anthropic-ratelimit-input-tokens-reset'] || null; + const outputTokensLimit = parseIntOrNull(headers['anthropic-ratelimit-output-tokens-limit']); + const outputTokensRemaining = parseIntOrNull(headers['anthropic-ratelimit-output-tokens-remaining']); + const outputTokensReset = headers['anthropic-ratelimit-output-tokens-reset'] || null; + + const inferredTier = inferTier(requestsLimit); + + insertRateLimitCheck({ + agentId, + model, + requestsLimit, + requestsRemaining, + requestsReset, + inputTokensLimit, + inputTokensRemaining, + inputTokensReset, + outputTokensLimit, + outputTokensRemaining, + outputTokensReset, + inferredTier, + }); + + log('INFO', `Rate limits checked — agent: ${agentId}, tier: ${inferredTier ?? 'unknown'}, RPM: ${requestsLimit ?? '?'}`); + + emitEvent('rate_limits_updated', { + agentId, + model, + inferredTier, + requestsLimit, + requestsRemaining, + requestsReset, + inputTokensLimit, + inputTokensRemaining, + inputTokensReset, + outputTokensLimit, + outputTokensRemaining, + outputTokensReset, + }); +} + /** * Invoke a single agent with a message. Contains all Claude/Codex invocation logic. * Returns the raw response text. @@ -76,6 +176,22 @@ export async function invokeAgent( const provider = agent.provider || 'anthropic'; + // Token usage tracking — pre-invocation + let usageRowId: number | null = null; + const startTime = Date.now(); + try { + const inputTokens = estimateTokens(message); + usageRowId = insertTokenUsage({ + agentId, + provider, + model: agent.model || '', + messageCharCount: message.length, + estimatedInputTokens: inputTokens, + }); + } catch (e) { + log('WARN', `Token tracking insert failed: ${(e as Error).message}`); + } + if (provider === 'openai') { log('INFO', `Using Codex CLI (agent: ${agentId})`); @@ -95,7 +211,7 @@ export async function invokeAgent( } codexArgs.push('--skip-git-repo-check', '--dangerously-bypass-approvals-and-sandbox', '--json', message); - const codexOutput = await runCommand('codex', codexArgs, workingDir); + const codexOutput = await runCommand('codex', codexArgs, workingDir, agent.env); // Parse JSONL output and extract final agent_message let response = ''; @@ -111,7 +227,19 @@ export async function invokeAgent( } } - return response || 'Sorry, I could not generate a response from Codex.'; + const finalResponse = response || 'Sorry, I could not generate a response from Codex.'; + + // Token usage tracking — post-invocation + if (usageRowId !== null) { + try { + const durationMs = Date.now() - startTime; + updateTokenUsageResponse(usageRowId, finalResponse.length, estimateTokens(finalResponse), durationMs); + } catch (e) { + log('WARN', `Token tracking update failed: ${(e as Error).message}`); + } + } + + return finalResponse; } else { // Default to Claude (Anthropic) log('INFO', `Using Claude provider (agent: ${agentId})`); @@ -132,6 +260,30 @@ export async function invokeAgent( } claudeArgs.push('-p', message); - return await runCommand('claude', claudeArgs, workingDir); + const claudeResponse = await runCommand('claude', claudeArgs, workingDir, agent.env); + + // Token usage tracking — post-invocation + if (usageRowId !== null) { + try { + const durationMs = Date.now() - startTime; + updateTokenUsageResponse(usageRowId, claudeResponse.length, estimateTokens(claudeResponse), durationMs); + } catch (e) { + log('WARN', `Token tracking update failed: ${(e as Error).message}`); + } + } + + // Check API rate limits after invocation (only when no custom env is set) + if (!agent.env) { + const apiKey = process.env.ANTHROPIC_API_KEY; + if (apiKey) { + try { + await checkAnthropicRateLimits(apiKey, modelId || 'claude-sonnet-4-20250514', agentId); + } catch (e) { + log('WARN', `Rate limit check failed: ${(e as Error).message}`); + } + } + } + + return claudeResponse; } } diff --git a/src/lib/synthesize.ts b/src/lib/synthesize.ts new file mode 100644 index 0000000..fadaecb --- /dev/null +++ b/src/lib/synthesize.ts @@ -0,0 +1,130 @@ +import https from 'https'; +import fs from 'fs'; + +const MAX_POLL_ATTEMPTS = 10; +const POLL_INTERVAL_MS = 3000; + +/** Make an HTTPS request and return the response body. */ +function httpsRequest(options: https.RequestOptions, body?: string): Promise { + return new Promise((resolve, reject) => { + const req = https.request(options, (res) => { + let data = ""; + res.on("data", (chunk: Buffer) => { data += chunk; }); + res.on("end", () => { + if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) { + resolve(data); + } else { + reject(new Error(`HTTP ${res.statusCode}: ${data}`)); + } + }); + }); + req.on("error", reject); + if (body) req.write(body); + req.end(); + }); +} + +/** Download a file from a URL, following one redirect. */ +function downloadUrl(url: string, destPath: string): Promise { + return new Promise((resolve, reject) => { + const file = fs.createWriteStream(destPath); + https.get(url, (res) => { + if ((res.statusCode === 301 || res.statusCode === 302) && res.headers.location) { + file.close(); + fs.unlinkSync(destPath); + const file2 = fs.createWriteStream(destPath); + https.get(res.headers.location, (res2) => { + res2.pipe(file2); + file2.on("finish", () => { file2.close(); resolve(); }); + }).on("error", reject); + return; + } + res.pipe(file); + file.on("finish", () => { file.close(); resolve(); }); + }).on("error", (err) => { + fs.unlink(destPath, () => {}); + reject(err); + }); + }); +} + +/** + * Synthesize text to speech using Replicate's MiniMax Speech-02-Turbo model. + * Creates a prediction, polls until succeeded/failed, downloads the audio. + * Returns the local file path of the generated MP3, or null on failure. + */ +export async function synthesizeSpeech(text: string, destPath: string): Promise { + const token = process.env.REPLICATE_API_TOKEN; + if (!token) return null; + + // Speech-02-Turbo has a 10k character limit + const trimmed = text.length > 10000 ? text.substring(0, 10000) : text; + + try { + // 1. Create the prediction + const body = JSON.stringify({ + input: { + text: trimmed, + pitch: 0, + speed: 1, + volume: 1, + bitrate: 128000, + channel: "mono", + emotion: "auto", + voice_id: "Wise_Woman", + sample_rate: 32000, + audio_format: "mp3", + language_boost: "None", + subtitle_enable: false, + english_normalization: true, + }, + }); + + const createResult = await httpsRequest( + { + hostname: "api.replicate.com", + path: "/v1/models/minimax/speech-02-turbo/predictions", + method: "POST", + headers: { + "Authorization": `Bearer ${token}`, + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(body), + }, + }, + body, + ); + + let prediction = JSON.parse(createResult); + + // 2. Poll until terminal state + for (let i = 0; i < MAX_POLL_ATTEMPTS && prediction.status !== "succeeded" && prediction.status !== "failed" && prediction.status !== "canceled"; i++) { + await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS)); + + const pollUrl: string | undefined = prediction.urls?.get; + if (!pollUrl) break; + + const parsed = new URL(pollUrl); + const pollResult = await httpsRequest({ + hostname: parsed.hostname, + path: parsed.pathname, + method: "GET", + headers: { + "Authorization": `Bearer ${token}`, + }, + }); + prediction = JSON.parse(pollResult); + } + + if (prediction.status !== "succeeded") return null; + + const audioUrl: string | undefined = prediction.output; + if (!audioUrl) return null; + + // 3. Download the audio file + await downloadUrl(audioUrl, destPath); + return destPath; + } catch { + try { fs.unlinkSync(destPath); } catch {} + return null; + } +} diff --git a/src/lib/transcribe.ts b/src/lib/transcribe.ts new file mode 100644 index 0000000..5f03b44 --- /dev/null +++ b/src/lib/transcribe.ts @@ -0,0 +1,58 @@ +import https from 'https'; + +/** + * Transcribe audio using Replicate's incredibly-fast-whisper model. + * Returns the transcribed text, or null on failure (non-blocking). + */ +export async function transcribeAudio(audioUrl: string): Promise { + const token = process.env.REPLICATE_API_TOKEN; + if (!token) return null; + + try { + const body = JSON.stringify({ + version: "3ab86df6c8f54c11309d4d1f930ac292bad43ace52d10c80d87eb258b3c9f79c", + input: { + task: "transcribe", + audio: audioUrl, + language: "None", + batch_size: 64, + }, + }); + + const result = await new Promise((resolve, reject) => { + const req = https.request( + { + hostname: "api.replicate.com", + path: "/v1/predictions", + method: "POST", + headers: { + "Authorization": `Bearer ${token}`, + "Content-Type": "application/json", + "Prefer": "wait", + "Content-Length": Buffer.byteLength(body), + }, + }, + (res) => { + let data = ""; + res.on("data", (chunk: Buffer) => { data += chunk; }); + res.on("end", () => { + if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) { + resolve(data); + } else { + reject(new Error(`Replicate API ${res.statusCode}: ${data}`)); + } + }); + }, + ); + req.on("error", reject); + req.write(body); + req.end(); + }); + + const parsed = JSON.parse(result); + const text = parsed?.output?.text; + return typeof text === "string" && text.trim().length > 0 ? text.trim() : null; + } catch { + return null; + } +} diff --git a/src/lib/types.ts b/src/lib/types.ts index f0b2e40..1ce5071 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -3,6 +3,7 @@ export interface AgentConfig { provider: string; // 'anthropic' or 'openai' model: string; // e.g. 'sonnet', 'opus', 'gpt-5.3-codex' working_directory: string; + env?: Record; } export interface TeamConfig { diff --git a/src/queue-processor.ts b/src/queue-processor.ts index e89b989..e73f191 100644 --- a/src/queue-processor.ts +++ b/src/queue-processor.ts @@ -14,6 +14,7 @@ * - Conversations complete when all branches resolve (no more pending mentions) */ +import 'dotenv/config'; import fs from 'fs'; import path from 'path'; import { MessageData, ResponseData, QueueFile, ChainStep, Conversation, TeamConfig } from './lib/types'; @@ -25,6 +26,7 @@ import { import { log, emitEvent } from './lib/logging'; import { parseAgentRouting, findTeamForAgent, getAgentResetFlag, extractTeammateMentions } from './lib/routing'; import { invokeAgent } from './lib/invoke'; +import { closeDb } from './lib/db'; // Ensure directories exist [QUEUE_INCOMING, QUEUE_OUTGOING, QUEUE_PROCESSING, FILES_DIR, path.dirname(LOG_FILE)].forEach(dir => { @@ -592,10 +594,12 @@ setInterval(processQueue, 1000); // Graceful shutdown process.on('SIGINT', () => { log('INFO', 'Shutting down queue processor...'); + closeDb(); process.exit(0); }); process.on('SIGTERM', () => { log('INFO', 'Shutting down queue processor...'); + closeDb(); process.exit(0); }); diff --git a/src/visualizer/team-visualizer.tsx b/src/visualizer/team-visualizer.tsx index 8df6aab..fe1f8d9 100644 --- a/src/visualizer/team-visualizer.tsx +++ b/src/visualizer/team-visualizer.tsx @@ -73,6 +73,21 @@ interface LogEntry { color: string; } +interface RateLimitInfo { + model: string; + inferredTier: string | null; + requestsLimit: number | null; + requestsRemaining: number | null; + requestsReset: string | null; + inputTokensLimit: number | null; + inputTokensRemaining: number | null; + inputTokensReset: string | null; + outputTokensLimit: number | null; + outputTokensRemaining: number | null; + outputTokensReset: string | null; + updatedAt: number; +} + // ─── Settings loader ──────────────────────────────────────────────────────── function loadSettings(): { teams: Record; agents: Record } { @@ -248,6 +263,84 @@ function StatusBar({ queueDepth, totalProcessed, processorAlive }: { queueDepth: ); } +function formatTokenCount(n: number | null): string { + if (n === null) return '?'; + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(0)}k`; + return String(n); +} + +function RateLimitBar({ label, remaining, limit, color }: { label: string; remaining: number | null; limit: number | null; color: string }) { + if (limit === null) return null; + const pct = remaining !== null ? Math.round((remaining / limit) * 100) : 0; + const barWidth = 12; + const filled = remaining !== null ? Math.round((remaining / limit) * barWidth) : 0; + const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(barWidth - filled); + const barColor = pct > 50 ? 'green' : pct > 20 ? 'yellow' : 'red'; + return ( + + {label} + {bar} + {formatTokenCount(remaining)}/{formatTokenCount(limit)} + + ); +} + +function RateLimitsRow({ rateLimits, agents }: { rateLimits: Record; agents: Record }) { + // Show any agent that has rate limit data (from events), plus known Claude agents + const agentsWithData = Object.keys(rateLimits); + const claudeAgentIds = Object.keys(agents).filter(id => agents[id].provider === 'anthropic'); + // Merge: agents with data + known Claude agents without data yet + const allIds = Array.from(new Set([...agentsWithData, ...claudeAgentIds])); + + if (allIds.length === 0) return null; + + return ( + + {'\u{1F4CA}'} API Rate Limits + {'\u2500'.repeat(72)} + {agentsWithData.length === 0 ? ( + No rate limit data yet (waiting for first Claude invocation) + ) : ( + + {agentsWithData.map(agentId => { + const rl = rateLimits[agentId]; + const tierColor = rl.inferredTier?.includes('4') ? 'green' + : rl.inferredTier?.includes('3') ? 'cyan' + : rl.inferredTier?.includes('2') ? 'yellow' + : 'red'; + return ( + + + @{agentId} + {'\u2502'} + {rl.inferredTier ?? '?'} + + {rl.model} + + + + {timeAgo(rl.updatedAt)} + + ); + })} + + )} + {/* Show known Claude agents that have no data yet */} + {claudeAgentIds.filter(id => !rateLimits[id]).length > 0 && ( + Waiting: {claudeAgentIds.filter(id => !rateLimits[id]).map(id => '@' + id).join(', ')} + )} + + ); +} + // ─── Main App ─────────────────────────────────────────────────────────────── function App({ filterTeamId }: { filterTeamId: string | null }) { @@ -260,6 +353,7 @@ function App({ filterTeamId }: { filterTeamId: string | null }) { const [queueDepth, setQueueDepth] = useState(0); const [processorAlive, setProcessorAlive] = useState(false); const [startTime] = useState(Date.now()); + const [rateLimits, setRateLimits] = useState>({}); const [, setTick] = useState(0); // Force re-render every second for animated dots and uptime @@ -400,6 +494,29 @@ function App({ filterTeamId }: { filterTeamId: string | null }) { break; } + case 'rate_limits_updated': { + const aid = String(event.agentId); + setRateLimits(prev => ({ + ...prev, + [aid]: { + model: String(event.model ?? ''), + inferredTier: event.inferredTier ? String(event.inferredTier) : null, + requestsLimit: typeof event.requestsLimit === 'number' ? event.requestsLimit : null, + requestsRemaining: typeof event.requestsRemaining === 'number' ? event.requestsRemaining : null, + requestsReset: event.requestsReset ? String(event.requestsReset) : null, + inputTokensLimit: typeof event.inputTokensLimit === 'number' ? event.inputTokensLimit : null, + inputTokensRemaining: typeof event.inputTokensRemaining === 'number' ? event.inputTokensRemaining : null, + inputTokensReset: event.inputTokensReset ? String(event.inputTokensReset) : null, + outputTokensLimit: typeof event.outputTokensLimit === 'number' ? event.outputTokensLimit : null, + outputTokensRemaining: typeof event.outputTokensRemaining === 'number' ? event.outputTokensRemaining : null, + outputTokensReset: event.outputTokensReset ? String(event.outputTokensReset) : null, + updatedAt: event.timestamp, + }, + })); + addLog('\u{1F4CA}', `@${aid} rate limits: ${event.inferredTier ?? '?'} (RPM ${event.requestsLimit ?? '?'})`, 'cyan'); + break; + } + case 'response_ready': setTotalProcessed(prev => prev + 1); // Reset agent states to idle after a short delay via next tick @@ -547,6 +664,9 @@ function App({ filterTeamId }: { filterTeamId: string | null }) { )} + {/* API rate limits per agent */} + + {/* Activity log */}