diff --git a/docs/configuration.md b/docs/configuration.md index dc06cb2..3ced4e9 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -105,6 +105,42 @@ attachments: | `server.mode` | `'api'` \| `'docker'` | Connection mode (legacy aliases: `'cloud'`, `'selfhosted'`) | | `server.apiKey` | string | API key for Letta API | | `server.baseUrl` | string | URL for Docker/custom server (e.g., `http://localhost:8283`) | +| `server.logLevel` | `'fatal'` \| `'error'` \| `'warn'` \| `'info'` \| `'debug'` \| `'trace'` | Log verbosity. Default: `info`. Env vars `LOG_LEVEL` / `LETTABOT_LOG_LEVEL` override. | + +### Logging + +LettaBot uses structured logging via [pino](https://getpino.io). In local dev, output is human-readable with colored timestamps and `[Module]` prefixes. In production (Railway/Docker), set `LOG_FORMAT=json` for structured JSON output that works with log aggregation tools. + +**Log levels** -- set in config or via environment variable (env takes precedence): + +```yaml +server: + logLevel: info # fatal | error | warn | info | debug | trace +``` + +```bash +LOG_LEVEL=debug npm run dev # verbose output for debugging +LOG_FORMAT=json npm start # structured JSON for production +``` + +**Debug logging** -- to enable verbose per-channel debug output (replaces the old `DEBUG_WHATSAPP=1` flag): + +```bash +LOG_LEVEL=debug npm run dev +``` + +**Output formats:** + +Local dev (default) -- single-line colored output: +``` +[23:22:37] INFO: [Bot] Session subprocess ready +[23:22:37] WARN: [WhatsApp] Socket not available for access control +``` + +Production (`LOG_FORMAT=json`) -- structured JSON: +```json +{"level":30,"time":1234567890,"module":"Bot","msg":"Session subprocess ready"} +``` ### Docker Server Mode @@ -747,5 +783,8 @@ Environment variables override config file values: | `OPENAI_API_KEY` | `transcription.apiKey` | | `GMAIL_ACCOUNT` | `polling.gmail.account` (comma-separated list allowed) | | `POLLING_INTERVAL_MS` | `polling.intervalMs` | +| `LOG_LEVEL` | `server.logLevel` (fatal/error/warn/info/debug/trace). Overrides config. | +| `LETTABOT_LOG_LEVEL` | Alias for `LOG_LEVEL` | +| `LOG_FORMAT` | Set to `json` for structured JSON output (recommended for Railway/Docker) | See [SKILL.md](../SKILL.md) for complete environment variable reference. diff --git a/docs/railway-deploy.md b/docs/railway-deploy.md index 73924ce..6d385f2 100644 --- a/docs/railway-deploy.md +++ b/docs/railway-deploy.md @@ -53,6 +53,8 @@ SLACK_APP_TOKEN=xapp-... | `HEARTBEAT_TARGET` | - | Target chat (e.g., `telegram:123456`) | | `OPENAI_API_KEY` | - | For voice message transcription | | `API_HOST` | `0.0.0.0` on Railway | Optional override for API bind address | +| `LOG_LEVEL` | `info` | Log verbosity (fatal/error/warn/info/debug/trace) | +| `LOG_FORMAT` | - | Set to `json` for structured JSON output (recommended for Railway) | ## How It Works @@ -158,6 +160,10 @@ At startup, LettaBot prints a `[Railway] Preflight check` block with: - `WARN` lines for risky settings (for example missing volume) - `FAIL` lines for blocking issues (for example missing `LETTA_API_KEY`) +### Reading logs + +Set `LOG_FORMAT=json` for structured output compatible with Railway's log search and filtering. Use `LOG_LEVEL=debug` to enable verbose channel-level debug output when diagnosing issues. + ### Data not persisting If data is lost between restarts: diff --git a/package-lock.json b/package-lock.json index 57047de..f9e0920 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "node-schedule": "^2.1.1", "open": "^11.0.0", "openai": "^6.17.0", + "pino": "^10.3.1", "qrcode-terminal": "^0.12.0", "telegramify-markdown": "^1.0.0", "tsx": "^4.21.0", @@ -42,6 +43,7 @@ }, "devDependencies": { "@types/update-notifier": "^6.0.8", + "pino-pretty": "^13.1.3", "vitest": "^4.0.18" }, "engines": { @@ -1411,8 +1413,7 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", @@ -2607,6 +2608,49 @@ "license": "BSD-3-Clause", "optional": true }, + "node_modules/@whiskeysockets/baileys/node_modules/pino": { + "version": "9.14.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz", + "integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==", + "license": "MIT", + "optional": true, + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^2.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^3.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/@whiskeysockets/baileys/node_modules/pino-abstract-transport": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", + "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==", + "license": "MIT", + "optional": true, + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/@whiskeysockets/baileys/node_modules/thread-stream": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz", + "integrity": "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==", + "license": "MIT", + "optional": true, + "dependencies": { + "real-require": "^0.2.0" + } + }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -2771,7 +2815,6 @@ "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", "license": "MIT", - "optional": true, "engines": { "node": ">=8.0.0" } @@ -3237,6 +3280,13 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true, + "license": "MIT" + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -3376,6 +3426,16 @@ "node": ">= 12" } }, + "node_modules/dateformat": { + "version": "4.6.3", + "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz", + "integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -3623,6 +3683,16 @@ "node": ">= 0.8" } }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/environment": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", @@ -3887,6 +3957,13 @@ "node": ">=0.10.0" } }, + "node_modules/fast-copy": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.0.2.tgz", + "integrity": "sha512-ybA6PDXIXOXivLJK/z9e+Otk7ve13I4ckBvGO5I2RRmBU1gMHLVDJYEuJYhGwez7YNlYji2M2DvVU+a9mSFDlw==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3894,6 +3971,13 @@ "license": "MIT", "optional": true }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, "node_modules/fd-slicer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", @@ -4440,6 +4524,13 @@ "node": ">= 0.4" } }, + "node_modules/help-me": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz", + "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==", + "dev": true, + "license": "MIT" + }, "node_modules/hookified": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.0.tgz", @@ -4988,6 +5079,16 @@ "@pkgjs/parseargs": "^0.11.0" } }, + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/js-yaml": { "version": "3.14.2", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", @@ -6350,7 +6451,6 @@ "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", "license": "MIT", - "optional": true, "engines": { "node": ">=14.0.0" } @@ -6634,44 +6734,79 @@ } }, "node_modules/pino": { - "version": "9.14.0", - "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz", - "integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==", + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", "license": "MIT", - "optional": true, "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", - "pino-abstract-transport": "^2.0.0", + "pino-abstract-transport": "^3.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", - "thread-stream": "^3.0.0" + "thread-stream": "^4.0.0" }, "bin": { "pino": "bin.js" } }, "node_modules/pino-abstract-transport": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", - "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", "license": "MIT", - "optional": true, "dependencies": { "split2": "^4.0.0" } }, + "node_modules/pino-pretty": { + "version": "13.1.3", + "resolved": "https://registry.npmjs.org/pino-pretty/-/pino-pretty-13.1.3.tgz", + "integrity": "sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "colorette": "^2.0.7", + "dateformat": "^4.6.3", + "fast-copy": "^4.0.0", + "fast-safe-stringify": "^2.1.1", + "help-me": "^5.0.0", + "joycon": "^3.1.1", + "minimist": "^1.2.6", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pump": "^3.0.0", + "secure-json-parse": "^4.0.0", + "sonic-boom": "^4.0.1", + "strip-json-comments": "^5.0.2" + }, + "bin": { + "pino-pretty": "bin.js" + } + }, + "node_modules/pino-pretty/node_modules/strip-json-comments": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz", + "integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/pino-std-serializers": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/postcss": { "version": "8.5.6", @@ -6743,8 +6878,7 @@ "url": "https://opencollective.com/fastify" } ], - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/proto-list": { "version": "1.2.4", @@ -6797,6 +6931,17 @@ "license": "MIT", "optional": true }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/pupa": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/pupa/-/pupa-3.3.0.tgz", @@ -6852,8 +6997,7 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/range-parser": { "version": "1.2.1", @@ -6931,7 +7075,6 @@ "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", "license": "MIT", - "optional": true, "engines": { "node": ">= 12.13.0" } @@ -7180,7 +7323,6 @@ "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", "license": "MIT", - "optional": true, "engines": { "node": ">=10" } @@ -7211,6 +7353,23 @@ "node": ">=4" } }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/semver": { "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", @@ -7493,7 +7652,6 @@ "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.0.tgz", "integrity": "sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==", "license": "MIT", - "optional": true, "dependencies": { "atomic-sleep": "^1.0.0" } @@ -7519,7 +7677,6 @@ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", "license": "ISC", - "optional": true, "engines": { "node": ">= 10.x" } @@ -8310,13 +8467,15 @@ } }, "node_modules/thread-stream": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz", - "integrity": "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz", + "integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==", "license": "MIT", - "optional": true, "dependencies": { "real-require": "^0.2.0" + }, + "engines": { + "node": ">=20" } }, "node_modules/tinybench": { diff --git a/package.json b/package.json index f967ecd..b4bc008 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,7 @@ "node-schedule": "^2.1.1", "open": "^11.0.0", "openai": "^6.17.0", + "pino": "^10.3.1", "qrcode-terminal": "^0.12.0", "telegramify-markdown": "^1.0.0", "tsx": "^4.21.0", @@ -96,6 +97,7 @@ }, "devDependencies": { "@types/update-notifier": "^6.0.8", + "pino-pretty": "^13.1.3", "vitest": "^4.0.18" }, "overrides": { diff --git a/src/api/auth.ts b/src/api/auth.ts index 9976ff5..08279d7 100644 --- a/src/api/auth.ts +++ b/src/api/auth.ts @@ -7,6 +7,9 @@ import * as fs from 'fs'; import * as path from 'path'; import type { IncomingHttpHeaders } from 'http'; +import { createLogger } from '../logger.js'; + +const log = createLogger('API'); const API_KEY_FILE = 'lettabot-api.json'; interface ApiKeyStore { @@ -70,7 +73,7 @@ export function loadOrGenerateApiKey(): string { return store.apiKey; } } catch (error) { - console.warn(`[API] Failed to load API key from ${API_KEY_FILE}:`, error); + log.warn(`Failed to load API key from ${API_KEY_FILE}:`, error); } } @@ -89,9 +92,9 @@ export function saveApiKey(key: string): void { try { fs.writeFileSync(filePath, JSON.stringify(store, null, 2), 'utf-8'); - console.log(`[API] Key saved to ${API_KEY_FILE}`); + log.info(`Key saved to ${API_KEY_FILE}`); } catch (error) { - console.error(`[API] Failed to save API key to ${API_KEY_FILE}:`, error); + log.error(`Failed to save API key to ${API_KEY_FILE}:`, error); } } diff --git a/src/api/server.ts b/src/api/server.ts index 7f916fb..9ce3f36 100644 --- a/src/api/server.ts +++ b/src/api/server.ts @@ -12,6 +12,9 @@ import { parseMultipart } from './multipart.js'; import type { AgentRouter } from '../core/interfaces.js'; import type { ChannelId } from '../core/types.js'; +import { createLogger } from '../logger.js'; + +const log = createLogger('API'); const VALID_CHANNELS: ChannelId[] = ['telegram', 'slack', 'discord', 'whatsapp', 'signal']; const MAX_BODY_SIZE = 10 * 1024; // 10KB const MAX_TEXT_LENGTH = 10000; // 10k chars @@ -104,7 +107,7 @@ export function createApiServer(deliverer: AgentRouter, options: ServerOptions): try { fs.unlinkSync(file.tempPath); } catch (err) { - console.warn('[API] Failed to cleanup temp file:', err); + log.warn('Failed to cleanup temp file:', err); } } @@ -116,7 +119,7 @@ export function createApiServer(deliverer: AgentRouter, options: ServerOptions): res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(response)); } catch (error: any) { - console.error('[API] Error handling request:', error); + log.error('Error handling request:', error); sendError(res, 500, error.message || 'Internal server error'); } return; @@ -165,7 +168,7 @@ export function createApiServer(deliverer: AgentRouter, options: ServerOptions): return; } - console.log(`[API] Chat request for agent "${resolvedName}": ${chatReq.message.slice(0, 100)}...`); + log.info(`Chat request for agent "${resolvedName}": ${chatReq.message.slice(0, 100)}...`); const context = { type: 'webhook' as const, outputMode: 'silent' as const }; const wantsStream = (req.headers.accept || '').includes('text/event-stream'); @@ -206,7 +209,7 @@ export function createApiServer(deliverer: AgentRouter, options: ServerOptions): res.end(JSON.stringify(chatRes)); } } catch (error: any) { - console.error('[API] Chat error:', error); + log.error('Chat error:', error); const chatRes: ChatResponse = { success: false, error: error.message || 'Internal server error', @@ -237,7 +240,7 @@ export function createApiServer(deliverer: AgentRouter, options: ServerOptions): res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(response)); } catch (error: any) { - console.error('[API] Pairing list error:', error); + log.error('Pairing list error:', error); sendError(res, 500, error.message || 'Internal server error'); } return; @@ -289,7 +292,7 @@ export function createApiServer(deliverer: AgentRouter, options: ServerOptions): return; } - console.log(`[API] Pairing approved: ${channel} user ${result.userId}`); + log.info(`Pairing approved: ${channel} user ${result.userId}`); const response: PairingApproveResponse = { success: true, userId: result.userId, @@ -297,7 +300,7 @@ export function createApiServer(deliverer: AgentRouter, options: ServerOptions): res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(response)); } catch (error: any) { - console.error('[API] Pairing approve error:', error); + log.error('Pairing approve error:', error); sendError(res, 500, error.message || 'Internal server error'); } return; @@ -311,7 +314,7 @@ export function createApiServer(deliverer: AgentRouter, options: ServerOptions): // Use API_HOST=0.0.0.0 in Docker to expose on all interfaces const host = options.host || '127.0.0.1'; server.listen(options.port, host, () => { - console.log(`[API] Server listening on ${host}:${options.port}`); + log.info(`Server listening on ${host}:${options.port}`); }); return server; diff --git a/src/auth/oauth.ts b/src/auth/oauth.ts index 81aae95..534ab64 100644 --- a/src/auth/oauth.ts +++ b/src/auth/oauth.ts @@ -7,6 +7,9 @@ import Letta from "@letta-ai/letta-client"; +import { createLogger } from '../logger.js'; + +const log = createLogger('Oauth'); export const LETTA_API_URL = "https://api.letta.com"; // Backward-compatible alias for older imports. export const LETTA_CLOUD_API_URL = LETTA_API_URL; @@ -189,13 +192,13 @@ export async function revokeToken(refreshToken: string): Promise { // OAuth 2.0 revoke endpoint should return 200 even if token is already invalid if (!response.ok) { const error = (await response.json()) as OAuthError; - console.error( + log.error( `Warning: Failed to revoke token: ${error.error_description || error.error}`, ); // Don't throw - we still want to clear local credentials } } catch (error) { - console.error("Warning: Failed to revoke token:", error); + log.error("Warning: Failed to revoke token:", error); // Don't throw - we still want to clear local credentials } } diff --git a/src/channels/discord.ts b/src/channels/discord.ts index 437ee0c..357b073 100644 --- a/src/channels/discord.ts +++ b/src/channels/discord.ts @@ -14,6 +14,9 @@ import { HELP_TEXT } from '../core/commands.js'; import { isGroupAllowed, isGroupUserAllowed, resolveGroupMode, resolveReceiveBotMessages, type GroupModeConfig } from './group-mode.js'; import { basename } from 'node:path'; +import { createLogger } from '../logger.js'; + +const log = createLogger('Discord'); // Dynamic import to avoid requiring Discord deps if not used let Client: typeof import('discord.js').Client; let GatewayIntentBits: typeof import('discord.js').GatewayIntentBits; @@ -151,8 +154,8 @@ Ask the bot owner to approve with: this.client.once('clientReady', () => { const tag = this.client?.user?.tag || '(unknown)'; - console.log(`[Discord] Bot logged in as ${tag}`); - console.log(`[Discord] DM policy: ${this.config.dmPolicy}`); + log.info(`Bot logged in as ${tag}`); + log.info(`DM policy: ${this.config.dmPolicy}`); this.running = true; }); @@ -194,15 +197,15 @@ Ask the bot owner to approve with: const result = await transcribeAudio(buffer, audioAttachment.name || `audio.${ext}`); if (result.success && result.text) { - console.log(`[Discord] Transcribed audio: "${result.text.slice(0, 50)}..."`); + log.info(`Transcribed audio: "${result.text.slice(0, 50)}..."`); content = (content ? content + '\n' : '') + `[Voice message]: ${result.text}`; } else { - console.error(`[Discord] Transcription failed: ${result.error}`); + log.error(`Transcription failed: ${result.error}`); content = (content ? content + '\n' : '') + `[Voice message - transcription failed: ${result.error}]`; } } } catch (error) { - console.error('[Discord] Error transcribing audio:', error); + log.error('Error transcribing audio:', error); content = (content ? content + '\n' : '') + `[Voice message - error: ${error instanceof Error ? error.message : 'unknown error'}]`; } } @@ -231,7 +234,7 @@ Ask the bot owner to approve with: } if (created) { - console.log(`[Discord] New pairing request from ${userId} (${message.author.username}): ${code}`); + log.info(`New pairing request from ${userId} (${message.author.username}): ${code}`); } await this.sendPairingMessage(message, this.formatPairingMsg(code)); @@ -273,7 +276,7 @@ Ask the bot owner to approve with: const keys = [chatId]; if (serverId) keys.push(serverId); if (!isGroupAllowed(this.config.groups, keys)) { - console.log(`[Discord] Group ${chatId} not in allowlist, ignoring`); + log.info(`Group ${chatId} not in allowlist, ignoring`); return; } @@ -311,7 +314,7 @@ Ask the bot owner to approve with: }); this.client.on('error', (err) => { - console.error('[Discord] Client error:', err); + log.error('Client error:', err); }); this.client.on('messageReactionAdd', async (reaction, user) => { @@ -322,7 +325,7 @@ Ask the bot owner to approve with: await this.handleReactionEvent(reaction, user, 'removed'); }); - console.log('[Discord] Connecting...'); + log.info('Connecting...'); await this.client.login(this.config.token); } @@ -374,7 +377,7 @@ Ask the bot owner to approve with: const message = await channel.messages.fetch(messageId); const botUserId = this.client.user?.id; if (!botUserId || message.author.id !== botUserId) { - console.warn('[Discord] Cannot edit message not sent by bot'); + log.warn('Cannot edit message not sent by bot'); return; } await message.edit(text); @@ -427,7 +430,7 @@ Ask the bot owner to approve with: await reaction.message.fetch(); } } catch (err) { - console.warn('[Discord] Failed to fetch reaction/message:', err); + log.warn('Failed to fetch reaction/message:', err); } const message = reaction.message; @@ -472,7 +475,7 @@ Ask the bot owner to approve with: action, }, }).catch((err) => { - console.error('[Discord] Error handling reaction:', err); + log.error('Error handling reaction:', err); }); } @@ -497,7 +500,7 @@ Ask the bot owner to approve with: continue; } if (this.attachmentsMaxBytes && attachment.size && attachment.size > this.attachmentsMaxBytes) { - console.warn(`[Discord] Attachment ${name} exceeds size limit, skipping download.`); + log.warn(`Attachment ${name} exceeds size limit, skipping download.`); results.push(entry); continue; } @@ -505,9 +508,9 @@ Ask the bot owner to approve with: try { await downloadToFile(attachment.url, target); entry.localPath = target; - console.log(`[Discord] Attachment saved to ${target}`); + log.info(`Attachment saved to ${target}`); } catch (err) { - console.warn('[Discord] Failed to download attachment:', err); + log.warn('Failed to download attachment:', err); } } results.push(entry); diff --git a/src/channels/signal.ts b/src/channels/signal.ts index bb8c1f5..cab53a4 100644 --- a/src/channels/signal.ts +++ b/src/channels/signal.ts @@ -23,6 +23,9 @@ import { copyFile, stat, access } from 'node:fs/promises'; import { constants } from 'node:fs'; import type { GroupModeConfig } from './group-mode.js'; +import { createLogger } from '../logger.js'; + +const log = createLogger('Signal'); export interface SignalGroupConfig extends GroupModeConfig {} export interface SignalConfig { @@ -217,7 +220,7 @@ This code expires in 1 hour.`; async start(): Promise { if (this.running) return; - console.log('[Signal] Starting adapter...'); + log.info('Starting adapter...'); // Spawn signal-cli daemon await this.startDaemon(); @@ -229,13 +232,13 @@ This code expires in 1 hour.`; this.startEventLoop(); this.running = true; - console.log('[Signal] Adapter started successfully'); + log.info('Adapter started successfully'); } async stop(): Promise { if (!this.running) return; - console.log('[Signal] Stopping adapter...'); + log.info('Stopping adapter...'); // Stop SSE loop this.sseAbortController?.abort(); @@ -248,7 +251,7 @@ This code expires in 1 hour.`; } this.running = false; - console.log('[Signal] Adapter stopped'); + log.info('Adapter stopped'); } isRunning(): boolean { @@ -338,7 +341,7 @@ This code expires in 1 hour.`; await this.rpcRequest('sendTyping', params); } catch (err) { // Typing indicators are best-effort - console.warn('[Signal] Failed to send typing indicator:', err); + log.warn('Failed to send typing indicator:', err); } } @@ -359,7 +362,7 @@ This code expires in 1 hour.`; args.push('--http', `${host}:${port}`); args.push('--no-receive-stdout'); - console.log(`[Signal] Spawning: ${cliPath} ${args.join(' ')}`); + log.info(`Spawning: ${cliPath} ${args.join(' ')}`); this.daemonProcess = spawn(cliPath, args, { stdio: ['ignore', 'pipe', 'pipe'], @@ -368,7 +371,7 @@ This code expires in 1 hour.`; this.daemonProcess.stdout?.on('data', (data) => { const lines = data.toString().split(/\r?\n/).filter((l: string) => l.trim()); for (const line of lines) { - console.log(`[signal-cli] ${line}`); + log.info(`${line}`); } }); @@ -377,19 +380,19 @@ This code expires in 1 hour.`; for (const line of lines) { // signal-cli writes most logs to stderr if (/\b(ERROR|WARN|FAILED|SEVERE)\b/i.test(line)) { - console.error(`[signal-cli] ${line}`); + log.error(`${line}`); } else { - console.log(`[signal-cli] ${line}`); + log.info(`${line}`); } } }); this.daemonProcess.on('error', (err) => { - console.error('[Signal] Daemon spawn error:', err); + log.error('Daemon spawn error:', err); }); this.daemonProcess.on('exit', (code) => { - console.log(`[Signal] Daemon exited with code ${code}`); + log.info(`Daemon exited with code ${code}`); if (this.running) { // Unexpected exit - mark as not running this.running = false; @@ -402,7 +405,7 @@ This code expires in 1 hour.`; const startTime = Date.now(); const pollIntervalMs = 500; - console.log('[Signal] Waiting for daemon to be ready...'); + log.info('Waiting for daemon to be ready...'); while (Date.now() - startTime < timeoutMs) { const controller = new AbortController(); @@ -417,7 +420,7 @@ This code expires in 1 hour.`; clearTimeout(timeout); if (res.ok) { - console.log('[Signal] Daemon is ready'); + log.info('Daemon is ready'); return; } } catch { @@ -437,7 +440,7 @@ This code expires in 1 hour.`; // Run SSE loop in background this.runSseLoop().catch((err) => { if (!this.sseAbortController?.signal.aborted) { - console.error('[Signal] SSE loop error:', err); + log.error('SSE loop error:', err); } }); } @@ -448,7 +451,7 @@ This code expires in 1 hour.`; url.searchParams.set('account', this.config.phoneNumber); } - console.log('[Signal] Starting SSE event loop:', url.toString()); + log.info('Starting SSE event loop:', url.toString()); while (!this.sseAbortController?.signal.aborted) { // Create a new controller for this connection attempt @@ -459,7 +462,7 @@ This code expires in 1 hour.`; this.sseAbortController?.signal.addEventListener('abort', onMainAbort, { once: true }); try { - console.log('[Signal] Connecting to SSE...'); + log.info('Connecting to SSE...'); const res = await fetch(url, { method: 'GET', headers: { Accept: 'text/event-stream' }, @@ -470,7 +473,7 @@ This code expires in 1 hour.`; throw new Error(`SSE failed: ${res.status} ${res.statusText}`); } - console.log('[Signal] SSE connected'); + log.info('SSE connected'); const reader = res.body.getReader(); const decoder = new TextDecoder(); @@ -479,7 +482,7 @@ This code expires in 1 hour.`; while (!this.sseAbortController?.signal.aborted) { const { value, done } = await reader.read(); if (done) { - console.log('[Signal] SSE stream ended'); + log.info('SSE stream ended'); break; } @@ -502,14 +505,14 @@ This code expires in 1 hour.`; } if (data) { this.handleSseData(data).catch((err) => { - console.error('[Signal] Error handling SSE data:', err); + log.error('Error handling SSE data:', err); }); } } } // Stream ended normally - wait before reconnecting - console.log('[Signal] SSE disconnected, reconnecting in 2s...'); + log.info('SSE disconnected, reconnecting in 2s...'); await new Promise((resolve) => setTimeout(resolve, 2000)); } catch (err) { @@ -517,7 +520,7 @@ This code expires in 1 hour.`; return; } - console.error('[Signal] SSE connection error, reconnecting in 5s:', err); + log.error('SSE connection error, reconnecting in 5s:', err); await new Promise((resolve) => setTimeout(resolve, 5000)); } finally { // Clean up the listener @@ -535,7 +538,7 @@ This code expires in 1 hour.`; // Debug: log when we receive any message if (envelope.dataMessage || envelope.syncMessage) { - console.log('[Signal] Received envelope:', JSON.stringify(envelope, null, 2)); + log.info('Received envelope:', JSON.stringify(envelope, null, 2)); } // Handle incoming data messages (from others) @@ -592,12 +595,12 @@ This code expires in 1 hour.`; // Handle voice message attachments // Log all attachments for debugging if (attachments?.length) { - console.log(`[Signal] Attachments received: ${JSON.stringify(attachments.map(a => ({ type: a.contentType, id: a.id })))}`); + log.info(`Attachments received: ${JSON.stringify(attachments.map(a => ({ type: a.contentType, id: a.id })))}`); } const voiceAttachment = attachments?.find(a => a.contentType?.startsWith('audio/')); if (voiceAttachment?.id) { - console.log(`[Signal] Voice attachment detected: ${voiceAttachment.contentType}, id: ${voiceAttachment.id}`); + log.info(`Voice attachment detected: ${voiceAttachment.contentType}, id: ${voiceAttachment.id}`); // Always persist voice audio to attachments directory let savedAudioPath: string | undefined; @@ -615,10 +618,10 @@ This code expires in 1 hour.`; if (voiceFileReady) { await copyFile(voiceSourcePath, voiceTargetPath); savedAudioPath = voiceTargetPath; - console.log(`[Signal] Voice audio saved to ${voiceTargetPath}`); + log.info(`Voice audio saved to ${voiceTargetPath}`); } } catch (err) { - console.warn('[Signal] Failed to save voice audio:', err); + log.warn('Failed to save voice audio:', err); } } @@ -639,18 +642,18 @@ This code expires in 1 hour.`; const { join: pjoin } = await import('node:path'); const attachmentPath = pjoin(hd(), '.local/share/signal-cli/attachments', voiceAttachment.id); - console.log(`[Signal] Waiting for attachment: ${attachmentPath}`); + log.info(`Waiting for attachment: ${attachmentPath}`); // Wait for file to be available (signal-cli may still be downloading) const fileReady = await waitForFile(attachmentPath, 5000); if (!fileReady) { - console.error(`[Signal] Attachment file not found after waiting: ${attachmentPath}`); + log.error(`Attachment file not found after waiting: ${attachmentPath}`); throw new Error(`Attachment file not found after waiting: ${attachmentPath}`); } - console.log(`[Signal] Attachment file ready: ${attachmentPath}`); + log.info(`Attachment file ready: ${attachmentPath}`); const buffer = readFileSync(attachmentPath); - console.log(`[Signal] Read ${buffer.length} bytes`); + log.info(`Read ${buffer.length} bytes`); const { transcribeAudio } = await import('../transcription/index.js'); const ext = voiceAttachment.contentType?.split('/')[1] || 'ogg'; @@ -660,26 +663,26 @@ This code expires in 1 hour.`; if (result.success) { if (result.text) { - console.log(`[Signal] Transcribed voice message: "${result.text.slice(0, 50)}..."`); + log.info(`Transcribed voice message: "${result.text.slice(0, 50)}..."`); messageText = (messageText ? messageText + '\n' : '') + `[Voice message]: ${result.text}`; } else { - console.warn(`[Signal] Transcription returned empty text`); + log.warn(`Transcription returned empty text`); messageText = (messageText ? messageText + '\n' : '') + `[Voice message - transcription returned empty${audioRef}]`; } } else { const errorMsg = result.error || 'Unknown transcription error'; - console.error(`[Signal] Transcription failed: ${errorMsg}`); + log.error(`Transcription failed: ${errorMsg}`); messageText = (messageText ? messageText + '\n' : '') + `[Voice message - transcription failed: ${errorMsg}${audioRef}]`; } } } catch (error) { - console.error('[Signal] Error transcribing voice message:', error); + log.error('Error transcribing voice message:', error); const audioRef = savedAudioPath ? ` Audio saved to: ${savedAudioPath}` : ''; messageText = (messageText ? messageText + '\n' : '') + `[Voice message - error: ${error instanceof Error ? error.message : 'unknown error'}.${audioRef}]`; } } else if (attachments?.some(a => a.contentType?.startsWith('audio/'))) { // Audio attachment exists but has no ID - console.warn(`[Signal] Audio attachment found but missing ID: ${JSON.stringify(voiceAttachment)}`); + log.warn(`Audio attachment found but missing ID: ${JSON.stringify(voiceAttachment)}`); } // Collect non-voice attachments (images, files, etc.) @@ -696,26 +699,26 @@ This code expires in 1 hour.`; } // Handle Note to Self - check selfChatMode - console.log(`[Signal] Processing message: chatId=${chatId}, source=${source}, selfChatMode=${this.config.selfChatMode}`); + log.info(`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)'); + log.info('Note to Self ignored (selfChatMode disabled)'); return; } // selfChatMode enabled - allow the message through - console.log('[Signal] Note to Self allowed (selfChatMode enabled)'); + log.info('Note to Self allowed (selfChatMode enabled)'); } else if (chatId.startsWith('group:')) { // Group messages bypass pairing - anyone in the group can interact - console.log('[Signal] Group message - bypassing access control'); + log.info('Group message - bypassing access control'); } else { // External DM - check access control - console.log('[Signal] Checking access for external message'); + log.info('Checking access for external message'); const access = await this.checkAccess(source); - console.log(`[Signal] Access result: ${access}`); + log.info(`Access result: ${access}`); if (access === 'blocked') { - console.log(`[Signal] Blocked message from unauthorized user: ${source}`); + log.info(`Blocked message from unauthorized user: ${source}`); await this.sendMessage({ chatId: source, text: "Sorry, you're not authorized to use this bot." }); return; } @@ -736,7 +739,7 @@ This code expires in 1 hour.`; // Send pairing message on first contact if (created) { - console.log(`[Signal] New pairing request from ${source}: ${code}`); + log.info(`New pairing request from ${source}: ${code}`); await this.sendMessage({ chatId: source, text: this.formatPairingMessage(code) }); } @@ -778,14 +781,14 @@ This code expires in 1 hour.`; }); if (!gatingResult.shouldProcess) { - console.log(`[Signal] Group message filtered: ${gatingResult.reason}`); + log.info(`Group message filtered: ${gatingResult.reason}`); return; } wasMentioned = gatingResult.wasMentioned; isListeningMode = gatingResult.mode === 'listen' && !wasMentioned; if (wasMentioned) { - console.log(`[Signal] Bot mentioned via ${gatingResult.method}`); + log.info(`Bot mentioned via ${gatingResult.method}`); } } @@ -803,11 +806,11 @@ This code expires in 1 hour.`; }; this.onMessage?.(msg).catch((err) => { - console.error('[Signal] Error handling message:', err); + log.error('Error handling message:', err); }); } catch (err) { - console.error('[Signal] Failed to parse SSE event:', err, data); + log.error('Failed to parse SSE event:', err, data); } } @@ -895,7 +898,7 @@ This code expires in 1 hour.`; try { const stats = await stat(sourcePath); if (stats.size > this.config.attachmentsMaxBytes) { - console.warn(`[Signal] Attachment ${name} exceeds size limit, skipping download.`); + log.warn(`Attachment ${name} exceeds size limit, skipping download.`); results.push(entry); continue; } @@ -907,7 +910,7 @@ This code expires in 1 hour.`; // Wait for file to be available (signal-cli may still be downloading) const fileReady = await waitForFile(sourcePath, 5000); if (!fileReady) { - console.warn(`[Signal] Attachment ${name} not found after waiting, skipping.`); + log.warn(`Attachment ${name} not found after waiting, skipping.`); results.push(entry); continue; } @@ -917,9 +920,9 @@ This code expires in 1 hour.`; try { await copyFile(sourcePath, target); entry.localPath = target; - console.log(`[Signal] Attachment saved to ${target}`); + log.info(`Attachment saved to ${target}`); } catch (err) { - console.warn('[Signal] Failed to copy attachment:', err); + log.warn('Failed to copy attachment:', err); } results.push(entry); diff --git a/src/channels/signal/group-gating.ts b/src/channels/signal/group-gating.ts index 01f8ea0..78d759c 100644 --- a/src/channels/signal/group-gating.ts +++ b/src/channels/signal/group-gating.ts @@ -6,6 +6,9 @@ import { isGroupAllowed, isGroupUserAllowed, resolveGroupMode, type GroupMode } from '../group-mode.js'; +import { createLogger } from '../../logger.js'; + +const log = createLogger('Signal'); export interface SignalGroupConfig { mode?: GroupMode; allowedUsers?: string[]; @@ -159,7 +162,7 @@ export function applySignalGroupGating(params: SignalGroupGatingParams): SignalG return { shouldProcess: true, mode, wasMentioned: true, method: 'regex' }; } } catch (err) { - console.warn(`[Signal] Invalid mention pattern: ${pattern}`, err); + log.warn(`Invalid mention pattern: ${pattern}`, err); } } } diff --git a/src/channels/slack-format.ts b/src/channels/slack-format.ts index f9398ec..2dd9645 100644 --- a/src/channels/slack-format.ts +++ b/src/channels/slack-format.ts @@ -6,6 +6,9 @@ * provide a conservative fallback if it is missing or fails at runtime. */ +import { createLogger } from '../logger.js'; + +const log = createLogger('Slack'); type SlackifyFn = (markdown: string) => string; let slackifyFn: SlackifyFn | null = null; @@ -36,7 +39,7 @@ async function loadSlackify(): Promise { } catch (e) { slackifyLoadFailed = true; const reason = e instanceof Error ? e.message : String(e); - console.warn(`[Slack] slackify-markdown unavailable; using fallback formatter (${reason})`); + log.warn(`slackify-markdown unavailable; using fallback formatter (${reason})`); return null; } })(); @@ -56,7 +59,7 @@ export async function markdownToSlackMrkdwn(markdown: string): Promise { try { return converter(markdown); } catch (e) { - console.error('[Slack] Markdown conversion failed, using fallback:', e); + log.error('Markdown conversion failed, using fallback:', e); return fallbackMarkdownToSlackMrkdwn(markdown); } } diff --git a/src/channels/slack.ts b/src/channels/slack.ts index 00a8129..ae46bf8 100644 --- a/src/channels/slack.ts +++ b/src/channels/slack.ts @@ -13,6 +13,9 @@ import { parseCommand, HELP_TEXT } from '../core/commands.js'; import { markdownToSlackMrkdwn } from './slack-format.js'; import { isGroupAllowed, isGroupUserAllowed, resolveGroupMode, type GroupMode, type GroupModeConfig } from './group-mode.js'; +import { createLogger } from '../logger.js'; + +const log = createLogger('Slack'); // Dynamic import to avoid requiring Slack deps if not used let App: typeof import('@slack/bolt').App; @@ -89,15 +92,15 @@ export class SlackAdapter implements ChannelAdapter { const result = await transcribeAudio(buffer, audioFile.name || `audio.${ext}`); if (result.success && result.text) { - console.log(`[Slack] Transcribed audio: "${result.text.slice(0, 50)}..."`); + log.info(`Transcribed audio: "${result.text.slice(0, 50)}..."`); text = (text ? text + '\n' : '') + `[Voice message]: ${result.text}`; } else { - console.error(`[Slack] Transcription failed: ${result.error}`); + log.error(`Transcription failed: ${result.error}`); text = (text ? text + '\n' : '') + `[Voice message - transcription failed: ${result.error}]`; } } } catch (error) { - console.error('[Slack] Error transcribing audio:', error); + log.error('Error transcribing audio:', error); text = (text ? text + '\n' : '') + `[Voice message - error: ${error instanceof Error ? error.message : 'unknown error'}]`; } } @@ -272,9 +275,9 @@ export class SlackAdapter implements ChannelAdapter { await this.handleReactionEvent(event as SlackReactionEvent, 'removed'); }); - console.log('[Slack] Connecting via Socket Mode...'); + log.info('Connecting via Socket Mode...'); await this.app.start(); - console.log('[Slack] Bot started in Socket Mode'); + log.info('Bot started in Socket Mode'); this.running = true; } @@ -465,7 +468,7 @@ async function maybeDownloadSlackFile( return attachment; } if (attachmentsMaxBytes && file.size && file.size > attachmentsMaxBytes) { - console.warn(`[Slack] Attachment ${name} exceeds size limit, skipping download.`); + log.warn(`Attachment ${name} exceeds size limit, skipping download.`); return attachment; } if (!url) { @@ -475,9 +478,9 @@ async function maybeDownloadSlackFile( try { await downloadToFile(url, target, { Authorization: `Bearer ${token}` }); attachment.localPath = target; - console.log(`[Slack] Attachment saved to ${target}`); + log.info(`Attachment saved to ${target}`); } catch (err) { - console.warn('[Slack] Failed to download attachment:', err); + log.warn('Failed to download attachment:', err); } return attachment; } diff --git a/src/channels/telegram-format.ts b/src/channels/telegram-format.ts index dc7fe79..0904e51 100644 --- a/src/channels/telegram-format.ts +++ b/src/channels/telegram-format.ts @@ -9,6 +9,9 @@ * Convert markdown to Telegram MarkdownV2 format. * Handles proper escaping of special characters. */ +import { createLogger } from '../logger.js'; + +const log = createLogger('Telegram'); export async function markdownToTelegramV2(markdown: string): Promise { try { // Dynamic import to handle ESM module @@ -16,7 +19,7 @@ export async function markdownToTelegramV2(markdown: string): Promise { // Use 'keep' strategy to preserve blockquotes (>) and other unsupported elements return telegramifyMarkdown(markdown, 'keep'); } catch (e) { - console.error('[Telegram] Markdown conversion failed, using escape fallback:', e); + log.error('Markdown conversion failed, using escape fallback:', e); // Fallback: escape special characters manually (loses formatting) return escapeMarkdownV2(markdown); } diff --git a/src/channels/telegram-mtproto.ts b/src/channels/telegram-mtproto.ts index 61f2391..b8b5925 100644 --- a/src/channels/telegram-mtproto.ts +++ b/src/channels/telegram-mtproto.ts @@ -22,6 +22,9 @@ import { isUserAllowed, upsertPairingRequest, approvePairingCode } from '../pair import { markdownToTdlib } from './telegram-mtproto-format.js'; import * as readline from 'node:readline'; +import { createLogger } from '../logger.js'; + +const log = createLogger('Telegram-mtproto'); // TDLib imports - configured at runtime let tdlModule: typeof import('tdl'); let getTdjson: () => string; @@ -147,7 +150,7 @@ Reply **approve** or **deny** to this message.`; firstName: user.first_name || null, }; } catch (err) { - console.warn(`[Telegram MTProto] Could not get user info for ${userId}:`, err); + log.warn(`Could not get user info for ${userId}:`, err); return { username: null, firstName: null }; } } @@ -162,7 +165,7 @@ Reply **approve** or **deny** to this message.`; const chat = await this.client.invoke({ _: 'createPrivateChat', user_id: userId, force: false }); return chat.id; } catch (err) { - console.warn(`[Telegram MTProto] Could not get private chat for user ${userId}:`, err); + log.warn(`Could not get private chat for user ${userId}:`, err); return null; } } @@ -220,7 +223,7 @@ Reply **approve** or **deny** to this message.`; // CRITICAL: Always attach error handler this.client.on('error', (err) => { - console.error('[Telegram MTProto] Client error:', err); + log.error('Client error:', err); }); } @@ -233,11 +236,11 @@ Reply **approve** or **deny** to this message.`; throw new Error('Client not initialized'); } - console.log('[Telegram MTProto] Starting update loop...'); + log.info('Starting update loop...'); for await (const update of this.client.iterUpdates()) { if (this.stopRequested) { - console.log('[Telegram MTProto] Stop requested, exiting update loop'); + log.info('Stop requested, exiting update loop'); if (this.authReject) { this.authReject(new Error('Stop requested')); } @@ -254,7 +257,7 @@ Reply **approve** or **deny** to this message.`; } // Ignore non-auth updates before we're ready } catch (err) { - console.error('[Telegram MTProto] Error handling update:', err); + log.error('Error handling update:', err); // If auth fails, reject the auth promise if (this.authState !== 'ready' && this.authReject) { this.authReject(err as Error); @@ -278,7 +281,7 @@ Reply **approve** or **deny** to this message.`; case 'authorizationStateWaitPhoneNumber': this.authState = 'waiting_phone'; - console.log('[Telegram MTProto] Sending phone number...'); + log.info('Sending phone number...'); await this.client!.invoke({ _: 'setAuthenticationPhoneNumber', phone_number: this.config.phoneNumber, @@ -287,7 +290,7 @@ Reply **approve** or **deny** to this message.`; case 'authorizationStateWaitCode': this.authState = 'waiting_code'; - console.log('[Telegram MTProto] Verification code sent to your Telegram app'); + log.info('Verification code sent to your Telegram app'); const code = await this.promptForInput('code'); if (this.stopRequested) throw new Error('Stop requested'); await this.client!.invoke({ @@ -298,7 +301,7 @@ Reply **approve** or **deny** to this message.`; case 'authorizationStateWaitPassword': this.authState = 'waiting_password'; - console.log('[Telegram MTProto] 2FA password required'); + log.info('2FA password required'); const password = await this.promptForInput('password'); if (this.stopRequested) throw new Error('Stop requested'); await this.client!.invoke({ @@ -309,16 +312,16 @@ Reply **approve** or **deny** to this message.`; case 'authorizationStateReady': this.authState = 'ready'; - console.log('[Telegram MTProto] Authenticated successfully!'); - console.log(`[Telegram MTProto] Session saved to ${this.config.databaseDirectory}/`); + log.info('Authenticated successfully!'); + log.info(`Session saved to ${this.config.databaseDirectory}/`); // Get our own user info for mention/reply detection try { const me = await this.client!.invoke({ _: 'getMe' }); this.myUserId = me.id; this.myUsername = me.usernames?.editable_username || me.username || null; - console.log(`[Telegram MTProto] Logged in as: ${this.myUsername || this.myUserId}`); + log.info(`Logged in as: ${this.myUsername || this.myUserId}`); } catch (err) { - console.warn('[Telegram MTProto] Could not fetch user info:', err); + log.warn('Could not fetch user info:', err); } // Signal that auth is complete if (this.authResolve) { @@ -419,7 +422,7 @@ Reply **approve** or **deny** to this message.`; const access = await this.checkAccess(userId); if (access === 'blocked') { - console.log(`[Telegram MTProto] Blocked message from user ${userId}`); + log.info(`Blocked message from user ${userId}`); return; } @@ -471,16 +474,16 @@ Reply **approve** or **deny** to this message.`; } } } catch (err) { - console.error(`[Telegram MTProto] Failed to send admin notification:`, err); + log.error(`Failed to send admin notification:`, err); // Fall back to console - console.log(`[Telegram MTProto] Pairing request from ${userInfo.username || userId}: ${code}`); - console.log(`[Telegram MTProto] To approve: lettabot pairing approve telegram-mtproto ${code}`); + log.info(`Pairing request from ${userInfo.username || userId}: ${code}`); + log.info(`To approve: lettabot pairing approve telegram-mtproto ${code}`); } } else { // No admin chat configured, log to console const userInfo = await this.getUserInfo(userId); - console.log(`[Telegram MTProto] Pairing request from ${userInfo.username || userId}: ${code}`); - console.log(`[Telegram MTProto] To approve: lettabot pairing approve telegram-mtproto ${code}`); + log.info(`Pairing request from ${userInfo.username || userId}: ${code}`); + log.info(`To approve: lettabot pairing approve telegram-mtproto ${code}`); } return; } @@ -529,7 +532,7 @@ Reply **approve** or **deny** to this message.`; }); } - console.log(`[Telegram MTProto] Approved pairing for ${pending.username} (${pending.userId})`); + log.info(`Approved pairing for ${pending.username} (${pending.userId})`); } else { await this.sendMessage({ chatId: String(chatId), @@ -548,7 +551,7 @@ Reply **approve** or **deny** to this message.`; text: `❌ Denied. ${pending.username} will not be able to chat.`, }); - console.log(`[Telegram MTProto] Denied pairing for ${pending.username} (${pending.userId})`); + log.info(`Denied pairing for ${pending.username} (${pending.userId})`); // Remove from pending this.pendingPairingApprovals.delete(replyToId); @@ -562,16 +565,16 @@ Reply **approve** or **deny** to this message.`; private handleConnectionState(state: any): void { switch (state._) { case 'connectionStateReady': - console.log('[Telegram MTProto] Connected'); + log.info('Connected'); break; case 'connectionStateConnecting': - console.log('[Telegram MTProto] Connecting...'); + log.info('Connecting...'); break; case 'connectionStateUpdating': - console.log('[Telegram MTProto] Updating...'); + log.info('Updating...'); break; case 'connectionStateWaitingForNetwork': - console.log('[Telegram MTProto] Waiting for network...'); + log.info('Waiting for network...'); break; } } @@ -589,7 +592,7 @@ Reply **approve** or **deny** to this message.`; const chatType = chat.type?._; return chatType === 'chatTypeBasicGroup' || chatType === 'chatTypeSupergroup'; } catch (err) { - console.warn('[Telegram MTProto] Could not determine chat type:', err); + log.warn('Could not determine chat type:', err); return false; } } @@ -643,7 +646,7 @@ Reply **approve** or **deny** to this message.`; // 'off' means never respond in groups if (policy === 'off') { - console.log('[Telegram MTProto] Group policy is off, ignoring group message'); + log.info('Group policy is off, ignoring group message'); return false; } @@ -653,14 +656,14 @@ Reply **approve** or **deny** to this message.`; switch (policy) { case 'mention': if (!mentioned) { - console.log('[Telegram MTProto] Not mentioned in group, ignoring'); + log.info('Not mentioned in group, ignoring'); return false; } return true; case 'reply': if (!isReply) { - console.log('[Telegram MTProto] Not a reply to us in group, ignoring'); + log.info('Not a reply to us in group, ignoring'); return false; } return true; @@ -680,7 +683,7 @@ Reply **approve** or **deny** to this message.`; async start(): Promise { if (this.running) return; - console.log('[Telegram MTProto] Starting adapter...'); + log.info('Starting adapter...'); this.stopRequested = false; this.authState = 'initializing'; @@ -697,7 +700,7 @@ Reply **approve** or **deny** to this message.`; // Start single update loop in background (handles both auth and runtime) this.updateLoopPromise = this.runUpdateLoop().catch((err) => { if (this.running && !this.stopRequested) { - console.error('[Telegram MTProto] Update loop error:', err); + log.error('Update loop error:', err); this.running = false; } }); @@ -706,16 +709,16 @@ Reply **approve** or **deny** to this message.`; await authPromise; this.running = true; - console.log('[Telegram MTProto] Adapter started'); + log.info('Adapter started'); } catch (err) { - console.error('[Telegram MTProto] Failed to start:', err); + log.error('Failed to start:', err); throw err; } } async stop(): Promise { // Always allow stop, even during auth (handles ctrl+c during code/password prompt) - console.log('[Telegram MTProto] Stopping adapter...'); + log.info('Stopping adapter...'); this.stopRequested = true; this.running = false; @@ -725,13 +728,13 @@ Reply **approve** or **deny** to this message.`; } catch (err) { // Ignore errors during shutdown (client may already be closing) if (!String(err).includes('closed')) { - console.error('[Telegram MTProto] Error closing client:', err); + log.error('Error closing client:', err); } } this.client = null; } - console.log('[Telegram MTProto] Adapter stopped'); + log.info('Adapter stopped'); } isRunning(): boolean { @@ -813,7 +816,7 @@ Reply **approve** or **deny** to this message.`; }); } catch (err) { // Typing indicators are best-effort, don't throw - console.warn('[Telegram MTProto] Failed to send typing indicator:', err); + log.warn('Failed to send typing indicator:', err); } } @@ -859,7 +862,7 @@ Reply **approve** or **deny** to this message.`; lastName: user.last_name || null, }; } catch (err) { - console.warn(`[Telegram MTProto] Could not get user info for ${userId}:`, err); + log.warn(`Could not get user info for ${userId}:`, err); throw err; } } @@ -922,7 +925,7 @@ Reply **approve** or **deny** to this message.`; } return null; } catch (err) { - console.warn(`[Telegram MTProto] Could not find user @${username}:`, err); + log.warn(`Could not find user @${username}:`, err); return null; } } diff --git a/src/channels/telegram.ts b/src/channels/telegram.ts index d0f2551..5050a86 100644 --- a/src/channels/telegram.ts +++ b/src/channels/telegram.ts @@ -20,6 +20,9 @@ import { buildAttachmentPath, downloadToFile } from './attachments.js'; import { applyTelegramGroupGating } from './telegram-group-gating.js'; import type { GroupModeConfig } from './group-mode.js'; +import { createLogger } from '../logger.js'; + +const log = createLogger('Telegram'); export interface TelegramConfig { token: string; dmPolicy?: DmPolicy; // 'pairing' (default), 'allowlist', or 'open' @@ -85,7 +88,7 @@ export class TelegramAdapter implements ChannelAdapter { }); if (!gatingResult.shouldProcess) { - console.log(`[Telegram] Group message filtered: ${gatingResult.reason}`); + log.info(`Group message filtered: ${gatingResult.reason}`); return null; } const wasMentioned = gatingResult.wasMentioned ?? false; @@ -141,7 +144,7 @@ export class TelegramAdapter implements ChannelAdapter { // No gating when policy is not pairing if (dmPolicy !== 'pairing') { await approveGroup('telegram', chatId); - console.log(`[Telegram] Group ${chatId} auto-approved (dmPolicy=${dmPolicy})`); + log.info(`Group ${chatId} auto-approved (dmPolicy=${dmPolicy})`); return; } @@ -151,14 +154,14 @@ export class TelegramAdapter implements ChannelAdapter { if (allowed) { await approveGroup('telegram', chatId); - console.log(`[Telegram] Group ${chatId} approved by paired user ${fromId}`); + log.info(`Group ${chatId} approved by paired user ${fromId}`); } else { - console.log(`[Telegram] Unpaired user ${fromId} tried to add bot to group ${chatId}, leaving`); + log.info(`Unpaired user ${fromId} tried to add bot to group ${chatId}, leaving`); try { await ctx.api.sendMessage(chatId, 'This bot can only be added to groups by paired users.'); await ctx.api.leaveChat(chatId); } catch (err) { - console.error('[Telegram] Failed to leave group:', err); + log.error('Failed to leave group:', err); } } }); @@ -213,7 +216,7 @@ export class TelegramAdapter implements ChannelAdapter { // Only send pairing message on first contact (created=true) // or if this is a new message (not just middleware check) if (created) { - console.log(`[Telegram] New pairing request from ${userId} (${ctx.from?.username || 'no username'}): ${code}`); + log.info(`New pairing request from ${userId} (${ctx.from?.username || 'no username'}): ${code}`); await ctx.reply(formatPairingMessage(code), { parse_mode: 'Markdown' }); } @@ -368,10 +371,10 @@ export class TelegramAdapter implements ChannelAdapter { let messageText: string; if (result.success && result.text) { - console.log(`[Telegram] Transcribed voice message: "${result.text.slice(0, 50)}..."`); + log.info(`Transcribed voice message: "${result.text.slice(0, 50)}..."`); messageText = `[Voice message]: ${result.text}`; } else { - console.error(`[Telegram] Transcription failed: ${result.error}`); + log.error(`Transcription failed: ${result.error}`); messageText = `[Voice message - transcription failed: ${result.error}]`; } @@ -392,7 +395,7 @@ export class TelegramAdapter implements ChannelAdapter { }); } } catch (error) { - console.error('[Telegram] Error processing voice message:', error); + log.error('Error processing voice message:', error); // Send error to agent so it can explain if (this.onMessage) { await this.onMessage({ @@ -447,7 +450,7 @@ export class TelegramAdapter implements ChannelAdapter { // Error handler this.bot.catch((err) => { - console.error('[Telegram] Bot error:', err); + log.error('Bot error:', err); }); } @@ -460,20 +463,20 @@ export class TelegramAdapter implements ChannelAdapter { // be active, causing a 409 Conflict. grammY retries internally but can throw. this.bot.start({ onStart: (botInfo) => { - console.log(`[Telegram] Bot started as @${botInfo.username}`); - console.log(`[Telegram] DM policy: ${this.config.dmPolicy}`); + log.info(`Bot started as @${botInfo.username}`); + log.info(`DM policy: ${this.config.dmPolicy}`); this.running = true; }, }).catch((err) => { const msg = err instanceof Error ? err.message : String(err); if (msg.includes('terminated by other getUpdates request') || msg.includes('409')) { - console.error(`[Telegram] getUpdates conflict (likely old instance still polling). Retrying in 5s...`); + log.error(`getUpdates conflict (likely old instance still polling). Retrying in 5s...`); setTimeout(() => { this.running = false; - this.start().catch(e => console.error('[Telegram] Retry failed:', e)); + this.start().catch(e => log.error('Retry failed:', e)); }, 5000); } else { - console.error('[Telegram] Bot polling error:', err); + log.error('Bot polling error:', err); } }); @@ -524,7 +527,7 @@ export class TelegramAdapter implements ChannelAdapter { } } catch (e) { // If MarkdownV2 fails, send raw text (also split if needed) - console.warn('[Telegram] MarkdownV2 send failed, falling back to raw text:', e); + log.warn('MarkdownV2 send failed, falling back to raw text:', e); const plainChunks = splitFormattedText(chunk); for (const plain of plainChunks) { const result = await this.bot.api.sendMessage(msg.chatId, plain, { @@ -560,7 +563,7 @@ export class TelegramAdapter implements ChannelAdapter { // "message is not modified" means content is already up-to-date -- harmless, don't retry if (e?.description?.includes('message is not modified')) return; // If MarkdownV2 fails, fall back to plain text (mirrors sendMessage fallback) - console.warn('[Telegram] MarkdownV2 edit failed, falling back to raw text:', e); + log.warn('MarkdownV2 edit failed, falling back to raw text:', e); await this.bot.api.editMessageText(chatId, Number(messageId), text); } } @@ -715,7 +718,7 @@ export class TelegramAdapter implements ChannelAdapter { return attachment; } if (this.attachmentsMaxBytes && size && size > this.attachmentsMaxBytes) { - console.warn(`[Telegram] Attachment ${fileName || fileId} exceeds size limit, skipping download.`); + log.warn(`Attachment ${fileName || fileId} exceeds size limit, skipping download.`); return attachment; } @@ -728,9 +731,9 @@ export class TelegramAdapter implements ChannelAdapter { const url = `https://api.telegram.org/file/bot${this.config.token}/${remotePath}`; await downloadToFile(url, target); attachment.localPath = target; - console.log(`[Telegram] Attachment saved to ${target}`); + log.info(`Attachment saved to ${target}`); } catch (err) { - console.warn('[Telegram] Failed to download attachment:', err); + log.warn('Failed to download attachment:', err); } return attachment; } diff --git a/src/channels/whatsapp/inbound/access-control.ts b/src/channels/whatsapp/inbound/access-control.ts index 77ac88b..4fc7950 100644 --- a/src/channels/whatsapp/inbound/access-control.ts +++ b/src/channels/whatsapp/inbound/access-control.ts @@ -9,6 +9,9 @@ import { isUserAllowed, upsertPairingRequest } from "../../../pairing/store.js"; import type { DmPolicy } from "../../../pairing/types.js"; import { normalizePhoneForStorage } from "../../../utils/phone.js"; +import { createLogger } from '../../../logger.js'; + +const log = createLogger('WhatsApp'); /** * Parameters for access control check */ @@ -213,7 +216,7 @@ export async function checkInboundAccess( // Send pairing message only on first contact if (created) { - console.log(`[WhatsApp] New pairing request from ${userId}: ${code}`); + log.info(`New pairing request from ${userId}: ${code}`); return { allowed: false, sendPairingMsg: true, diff --git a/src/channels/whatsapp/inbound/media.ts b/src/channels/whatsapp/inbound/media.ts index 5a24283..f1e718f 100644 --- a/src/channels/whatsapp/inbound/media.ts +++ b/src/channels/whatsapp/inbound/media.ts @@ -8,6 +8,9 @@ import type { InboundAttachment } from "../../../core/types.js"; import { buildAttachmentPath, writeStreamToFile } from "../../attachments.js"; +import { createLogger } from '../../../logger.js'; + +const log = createLogger('WhatsApp'); /** * Unwrap ephemeral and viewOnce message containers. * WhatsApp wraps certain messages in these containers which need to be unwrapped @@ -139,7 +142,7 @@ export async function collectAttachments(params: { // Size limit enforcement if (attachmentsMaxBytes && size && size > attachmentsMaxBytes) { - console.warn(`[WhatsApp] Attachment ${name} (${size} bytes) exceeds size limit, skipping download.`); + log.warn(`Attachment ${name} (${size} bytes) exceeds size limit, skipping download.`); attachments.push(attachment); const caption = mediaMessage.caption as string | undefined; return { attachments, caption }; @@ -151,9 +154,9 @@ export async function collectAttachments(params: { const stream = await downloadContentFromMessage(mediaMessage, mediaType); await writeStreamToFile(stream, target); attachment.localPath = target; - console.log(`[WhatsApp] Attachment saved to ${target}`); + log.info(`Attachment saved to ${target}`); } catch (err) { - console.warn('[WhatsApp] Failed to download attachment:', err); + log.warn('Failed to download attachment:', err); } } diff --git a/src/channels/whatsapp/inbound/mentions.ts b/src/channels/whatsapp/inbound/mentions.ts index edca0db..6e18005 100644 --- a/src/channels/whatsapp/inbound/mentions.ts +++ b/src/channels/whatsapp/inbound/mentions.ts @@ -5,6 +5,9 @@ * Based on OpenClaw's mention detection patterns. */ +import { createLogger } from '../../../logger.js'; + +const log = createLogger('WhatsApp'); export interface MentionConfig { /** Regex patterns to detect mentions (e.g., ["@?bot", "@?lettabot"]) */ mentionPatterns: string[]; @@ -122,7 +125,7 @@ export function detectMention(params: { return { wasMentioned: true, implicitMention: false, method: 'regex' }; } } catch (err) { - console.warn(`[WhatsApp] Invalid mention pattern: ${pattern}`, err); + log.warn(`Invalid mention pattern: ${pattern}`, err); } } diff --git a/src/channels/whatsapp/index.ts b/src/channels/whatsapp/index.ts index 1f70566..d235881 100644 --- a/src/channels/whatsapp/index.ts +++ b/src/channels/whatsapp/index.ts @@ -76,13 +76,9 @@ import { parseCommand, HELP_TEXT } from "../../core/commands.js"; // Node imports -// ============================================================================ -// DEBUG MODE -// ============================================================================ - -/** Enable verbose debug logging with DEBUG_WHATSAPP=1 */ -const DEBUG_WA = process.env.DEBUG_WHATSAPP === '1'; +import { createLogger } from '../../logger.js'; +const log = createLogger('WhatsApp'); // ============================================================================ // CONSTANTS // ============================================================================ @@ -221,7 +217,7 @@ export class WhatsAppAdapter implements ChannelAdapter { await this.onMessage?.(combined); }, onError: (err) => { - console.error('[WhatsApp] Debouncer error:', err); + log.error('Debouncer error:', err); }, }); @@ -266,7 +262,7 @@ export class WhatsAppAdapter implements ChannelAdapter { // Spawn background monitor (non-blocking) this.reconnectState.monitorTask = this.monitorConnection().catch((error) => { - console.error("[WhatsApp] Monitor task failed:", error); + log.error("Monitor task failed:", error); this.running = false; }); @@ -276,7 +272,7 @@ export class WhatsAppAdapter implements ChannelAdapter { async stop(): Promise { if (!this.running) return; - console.log("[WhatsApp] Stopping..."); + log.info("Stopping..."); // Signal monitor to stop this.reconnectState.abortController?.abort(); @@ -294,7 +290,7 @@ export class WhatsAppAdapter implements ChannelAdapter { ), ]); } catch (error) { - console.warn("[WhatsApp] Stop timeout, forcing cleanup"); + log.warn("Stop timeout, forcing cleanup"); } } @@ -310,13 +306,13 @@ export class WhatsAppAdapter implements ChannelAdapter { // server-side, which destroys credentials and forces QR re-pair on restart this.sock.ws?.close(); } catch (error) { - console.warn("[WhatsApp] Disconnect error:", error); + log.warn("Disconnect error:", error); } this.sock = null; } this.running = false; - console.log("[WhatsApp] Stopped"); + log.info("Stopped"); } isRunning(): boolean { @@ -365,10 +361,9 @@ export class WhatsAppAdapter implements ChannelAdapter { } catch (error) { const msg = error instanceof Error ? error.message : String(error); if (msg.includes("Connection closed during startup")) { - // Log the reason (status code) without the full stack trace - console.warn(`[WhatsApp] ${msg}`); + log.warn(msg); } else { - console.error("[WhatsApp] Socket error:", msg); + log.error("Socket error:", msg); } // Resolve the disconnect promise if it's still pending disconnectResolve!(); @@ -386,8 +381,8 @@ export class WhatsAppAdapter implements ChannelAdapter { // Check for persistent session failures (only warn after many attempts -- // 1-3 failures on startup is normal WhatsApp reconnection cooldown) if (this.consecutiveNoQrFailures >= SESSION_CORRUPTION_THRESHOLD) { - console.warn( - `[WhatsApp] ${SESSION_CORRUPTION_THRESHOLD} consecutive connection failures without QR. Session may need re-pairing -- use /reset whatsapp if this persists.` + log.warn( + `${SESSION_CORRUPTION_THRESHOLD} consecutive connection failures without QR. Session may need re-pairing -- use /reset whatsapp if this persists.` ); this.consecutiveNoQrFailures = 0; } @@ -395,15 +390,15 @@ export class WhatsAppAdapter implements ChannelAdapter { // Increment and check retry limit this.reconnectState.attempts++; if (this.reconnectState.attempts >= policy.maxAttempts) { - console.error("[WhatsApp] Max reconnect attempts reached"); + log.error("Max reconnect attempts reached"); this.running = false; break; } // Exponential backoff const delay = computeBackoff(policy, this.reconnectState.attempts); - console.log( - `[WhatsApp] Reconnecting in ${delay}ms (attempt ${this.reconnectState.attempts}/${policy.maxAttempts})` + log.info( + `Reconnecting in ${delay}ms (attempt ${this.reconnectState.attempts}/${policy.maxAttempts})` ); try { @@ -413,7 +408,7 @@ export class WhatsAppAdapter implements ChannelAdapter { } } - console.log("[WhatsApp] Monitor loop exited"); + log.info("Monitor loop exited"); this.running = false; } @@ -434,7 +429,7 @@ export class WhatsAppAdapter implements ChannelAdapter { this.sock.ws.close(); } } catch (error) { - console.warn("[WhatsApp] Socket cleanup warning:", error); + log.warn("Socket cleanup warning:", error); } this.sock = null; } @@ -447,10 +442,10 @@ export class WhatsAppAdapter implements ChannelAdapter { { encoding: "utf-8" } ); if (procs.trim()) { - console.warn( + log.warn( "[WhatsApp] Warning: clawdbot/moltbot is running and may compete for WhatsApp connection." ); - console.warn( + log.warn( "[WhatsApp] Stop it with: launchctl unload ~/Library/LaunchAgents/com.clawdbot.gateway.plist" ); } @@ -480,6 +475,7 @@ export class WhatsAppAdapter implements ChannelAdapter { // Track connection close during initial connection (silent -- logged at session clear) if (update.connection === "close" && !qrWasShown) { this.consecutiveNoQrFailures++; + } }, }); @@ -562,7 +558,7 @@ export class WhatsAppAdapter implements ChannelAdapter { const statusCode = (lastDisconnect?.error as any)?.output?.statusCode; const isLoggedOut = statusCode === this.DisconnectReason?.loggedOut; - console.log( + log.info( `[WhatsApp] Connection closed (status: ${statusCode ?? "unknown"}, loggedOut: ${isLoggedOut})` ); @@ -570,7 +566,7 @@ export class WhatsAppAdapter implements ChannelAdapter { if (isLoggedOut) { // Logged out - stop monitor completely - console.warn("[WhatsApp] Session logged out, stopping monitor"); + log.warn("Session logged out, stopping monitor"); this.running = false; this.reconnectState.abortController?.abort(); } @@ -589,10 +585,10 @@ export class WhatsAppAdapter implements ChannelAdapter { // Only process "notify" (new message) and "append" (history) if (type !== "notify" && type !== "append") { - if (DEBUG_WA) console.log(`[WhatsApp:Debug] Skipped batch: type=${type} (not notify/append)`); + log.debug(`Skipped batch: type=${type} (not notify/append)`); return; } - if (DEBUG_WA) console.log(`[WhatsApp:Debug] Processing ${messages.length} message(s), type=${type}`); + log.debug(`Processing ${messages.length} message(s), type=${type}`); for (const m of messages) { const messageId = m.key.id || ""; @@ -600,13 +596,13 @@ export class WhatsAppAdapter implements ChannelAdapter { // Filter out status updates and broadcast messages if (isStatusOrBroadcast(remoteJid)) { - if (DEBUG_WA) console.log(`[WhatsApp:Debug] Skipped status/broadcast: ${remoteJid}`); + log.debug(`Skipped status/broadcast: ${remoteJid}`); continue; } // Skip messages we sent (prevents loop in selfChatMode) if (this.sentMessageIds.has(messageId)) { - if (DEBUG_WA) console.log(`[WhatsApp:Debug] Skipped own sent message: ${messageId}`); + log.debug(`Skipped own sent message: ${messageId}`); this.sentMessageIds.delete(messageId); continue; } @@ -633,9 +629,7 @@ export class WhatsAppAdapter implements ChannelAdapter { this.config.selfChatMode || false ); - if (DEBUG_WA) { - console.log(`[WhatsApp:Debug] Message: remoteJid=${remoteJid}, fromMe=${m.key.fromMe}, isSelfChat=${isSelfChat}, selfChatMode=${this.config.selfChatMode}, myJid=${this.myJid}, myNumber=${this.myNumber}`); - } + log.debug(`Message: remoteJid=${remoteJid}, fromMe=${m.key.fromMe}, isSelfChat=${isSelfChat}, selfChatMode=${this.config.selfChatMode}, myJid=${this.myJid}, myNumber=${this.myNumber}`); // Track self-chat LID for reply conversion if (isSelfChat && isLid(remoteJid)) { @@ -645,7 +639,7 @@ export class WhatsAppAdapter implements ChannelAdapter { // Skip own messages (unless selfChatMode enabled for self-chat) if (m.key.fromMe) { if (!(this.config.selfChatMode && isSelfChat)) { - if (DEBUG_WA) console.log(`[WhatsApp:Debug] DROPPED: fromMe=true but not (selfChatMode && isSelfChat)`); + log.debug(`DROPPED: fromMe=true but not (selfChatMode && isSelfChat)`); continue; } } @@ -676,14 +670,14 @@ export class WhatsAppAdapter implements ChannelAdapter { } catch (err) { // Extraction threw error (e.g., Bad MAC during session renegotiation) // Skip without marking as seen → WhatsApp will retry after session fix - if (DEBUG_WA) console.log(`[WhatsApp:Debug] DROPPED: extraction threw error:`, err); + log.debug(`DROPPED: extraction threw error:`, err); continue; } if (!extracted) { // Extraction returned null (no text, invalid format, or decryption failure) // Skip without marking as seen → allows retry on next attempt - if (DEBUG_WA) console.log(`[WhatsApp:Debug] DROPPED: extraction returned null (no text/media)`); + log.debug(`DROPPED: extraction returned null (no text/media)`); continue; } @@ -691,7 +685,7 @@ export class WhatsAppAdapter implements ChannelAdapter { // Why: If we dedupe first, failed messages get marked as "seen" and are lost forever // With this order: Failed messages can retry after WhatsApp renegotiates the session if (this.dedupeCache.check(dedupeKey)) { - if (DEBUG_WA) console.log(`[WhatsApp:Debug] DROPPED: duplicate message ${dedupeKey}`); + log.debug(`DROPPED: duplicate message ${dedupeKey}`); continue; // Duplicate message - skip } @@ -702,7 +696,7 @@ export class WhatsAppAdapter implements ChannelAdapter { // CRITICAL: Skip messages older than connection time (prevents duplicate responses on reconnect) const messageTimestampMs = extracted.timestamp.getTime(); if (messageTimestampMs < this.connectedAtMs) { - if (DEBUG_WA) console.log(`[WhatsApp:Debug] DROPPED: old message (msgTime=${messageTimestampMs}, connectedAt=${this.connectedAtMs}, diff=${messageTimestampMs - this.connectedAtMs}ms)`); + log.debug(`DROPPED: old message (msgTime=${messageTimestampMs}, connectedAt=${this.connectedAtMs}, diff=${messageTimestampMs - this.connectedAtMs}ms)`); // This is an old message from before we connected - mark as read but don't auto-reply if (messageId && !isExtractedSelfChat && this.sock) { await sendReadReceipt(this.sock, remoteJid, messageId, m.key?.participant); @@ -710,19 +704,19 @@ export class WhatsAppAdapter implements ChannelAdapter { continue; } - if (DEBUG_WA) console.log(`[WhatsApp:Debug] Post-extraction: from=${from}, chatId=${chatId}, isGroup=${isGroup}, isExtractedSelfChat=${isExtractedSelfChat}, body="${body.slice(0, 50)}"`); + log.debug(`Post-extraction: from=${from}, chatId=${chatId}, isGroup=${isGroup}, isExtractedSelfChat=${isExtractedSelfChat}, body="${body.slice(0, 50)}"`); // Check access control for DMs only (groups are open, self-chat always allowed) if (!isGroup && !isExtractedSelfChat) { // If selfChatMode is enabled, ONLY respond to self-chat messages if (this.config.selfChatMode) { - if (DEBUG_WA) console.log(`[WhatsApp:Debug] DROPPED: selfChatMode=true but isExtractedSelfChat=false (from=${from}, selfE164=${extracted.selfE164})`); + log.debug(`DROPPED: selfChatMode=true but isExtractedSelfChat=false (from=${from}, selfE164=${extracted.selfE164})`); continue; // Silently ignore all non-self messages } // Type safety: sock must be available for access control if (!this.sock) { - console.warn('[WhatsApp] Socket not available for access control'); + log.warn('Socket not available for access control'); continue; } @@ -762,10 +756,10 @@ export class WhatsAppAdapter implements ChannelAdapter { ); if (botParticipant?.lid) { this.myLid = botParticipant.lid; - console.log(`[WhatsApp] Discovered bot LID from group participants`); + log.info(`Discovered bot LID from group participants`); } } catch (err) { - console.warn('[WhatsApp] Could not fetch group metadata for LID extraction:', err); + log.warn('Could not fetch group metadata for LID extraction:', err); } } @@ -786,10 +780,10 @@ export class WhatsAppAdapter implements ChannelAdapter { if (!gatingResult.shouldProcess) { if (gatingResult.reason === 'no-groups-config' && !this.loggedNoGroupsHint) { - console.log(`[WhatsApp] Group messages ignored (no groups config). Add a "groups" section to your agent config to enable.`); + log.info(`Group messages ignored (no groups config). Add a "groups" section to your agent config to enable.`); this.loggedNoGroupsHint = true; } else if (gatingResult.reason !== 'no-groups-config') { - console.log(`[WhatsApp] Group message skipped: ${gatingResult.reason}`); + log.info(`Group message skipped: ${gatingResult.reason}`); } continue; } @@ -865,7 +859,7 @@ export class WhatsAppAdapter implements ChannelAdapter { const THIRTY_MINUTES = 30 * 60 * 1000; if (elapsed > THIRTY_MINUTES) { - console.warn( + log.warn( "[WhatsApp] Watchdog: No messages in 30 minutes, forcing reconnect" ); this.forceDisconnect("watchdog-timeout"); @@ -974,7 +968,7 @@ export class WhatsAppAdapter implements ChannelAdapter { * Force a disconnect to trigger reconnection. */ private forceDisconnect(reason: string): void { - console.log("[WhatsApp] Triggering disconnect:", reason); + log.info("Triggering disconnect:", reason); if (this.disconnectSignal) { this.disconnectSignal(); } @@ -1022,7 +1016,7 @@ export class WhatsAppAdapter implements ChannelAdapter { async addReaction(_chatId: string, _messageId: string, _emoji: string): Promise { // WhatsApp reactions via Baileys are not supported here yet - console.warn('[WhatsApp] addReaction not implemented -- directive skipped'); + log.warn('addReaction not implemented -- directive skipped'); } async sendFile(file: OutboundFile): Promise<{ messageId: string }> { diff --git a/src/channels/whatsapp/outbound.ts b/src/channels/whatsapp/outbound.ts index 8ce9f6d..a9e4638 100644 --- a/src/channels/whatsapp/outbound.ts +++ b/src/channels/whatsapp/outbound.ts @@ -10,6 +10,9 @@ import type { WAMessage } from '@whiskeysockets/baileys'; import { isLid } from "./utils.js"; import { basename } from "node:path"; +import { createLogger } from '../../logger.js'; + +const log = createLogger('WhatsApp'); /** * LID (Linked Identifier) mapping for message sending. * Maps LID addresses to real JIDs. @@ -76,7 +79,7 @@ export function resolveSendJid( } // FAIL SAFE: Cannot resolve LID - don't send to unknown address - console.error(`[WhatsApp] Cannot resolve LID: ${chatId}`); + log.error(`Cannot resolve LID: ${chatId}`); throw new Error("Cannot send to unknown LID - no mapping found"); } @@ -144,7 +147,7 @@ export async function sendWhatsAppMessage( return { messageId }; } catch (error) { - console.error("[WhatsApp] sendMessage error:", error); + log.error("sendMessage error:", error); throw error; } } @@ -211,7 +214,7 @@ export async function sendReadReceipt( ]); } catch (err) { // Ignore read receipt errors - not critical - console.warn(`[WhatsApp] Failed to send read receipt for ${messageId}:`, err); + log.warn(`Failed to send read receipt for ${messageId}:`, err); } } @@ -272,7 +275,7 @@ export async function sendWhatsAppFile( return { messageId }; } catch (error) { - console.error("[WhatsApp] sendFile error:", error); + log.error("sendFile error:", error); throw error; } } diff --git a/src/channels/whatsapp/session.ts b/src/channels/whatsapp/session.ts index 1c033b3..08d773c 100644 --- a/src/channels/whatsapp/session.ts +++ b/src/channels/whatsapp/session.ts @@ -15,6 +15,9 @@ import { } from "../../utils/creds-queue.js"; import type { ConnectionUpdate } from "./types.js"; +import { createLogger } from '../../logger.js'; + +const log = createLogger('WhatsApp'); // Constants const INITIAL_CONNECT_TIMEOUT_MS = 30000; // 30 seconds const QR_SCAN_TIMEOUT_MS = 120000; // 2 minutes @@ -160,13 +163,13 @@ export async function createWaSocket(options: SocketOptions): Promise console.log(`[WhatsApp] ${msg}`), - warn: (msg, err) => console.warn(`[WhatsApp] ${msg}:`, err), + log: (msg) => log.info(msg), + warn: (msg, err) => log.warn(msg, err), }, }); if (restored) { - console.log("[WhatsApp] Session recovered from backup"); + log.info("Session recovered from backup"); } // Dynamic import Baileys @@ -183,7 +186,7 @@ export async function createWaSocket(options: SocketOptions): Promise { - console.error("[WhatsApp] WebSocket error:", err.message); + log.error("WebSocket error:", err.message); }); } @@ -223,7 +226,7 @@ export async function createWaSocket(options: SocketOptions): Promise console.warn(`[WhatsApp] ${msg}:`, err), + warn: (msg, err) => log.warn(msg, err), }, }); @@ -253,7 +256,7 @@ export async function createWaSocket(options: SocketOptions): Promise Linked Devices:"); + log.info("Scan this QR code in WhatsApp -> Linked Devices:"); qrcode.generate(update.qr, { small: true }); } @@ -296,9 +299,9 @@ export async function createWaSocket(options: SocketOptions): Promise { const agentId = getAgentId(); if (!agentId) { - console.error('No agent found. Run `lettabot server` first to create an agent.'); + log.error('No agent found. Run `lettabot server` first to create an agent.'); process.exit(1); } const model = await getAgentModel(agentId); if (model) { - console.log(`Agent model: ${model}`); + log.info(`Agent model: ${model}`); } else { - console.error('Could not retrieve agent model. Check your connection and API key.'); + log.error('Could not retrieve agent model. Check your connection and API key.'); process.exit(1); } } @@ -45,16 +48,16 @@ export async function modelShow(): Promise { export async function modelSet(handle: string): Promise { const agentId = getAgentId(); if (!agentId) { - console.error('No agent found. Run `lettabot server` first to create an agent.'); + log.error('No agent found. Run `lettabot server` first to create an agent.'); process.exit(1); } - console.log(`Setting model to: ${handle}`); + log.info(`Setting model to: ${handle}`); const success = await updateAgentModel(agentId, handle); if (success) { - console.log(`Model updated to: ${handle}`); + log.info(`Model updated to: ${handle}`); } else { - console.error('Failed to update model. Check the handle is valid and try again.'); + log.error('Failed to update model. Check the handle is valid and try again.'); process.exit(1); } } @@ -67,7 +70,7 @@ export async function modelInteractive(): Promise { const agentId = getAgentId(); if (!agentId) { - console.error('No agent found. Run `lettabot server` first to create an agent.'); + log.error('No agent found. Run `lettabot server` first to create an agent.'); process.exit(1); } @@ -141,8 +144,8 @@ export async function modelCommand(subCommand?: string, arg?: string): Promise'); - console.error('Example: lettabot model set anthropic/claude-sonnet-4-5-20250929'); + log.error('Usage: lettabot model set '); + log.error('Example: lettabot model set anthropic/claude-sonnet-4-5-20250929'); process.exit(1); } await modelSet(arg); @@ -152,8 +155,8 @@ export async function modelCommand(subCommand?: string, arg?: string): Promise]'); + log.error(`Unknown subcommand: ${subCommand}`); + log.error('Usage: lettabot model [show|set ]'); process.exit(1); } } diff --git a/src/config/io.test.ts b/src/config/io.test.ts index 4681048..03ac197 100644 --- a/src/config/io.test.ts +++ b/src/config/io.test.ts @@ -3,6 +3,20 @@ import { mkdtempSync, existsSync, readFileSync, writeFileSync, rmSync } from 'no import { join } from 'node:path'; import { tmpdir } from 'node:os'; import YAML from 'yaml'; + +// Mock the logger so log.warn/error route through console (tests spy on console) +vi.mock('../logger.js', () => ({ + createLogger: () => ({ + fatal: (...args: unknown[]) => console.error(...args), + error: (...args: unknown[]) => console.error(...args), + warn: (...args: unknown[]) => console.warn(...args), + info: (...args: unknown[]) => console.log(...args), + debug: (...args: unknown[]) => console.log(...args), + trace: (...args: unknown[]) => console.log(...args), + pino: {}, + }), +})); + import { saveConfig, loadConfig, loadConfigStrict, configToEnv, didLoadFail } from './io.js'; import { normalizeAgents, DEFAULT_CONFIG } from './types.js'; import type { LettaBotConfig } from './types.js'; diff --git a/src/config/io.ts b/src/config/io.ts index 99883a1..00f957c 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -12,6 +12,9 @@ import type { LettaBotConfig, ProviderConfig } from './types.js'; import { DEFAULT_CONFIG, canonicalizeServerMode, isApiServerMode, isDockerServerMode } from './types.js'; import { LETTA_API_URL } from '../auth/oauth.js'; +import { createLogger } from '../logger.js'; + +const log = createLogger('Config'); // Config file locations (checked in order) const CONFIG_PATHS = [ resolve(process.cwd(), 'lettabot.yaml'), // Project-local @@ -92,7 +95,7 @@ function parseAndNormalizeConfig(content: string): LettaBotConfig { // Deprecation warning: top-level api should be moved under server if (config.api && !config.server.api) { - console.warn('[Config] WARNING: Top-level `api:` is deprecated. Move it under `server:`.'); + log.warn('WARNING: Top-level `api:` is deprecated. Move it under `server:`.'); } return config; @@ -114,8 +117,8 @@ export function loadConfig(): LettaBotConfig { return parseAndNormalizeConfig(content); } catch (err) { _lastLoadFailed = true; - console.error(`[Config] Failed to load ${configPath}:`, err); - console.warn('[Config] Using default configuration. Check your YAML syntax and field locations.'); + log.error(`Failed to load ${configPath}:`, err); + log.warn('Using default configuration. Check your YAML syntax and field locations.'); return { ...DEFAULT_CONFIG }; } } @@ -160,7 +163,7 @@ export function saveConfig(config: Partial & Pick & Pick & Pick ({ + createLogger: () => ({ + fatal: (...args: unknown[]) => console.error(...args), + error: (...args: unknown[]) => console.error(...args), + warn: (...args: unknown[]) => console.warn(...args), + info: (...args: unknown[]) => console.log(...args), + debug: (...args: unknown[]) => console.log(...args), + trace: (...args: unknown[]) => console.log(...args), + pino: {}, + }), +})); + import { normalizeAgents, canonicalizeServerMode, diff --git a/src/config/runtime.test.ts b/src/config/runtime.test.ts index ce02bd7..56f81be 100644 --- a/src/config/runtime.test.ts +++ b/src/config/runtime.test.ts @@ -2,6 +2,20 @@ import { describe, it, expect, vi } from 'vitest'; import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; + +// Mock the logger so log.warn/error route through console (tests spy on console) +vi.mock('../logger.js', () => ({ + createLogger: () => ({ + fatal: (...args: unknown[]) => console.error(...args), + error: (...args: unknown[]) => console.error(...args), + warn: (...args: unknown[]) => console.warn(...args), + info: (...args: unknown[]) => console.log(...args), + debug: (...args: unknown[]) => console.log(...args), + trace: (...args: unknown[]) => console.log(...args), + pino: {}, + }), +})); + import { loadAppConfigOrExit } from './runtime.js'; import { didLoadFail } from './io.js'; diff --git a/src/config/runtime.ts b/src/config/runtime.ts index 821aafc..b39ecb4 100644 --- a/src/config/runtime.ts +++ b/src/config/runtime.ts @@ -1,6 +1,9 @@ import type { LettaBotConfig } from './types.js'; import { loadConfigStrict, resolveConfigPath } from './io.js'; +import { createLogger } from '../logger.js'; + +const log = createLogger('Config'); export type ExitFn = (code: number) => never; /** @@ -12,8 +15,8 @@ export function loadAppConfigOrExit(exitFn: ExitFn = process.exit): LettaBotConf return loadConfigStrict(); } catch (err) { const configPath = resolveConfigPath(); - console.error(`[Config] Failed to load ${configPath}:`, err); - console.error(`[Config] Fix the errors above in ${configPath} and restart.`); + log.error(`Failed to load ${configPath}:`, err); + log.error(`Fix the errors above in ${configPath} and restart.`); return exitFn(1); } } diff --git a/src/config/types.ts b/src/config/types.ts index 1acbb7c..31146ba 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -6,6 +6,9 @@ * 2. Letta API: Uses apiKey, optional BYOK providers */ +import { createLogger } from '../logger.js'; + +const log = createLogger('Config'); export type ServerMode = 'api' | 'docker' | 'cloud' | 'selfhosted'; export type CanonicalServerMode = 'api' | 'docker'; @@ -100,6 +103,8 @@ export interface LettaBotConfig { baseUrl?: string; // Only for api mode apiKey?: string; + // Log level (fatal|error|warn|info|debug|trace). Env vars LOG_LEVEL / LETTABOT_LOG_LEVEL override. + logLevel?: 'fatal' | 'error' | 'warn' | 'info' | 'debug' | 'trace'; // API server config (port, host, CORS) — canonical location api?: { port?: number; // Default: 8080 (or PORT env var) @@ -364,7 +369,7 @@ function warnGroupConfigDeprecation(path: string, detail: string): void { const key = `${path}:${detail}`; if (warnedGroupConfigDeprecations.has(key)) return; warnedGroupConfigDeprecations.add(key); - console.warn(`[Config] WARNING: ${path} ${detail}`); + log.warn(`WARNING: ${path} ${detail}`); } function normalizeLegacyGroupFields( diff --git a/src/core/bot.ts b/src/core/bot.ts index 6eee58b..9af4db3 100644 --- a/src/core/bot.ts +++ b/src/core/bot.ts @@ -23,6 +23,9 @@ import { createManageTodoTool } from '../tools/todo.js'; import { syncTodosFromTool } from '../todo/store.js'; +import { createLogger } from '../logger.js'; + +const log = createLogger('Bot'); /** * Detect if an error is a 409 CONFLICT from an orphaned approval. */ @@ -172,12 +175,12 @@ async function buildMultimodalMessage( content.push(await imageFromURL(attachment.url)); } } catch (err) { - console.warn(`[Bot] Failed to load image ${attachment.name || 'unknown'}: ${err instanceof Error ? err.message : err}`); + log.warn(`Failed to load image ${attachment.name || 'unknown'}: ${err instanceof Error ? err.message : err}`); } } if (content.length > 1) { - console.log(`[Bot] Sending ${content.length - 1} inline image(s) to LLM`); + log.info(`Sending ${content.length - 1} inline image(s) to LLM`); } return content.length > 1 ? content : formattedText; @@ -240,7 +243,7 @@ export class LettaBot implements AgentSession { this.config = config; mkdirSync(config.workingDir, { recursive: true }); this.store = new Store('lettabot-agent.json', config.agentName); - console.log(`LettaBot initialized. Agent ID: ${this.store.agentId || '(new)'}`); + log.info(`LettaBot initialized. Agent ID: ${this.store.agentId || '(new)'}`); } // ========================================================================= @@ -345,10 +348,10 @@ export class LettaBot implements AgentSession { try { const summary = syncTodosFromTool(this.getTodoAgentKey(), incoming); if (summary.added > 0 || summary.updated > 0) { - console.log(`[Bot] Synced ${summary.totalIncoming} todo(s) from ${streamMsg.toolName} into heartbeat store (added=${summary.added}, updated=${summary.updated})`); + log.info(`Synced ${summary.totalIncoming} todo(s) from ${streamMsg.toolName} into heartbeat store (added=${summary.added}, updated=${summary.updated})`); } } catch (err) { - console.warn('[Bot] Failed to sync TodoWrite todos:', err instanceof Error ? err.message : err); + log.warn('Failed to sync TodoWrite todos:', err instanceof Error ? err.message : err); } } @@ -453,16 +456,16 @@ export class LettaBot implements AgentSession { if (directive.type === 'react') { const targetId = directive.messageId || fallbackMessageId; if (!adapter.addReaction) { - console.warn(`[Bot] Directive react skipped: ${adapter.name} does not support addReaction`); + log.warn(`Directive react skipped: ${adapter.name} does not support addReaction`); continue; } if (targetId) { try { await adapter.addReaction(chatId, targetId, directive.emoji); acted = true; - console.log(`[Bot] Directive: reacted with ${directive.emoji}`); + log.info(`Directive: reacted with ${directive.emoji}`); } catch (err) { - console.warn('[Bot] Directive react failed:', err instanceof Error ? err.message : err); + log.warn('Directive react failed:', err instanceof Error ? err.message : err); } } continue; @@ -599,7 +602,7 @@ export class LettaBot implements AgentSession { session = createSession(this.store.agentId, opts); } else { // Create new agent -- persist immediately so we don't orphan it on later failures - console.log('[Bot] Creating new agent'); + log.info('Creating new agent'); const newAgentId = await createAgent({ systemPrompt: SYSTEM_PROMPT, memory: loadMemoryBlocks(this.config.agentName), @@ -607,7 +610,7 @@ export class LettaBot implements AgentSession { }); const currentBaseUrl = process.env.LETTA_BASE_URL || 'https://api.letta.com'; this.store.setAgent(newAgentId, currentBaseUrl); - console.log('[Bot] Saved new agent ID:', newAgentId); + log.info('Saved new agent ID:', newAgentId); if (this.config.agentName) { updateAgentName(newAgentId, this.config.agentName).catch(() => {}); @@ -618,10 +621,10 @@ export class LettaBot implements AgentSession { } // Initialize eagerly so the subprocess is ready before the first send() - console.log(`[Bot] Initializing session subprocess (key=${key})...`); + log.info(`Initializing session subprocess (key=${key})...`); try { await this.withSessionTimeout(session.initialize(), `Session initialize (key=${key})`); - console.log(`[Bot] Session subprocess ready (key=${key})`); + log.info(`Session subprocess ready (key=${key})`); this.sessions.set(key, session); return session; } catch (error) { @@ -644,13 +647,13 @@ export class LettaBot implements AgentSession { if (key) { const session = this.sessions.get(key); if (session) { - console.log(`[Bot] Invalidating session (key=${key})`); + log.info(`Invalidating session (key=${key})`); session.close(); this.sessions.delete(key); } } else { for (const [k, session] of this.sessions) { - console.log(`[Bot] Invalidating session (key=${k})`); + log.info(`Invalidating session (key=${k})`); session.close(); } this.sessions.clear(); @@ -670,7 +673,7 @@ export class LettaBot implements AgentSession { await this.ensureSessionForKey('shared'); } } catch (err) { - console.warn('[Bot] Session pre-warm failed:', err instanceof Error ? err.message : err); + log.warn('Session pre-warm failed:', err instanceof Error ? err.message : err); } } @@ -684,18 +687,18 @@ export class LettaBot implements AgentSession { 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, session.conversationId || undefined); - console.log('[Bot] Agent ID updated:', session.agentId); + log.info('Agent ID updated:', session.agentId); } else if (session.conversationId) { // In per-channel mode, persist per-key. In shared mode, use legacy field. if (convKey && convKey !== 'shared') { const existing = this.store.getConversationId(convKey); if (session.conversationId !== existing) { this.store.setConversationId(convKey, session.conversationId); - console.log(`[Bot] Conversation ID updated (key=${convKey}):`, session.conversationId); + log.info(`Conversation ID updated (key=${convKey}):`, session.conversationId); } } else if (session.conversationId !== this.store.conversationId) { this.store.conversationId = session.conversationId; - console.log('[Bot] Conversation ID updated:', session.conversationId); + log.info('Conversation ID updated:', session.conversationId); } } } @@ -732,17 +735,17 @@ export class LettaBot implements AgentSession { } catch (error) { // 409 CONFLICT from orphaned approval if (!retried && isApprovalConflictError(error) && this.store.agentId && convId) { - console.log('[Bot] CONFLICT detected - attempting orphaned approval recovery...'); + log.info('CONFLICT detected - attempting orphaned approval recovery...'); this.invalidateSession(convKey); const result = await recoverOrphanedConversationApproval( this.store.agentId, convId ); if (result.recovered) { - console.log(`[Bot] Recovery succeeded (${result.details}), retrying...`); + log.info(`Recovery succeeded (${result.details}), retrying...`); return this.runSession(message, { retried: true, canUseTool, convKey }); } - console.error(`[Bot] Orphaned approval recovery failed: ${result.details}`); + log.error(`Orphaned approval recovery failed: ${result.details}`); throw error; } @@ -750,7 +753,7 @@ export class LettaBot implements AgentSession { // Only retry on errors that indicate missing conversation/agent, not // on auth, network, or protocol errors (which would just fail again). if (this.store.agentId && isConversationMissingError(error)) { - console.warn(`[Bot] Conversation not found (key=${convKey}), creating a new conversation...`); + log.warn(`Conversation not found (key=${convKey}), creating a new conversation...`); this.invalidateSession(convKey); if (convKey !== 'shared') { this.store.clearConversation(convKey); @@ -813,7 +816,7 @@ export class LettaBot implements AgentSession { adapter.onMessage = (msg) => this.handleMessage(msg, adapter); adapter.onCommand = (cmd) => this.handleCommand(cmd, adapter.id); this.channels.set(adapter.id, adapter); - console.log(`Registered channel: ${adapter.name}`); + log.info(`Registered channel: ${adapter.name}`); } setGroupBatcher(batcher: GroupBatcher, intervals: Map, instantGroupIds?: Set, listeningGroupIds?: Set): void { @@ -825,12 +828,12 @@ export class LettaBot implements AgentSession { if (listeningGroupIds) { this.listeningGroupIds = listeningGroupIds; } - console.log('[Bot] Group batcher configured'); + log.info('Group batcher configured'); } processGroupBatch(msg: InboundMessage, adapter: ChannelAdapter): void { const count = msg.batchedMessages?.length || 0; - console.log(`[Bot] Group batch: ${count} messages from ${msg.channel}:${msg.chatId}`); + log.info(`Group batch: ${count} messages from ${msg.channel}:${msg.chatId}`); const effective = (count === 1 && msg.batchedMessages) ? msg.batchedMessages[0] : msg; @@ -850,7 +853,7 @@ export class LettaBot implements AgentSession { } else { this.messageQueue.push({ msg: effective, adapter }); if (!this.processing) { - this.processQueue().catch(err => console.error('[Queue] Fatal error in processQueue:', err)); + this.processQueue().catch(err => log.error('Fatal error in processQueue:', err)); } } } @@ -860,7 +863,7 @@ export class LettaBot implements AgentSession { // ========================================================================= private async handleCommand(command: string, channelId?: string): Promise { - console.log(`[Command] Received: /${command}`); + log.info(`Received: /${command}`); switch (command) { case 'status': { const info = this.store.getInfo(); @@ -878,7 +881,7 @@ export class LettaBot implements AgentSession { return '⚠️ Heartbeat service not configured'; } this.onTriggerHeartbeat().catch(err => { - console.error('[Heartbeat] Manual trigger failed:', err); + log.error('Manual trigger failed:', err); }); return '⏰ Heartbeat triggered (silent mode - check server logs)'; } @@ -888,7 +891,7 @@ export class LettaBot implements AgentSession { // Per-channel mode: only clear the conversation for this channel this.store.clearConversation(convKey); this.invalidateSession(convKey); - console.log(`[Command] /reset - conversation cleared for ${convKey}`); + log.info(`/reset - conversation cleared for ${convKey}`); // Eagerly create the new session so we can report the conversation ID try { const session = await this.ensureSessionForKey(convKey); @@ -903,7 +906,7 @@ export class LettaBot implements AgentSession { this.store.clearConversation(); this.store.resetRecoveryAttempts(); this.invalidateSession(); - console.log('[Command] /reset - all conversations cleared'); + log.info('/reset - all conversations cleared'); try { const session = await this.ensureSessionForKey('shared'); const newConvId = session.conversationId || '(pending)'; @@ -925,11 +928,11 @@ export class LettaBot implements AgentSession { async start(): Promise { const startPromises = Array.from(this.channels.entries()).map(async ([id, adapter]) => { try { - console.log(`Starting channel: ${adapter.name}...`); + log.info(`Starting channel: ${adapter.name}...`); await adapter.start(); - console.log(`Started channel: ${adapter.name}`); + log.info(`Started channel: ${adapter.name}`); } catch (e) { - console.error(`Failed to start channel ${id}:`, e); + log.error(`Failed to start channel ${id}:`, e); } }); await Promise.all(startPromises); @@ -940,7 +943,7 @@ export class LettaBot implements AgentSession { try { await adapter.stop(); } catch (e) { - console.error(`Failed to stop channel ${adapter.id}:`, e); + log.error(`Failed to stop channel ${adapter.id}:`, e); } } } @@ -954,7 +957,7 @@ export class LettaBot implements AgentSession { return { recovered: false, shouldReset: false }; } - console.log('[Bot] Checking for pending approvals...'); + log.info('Checking for pending approvals...'); try { const pendingApprovals = await getPendingApprovals( @@ -969,7 +972,7 @@ export class LettaBot implements AgentSession { this.store.conversationId ); if (convResult.recovered) { - console.log(`[Bot] Conversation-level recovery succeeded: ${convResult.details}`); + log.info(`Conversation-level recovery succeeded: ${convResult.details}`); return { recovered: true, shouldReset: false }; } } @@ -979,15 +982,15 @@ export class LettaBot implements AgentSession { const attempts = this.store.recoveryAttempts; if (attempts >= maxAttempts) { - console.error(`[Bot] Recovery failed after ${attempts} attempts. Still have ${pendingApprovals.length} pending approval(s).`); + log.error(`Recovery failed after ${attempts} attempts. Still have ${pendingApprovals.length} pending approval(s).`); return { recovered: false, shouldReset: true }; } - console.log(`[Bot] Found ${pendingApprovals.length} pending approval(s), attempting recovery (attempt ${attempts + 1}/${maxAttempts})...`); + log.info(`Found ${pendingApprovals.length} pending approval(s), attempting recovery (attempt ${attempts + 1}/${maxAttempts})...`); this.store.incrementRecoveryAttempts(); for (const approval of pendingApprovals) { - console.log(`[Bot] Rejecting approval for ${approval.toolName} (${approval.toolCallId})`); + log.info(`Rejecting approval for ${approval.toolName} (${approval.toolCallId})`); await rejectApproval( this.store.agentId, { toolCallId: approval.toolCallId, reason: 'Session was interrupted - retrying request' }, @@ -997,15 +1000,15 @@ export class LettaBot implements AgentSession { const runIds = [...new Set(pendingApprovals.map(a => a.runId))]; if (runIds.length > 0) { - console.log(`[Bot] Cancelling ${runIds.length} active run(s)...`); + log.info(`Cancelling ${runIds.length} active run(s)...`); await cancelRuns(this.store.agentId, runIds); } - console.log('[Bot] Recovery completed'); + log.info('Recovery completed'); return { recovered: true, shouldReset: false }; } catch (error) { - console.error('[Bot] Recovery failed:', error); + log.error('Recovery failed:', error); this.store.incrementRecoveryAttempts(); return { recovered: false, shouldReset: this.store.recoveryAttempts >= maxAttempts }; } @@ -1022,19 +1025,19 @@ export class LettaBot implements AgentSession { // the stream is paused waiting for user input while the processing // flag blocks new messages from being handled. if (this.pendingQuestionResolver) { - console.log(`[Bot] Intercepted message as AskUserQuestion answer from ${msg.userId}`); + log.info(`Intercepted message as AskUserQuestion answer from ${msg.userId}`); this.pendingQuestionResolver(msg.text || ''); this.pendingQuestionResolver = null; return; } - console.log(`[${msg.channel}] Message from ${msg.userId}: ${msg.text}`); + log.info(`Message from ${msg.userId} on ${msg.channel}: ${msg.text}`); if (msg.isGroup && this.groupBatcher) { const isInstant = this.instantGroupIds.has(`${msg.channel}:${msg.chatId}`) || (msg.serverId && this.instantGroupIds.has(`${msg.channel}:${msg.serverId}`)); const debounceMs = isInstant ? 0 : (this.groupIntervals.get(msg.channel) ?? 5000); - console.log(`[Bot] Group message routed to batcher (debounce=${debounceMs}ms, mentioned=${msg.wasMentioned}, instant=${!!isInstant})`); + log.info(`Group message routed to batcher (debounce=${debounceMs}ms, mentioned=${msg.wasMentioned}, instant=${!!isInstant})`); this.groupBatcher.enqueue(msg, adapter, debounceMs); return; } @@ -1048,7 +1051,7 @@ export class LettaBot implements AgentSession { // Shared mode: single global queue (existing behavior) this.messageQueue.push({ msg, adapter }); if (!this.processing) { - this.processQueue().catch(err => console.error('[Queue] Fatal error in processQueue:', err)); + this.processQueue().catch(err => log.error('Fatal error in processQueue:', err)); } } } @@ -1069,7 +1072,7 @@ export class LettaBot implements AgentSession { if (!this.processingKeys.has(key)) { this.processKeyedQueue(key).catch(err => - console.error(`[Queue] Fatal error in processKeyedQueue(${key}):`, err) + log.error(`Fatal error in processKeyedQueue(${key}):`, err) ); } } @@ -1084,7 +1087,7 @@ export class LettaBot implements AgentSession { try { await this.processMessage(msg, adapter); } catch (error) { - console.error(`[Queue] Error processing message (key=${key}):`, error); + log.error(`Error processing message (key=${key}):`, error); } } @@ -1102,11 +1105,11 @@ export class LettaBot implements AgentSession { try { await this.processMessage(msg, adapter); } catch (error) { - console.error('[Queue] Error processing message:', error); + log.error('Error processing message:', error); } } - console.log('[Queue] Finished processing all messages'); + log.info('Finished processing all messages'); this.processing = false; } @@ -1119,7 +1122,7 @@ export class LettaBot implements AgentSession { const debugTiming = !!process.env.LETTABOT_DEBUG_TIMING; const t0 = debugTiming ? performance.now() : 0; const lap = (label: string) => { - if (debugTiming) console.log(`[Timing] ${label}: ${(performance.now() - t0).toFixed(0)}ms`); + log.debug(`${label}: ${(performance.now() - t0).toFixed(0)}ms`); }; const suppressDelivery = isResponseDeliverySuppressed(msg); this.lastUserMessageTime = new Date(); @@ -1185,14 +1188,14 @@ export class LettaBot implements AgentSession { multiSelect: boolean; }>; const questionText = this.formatQuestionsForChannel(questions); - console.log(`[Bot] AskUserQuestion: sending ${questions.length} question(s) to ${msg.channel}:${msg.chatId}`); + log.info(`AskUserQuestion: sending ${questions.length} question(s) to ${msg.channel}:${msg.chatId}`); await adapter.sendMessage({ chatId: msg.chatId, text: questionText, threadId: msg.threadId }); // Wait for the user's next message (intercepted by handleMessage) const answer = await new Promise((resolve) => { this.pendingQuestionResolver = resolve; }); - console.log(`[Bot] AskUserQuestion: received answer (${answer.length} chars)`); + log.info(`AskUserQuestion: received answer (${answer.length} chars)`); // Map the user's response to each question const answers: Record = {}; @@ -1255,7 +1258,7 @@ export class LettaBot implements AgentSession { // Check for no-reply AFTER directive parsing if (response.trim() === '') { - console.log('[Bot] Agent chose not to reply (no-reply marker)'); + log.info('Agent chose not to reply (no-reply marker)'); sentAnyMessage = true; response = ''; messageId = null; @@ -1293,7 +1296,7 @@ export class LettaBot implements AgentSession { msgTypeCounts[streamMsg.type] = (msgTypeCounts[streamMsg.type] || 0) + 1; const preview = JSON.stringify(streamMsg).slice(0, 300); - console.log(`[Stream] type=${streamMsg.type} ${preview}`); + log.info(`type=${streamMsg.type} ${preview}`); // stream_event is a low-level streaming primitive (partial deltas), not a // semantic type change. Skip it for type-transition logic so it doesn't @@ -1340,7 +1343,7 @@ export class LettaBot implements AgentSession { // Tool loop detection const maxToolCalls = this.config.maxToolCalls ?? 100; if (streamMsg.type === 'tool_call' && (msgTypeCounts['tool_call'] || 0) >= maxToolCalls) { - console.error(`[Bot] Agent stuck in tool loop (${msgTypeCounts['tool_call']} calls), aborting`); + log.error(`Agent stuck in tool loop (${msgTypeCounts['tool_call']} calls), aborting`); session.abort().catch(() => {}); response = '(Agent got stuck in a tool loop and was stopped. Try sending your message again.)'; break; @@ -1351,19 +1354,19 @@ export class LettaBot implements AgentSession { this.syncTodoToolCall(streamMsg); const tcName = streamMsg.toolName || 'unknown'; const tcId = streamMsg.toolCallId?.slice(0, 12) || '?'; - console.log(`[Stream] >>> TOOL CALL: ${tcName} (id: ${tcId})`); + log.info(`>>> TOOL CALL: ${tcName} (id: ${tcId})`); sawNonAssistantSinceLastUuid = true; // Buffer the tool call -- the SDK streams multiple chunks per call // (first has empty args). We display the last chunk when type changes. pendingToolDisplay = { toolCallId: streamMsg.toolCallId || '', msg: streamMsg }; } else if (streamMsg.type === 'tool_result') { - console.log(`[Stream] <<< TOOL RESULT: error=${streamMsg.isError}, len=${(streamMsg as any).content?.length || 0}`); + log.info(`<<< TOOL RESULT: error=${streamMsg.isError}, len=${(streamMsg as any).content?.length || 0}`); sawNonAssistantSinceLastUuid = true; } else if (streamMsg.type === 'assistant' && lastMsgType !== 'assistant') { - console.log(`[Bot] Generating response...`); + log.info(`Generating response...`); } else if (streamMsg.type === 'reasoning') { if (lastMsgType !== 'reasoning') { - console.log(`[Bot] Reasoning...`); + log.info(`Reasoning...`); } sawNonAssistantSinceLastUuid = true; // Accumulate reasoning content for display @@ -1378,12 +1381,12 @@ export class LettaBot implements AgentSession { stopReason: (streamMsg as any).stopReason || 'error', apiError: (streamMsg as any).apiError, }; - console.error(`[Bot] Stream error detail: ${lastErrorDetail.message} [${lastErrorDetail.stopReason}]`); + log.error(`Stream error detail: ${lastErrorDetail.message} [${lastErrorDetail.stopReason}]`); sawNonAssistantSinceLastUuid = true; } else if (streamMsg.type === 'retry') { const rm = streamMsg as any; retryInfo = { attempt: rm.attempt, maxAttempts: rm.maxAttempts, reason: rm.reason }; - console.log(`[Bot] Retrying (${rm.attempt}/${rm.maxAttempts}): ${rm.reason}`); + log.info(`Retrying (${rm.attempt}/${rm.maxAttempts}): ${rm.reason}`); sawNonAssistantSinceLastUuid = true; } else if (streamMsg.type !== 'assistant') { sawNonAssistantSinceLastUuid = true; @@ -1397,7 +1400,7 @@ export class LettaBot implements AgentSession { if (msgUuid && lastAssistantUuid && msgUuid !== lastAssistantUuid) { if (response.trim()) { if (!sawNonAssistantSinceLastUuid) { - console.warn(`[Stream] WARNING: Assistant UUID changed (${lastAssistantUuid.slice(0, 8)} -> ${msgUuid.slice(0, 8)}) with no visible tool_call/reasoning events between them. Tool call events may have been dropped by SDK transformMessage().`); + log.warn(`WARNING: Assistant UUID changed (${lastAssistantUuid.slice(0, 8)} -> ${msgUuid.slice(0, 8)}) with no visible tool_call/reasoning events between them. Tool call events may have been dropped by SDK transformMessage().`); } await finalizeMessage(); } @@ -1431,7 +1434,7 @@ export class LettaBot implements AgentSession { sentAnyMessage = true; } } catch (editErr) { - console.warn('[Bot] Streaming edit failed:', editErr instanceof Error ? editErr.message : editErr); + log.warn('Streaming edit failed:', editErr instanceof Error ? editErr.message : editErr); } lastUpdate = Date.now(); } @@ -1444,8 +1447,8 @@ export class LettaBot implements AgentSession { } const hasResponse = response.trim().length > 0; const isTerminalError = streamMsg.success === false || !!streamMsg.error; - console.log(`[Bot] Stream result: success=${streamMsg.success}, hasResponse=${hasResponse}, resultLen=${resultText.length}`); - console.log(`[Bot] Stream message counts:`, msgTypeCounts); + log.info(`Stream result: success=${streamMsg.success}, hasResponse=${hasResponse}, resultLen=${resultText.length}`); + log.info(`Stream message counts:`, msgTypeCounts); if (streamMsg.error) { const detail = resultText.trim(); const parts = [`error=${streamMsg.error}`]; @@ -1453,7 +1456,7 @@ export class LettaBot implements AgentSession { if (streamMsg.durationMs !== undefined) parts.push(`duration=${streamMsg.durationMs}ms`); if (streamMsg.conversationId) parts.push(`conv=${streamMsg.conversationId}`); if (detail) parts.push(`detail=${detail.slice(0, 300)}`); - console.error(`[Bot] Result error: ${parts.join(', ')}`); + log.error(`Result error: ${parts.join(', ')}`); } // Retry once when stream ends without any assistant text. @@ -1511,15 +1514,15 @@ export class LettaBot implements AgentSession { const shouldRetryForErrorResult = isTerminalError && nothingDelivered && !isConflictError; if (shouldRetryForEmptyResult || shouldRetryForErrorResult) { if (shouldRetryForEmptyResult) { - console.error(`[Bot] Warning: Agent returned empty result with no response. stopReason=${streamMsg.stopReason || 'N/A'}, conv=${streamMsg.conversationId || 'N/A'}`); + log.error(`Warning: Agent returned empty result with no response. stopReason=${streamMsg.stopReason || 'N/A'}, conv=${streamMsg.conversationId || 'N/A'}`); } if (shouldRetryForErrorResult) { - console.error(`[Bot] Warning: Agent returned terminal error (error=${streamMsg.error}, stopReason=${streamMsg.stopReason || 'N/A'}) with no response.`); + log.error(`Warning: Agent returned terminal error (error=${streamMsg.error}, stopReason=${streamMsg.stopReason || 'N/A'}) with no response.`); } if (!retried && this.store.agentId && retryConvId) { const reason = shouldRetryForErrorResult ? 'error result' : 'empty result'; - console.log(`[Bot] ${reason} - attempting orphaned approval recovery...`); + log.info(`${reason} - attempting orphaned approval recovery...`); this.invalidateSession(retryConvKey); session = null; clearInterval(typingInterval); @@ -1528,15 +1531,15 @@ export class LettaBot implements AgentSession { retryConvId ); if (convResult.recovered) { - console.log(`[Bot] Recovery succeeded (${convResult.details}), retrying message...`); + log.info(`Recovery succeeded (${convResult.details}), retrying message...`); return this.processMessage(msg, adapter, true); } - console.warn(`[Bot] No orphaned approvals found: ${convResult.details}`); + log.warn(`No orphaned approvals found: ${convResult.details}`); // Some client-side approval failures do not surface as pending approvals. // Retry once anyway in case the previous run terminated mid-tool cycle. if (shouldRetryForErrorResult) { - console.log('[Bot] Retrying once after terminal error (no orphaned approvals detected)...'); + log.info('Retrying once after terminal error (no orphaned approvals detected)...'); return this.processMessage(msg, adapter, true); } } @@ -1572,12 +1575,12 @@ export class LettaBot implements AgentSession { // Detect unsupported multimodal if (Array.isArray(messageToSend) && response.includes('[Image omitted]')) { - console.warn('[Bot] Model does not support images -- consider a vision-capable model or features.inlineImages: false'); + log.warn('Model does not support images -- consider a vision-capable model or features.inlineImages: false'); } // Listening mode: agent processed for memory, suppress response delivery if (suppressDelivery) { - console.log(`[Bot] Listening mode: processed ${msg.channel}:${msg.chatId} for memory (response suppressed)`); + log.info(`Listening mode: processed ${msg.channel}:${msg.chatId} for memory (response suppressed)`); return; } @@ -1600,7 +1603,7 @@ export class LettaBot implements AgentSession { sentAnyMessage = true; this.store.resetRecoveryAttempts(); } catch (retryError) { - console.error('[Bot] Retry send also failed:', retryError); + log.error('Retry send also failed:', retryError); } } } @@ -1609,7 +1612,7 @@ export class LettaBot implements AgentSession { // Handle no response if (!sentAnyMessage) { if (!receivedAnyData) { - console.error('[Bot] Stream received NO DATA - possible stuck state'); + log.error('Stream received NO DATA - possible stuck state'); await adapter.sendMessage({ chatId: msg.chatId, text: '(No response received -- the connection may have dropped or the server may be busy. Please try again. If this persists, /reset will start a fresh conversation.)', @@ -1618,7 +1621,7 @@ export class LettaBot implements AgentSession { } else { const hadToolActivity = (msgTypeCounts['tool_call'] || 0) > 0 || (msgTypeCounts['tool_result'] || 0) > 0; if (hadToolActivity) { - console.log('[Bot] Agent had tool activity but no assistant message - likely sent via tool'); + log.info('Agent had tool activity but no assistant message - likely sent via tool'); } else { await adapter.sendMessage({ chatId: msg.chatId, @@ -1630,7 +1633,7 @@ export class LettaBot implements AgentSession { } } catch (error) { - console.error('[Bot] Error processing message:', error); + log.error('Error processing message:', error); try { await adapter.sendMessage({ chatId: msg.chatId, @@ -1638,7 +1641,7 @@ export class LettaBot implements AgentSession { threadId: msg.threadId, }); } catch (sendError) { - console.error('[Bot] Failed to send error message to channel:', sendError); + log.error('Failed to send error message to channel:', sendError); } } finally { // Session stays alive for reuse -- only invalidated on errors @@ -1796,12 +1799,12 @@ export class LettaBot implements AgentSession { setAgentId(agentId: string): void { this.store.agentId = agentId; - console.log(`[Bot] Agent ID set to: ${agentId}`); + log.info(`Agent ID set to: ${agentId}`); } reset(): void { this.store.reset(); - console.log('Agent reset'); + log.info('Agent reset'); } getLastMessageTarget(): { channel: string; chatId: string } | null { diff --git a/src/core/gateway.ts b/src/core/gateway.ts index e03b96c..89a59f6 100644 --- a/src/core/gateway.ts +++ b/src/core/gateway.ts @@ -11,6 +11,9 @@ import type { AgentSession, AgentRouter } from './interfaces.js'; import type { TriggerContext } from './types.js'; import type { StreamMsg } from './bot.js'; +import { createLogger } from '../logger.js'; + +const log = createLogger('Gateway'); export class LettaGateway implements AgentRouter { private agents: Map = new Map(); @@ -26,7 +29,7 @@ export class LettaGateway implements AgentRouter { throw new Error(`Agent "${name}" already exists`); } this.agents.set(name, session); - console.log(`[Gateway] Added agent: ${name}`); + log.info(`Added agent: ${name}`); } /** Get an agent session by name */ @@ -46,29 +49,29 @@ export class LettaGateway implements AgentRouter { /** Start all agents */ async start(): Promise { - console.log(`[Gateway] Starting ${this.agents.size} agent(s)...`); + log.info(`Starting ${this.agents.size} agent(s)...`); const results = await Promise.allSettled( Array.from(this.agents.entries()).map(async ([name, session]) => { await session.start(); - console.log(`[Gateway] Started: ${name}`); + log.info(`Started: ${name}`); }) ); const failed = results.filter(r => r.status === 'rejected'); if (failed.length > 0) { - console.error(`[Gateway] ${failed.length} agent(s) failed to start`); + log.error(`${failed.length} agent(s) failed to start`); } - console.log(`[Gateway] ${results.length - failed.length}/${results.length} agents started`); + log.info(`${results.length - failed.length}/${results.length} agents started`); } /** Stop all agents */ async stop(): Promise { - console.log(`[Gateway] Stopping all agents...`); + log.info(`Stopping all agents...`); for (const [name, session] of this.agents) { try { await session.stop(); - console.log(`[Gateway] Stopped: ${name}`); + log.info(`Stopped: ${name}`); } catch (e) { - console.error(`[Gateway] Failed to stop ${name}:`, e); + log.error(`Failed to stop ${name}:`, e); } } } diff --git a/src/core/memory.ts b/src/core/memory.ts index 1f7939f..3345e59 100644 --- a/src/core/memory.ts +++ b/src/core/memory.ts @@ -8,6 +8,9 @@ import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import matter from 'gray-matter'; +import { createLogger } from '../logger.js'; + +const log = createLogger('Memory'); const __dirname = dirname(fileURLToPath(import.meta.url)); // Try dist/memories first, fall back to src/memories const MEMORIES_DIR = existsSync(join(__dirname, '..', 'memories')) @@ -29,7 +32,7 @@ export interface MemoryBlock { */ export function loadMemoryBlocks(agentName = 'LettaBot'): MemoryBlock[] { if (!existsSync(MEMORIES_DIR)) { - console.warn(`[Memory] No memories directory found at ${MEMORIES_DIR}`); + log.warn(`No memories directory found at ${MEMORIES_DIR}`); return []; } diff --git a/src/core/store.ts b/src/core/store.ts index c5c3805..7c8fd5f 100644 --- a/src/core/store.ts +++ b/src/core/store.ts @@ -20,6 +20,9 @@ import { randomUUID } from 'node:crypto'; import { dirname, resolve } from 'node:path'; import type { AgentStore, LastMessageTarget } from './types.js'; import { getDataDir } from '../utils/paths.js'; +import { createLogger } from '../logger.js'; + +const log = createLogger('Store'); const DEFAULT_STORE_PATH = 'lettabot-agent.json'; const LOCK_RETRY_MS = 25; @@ -45,7 +48,7 @@ function sleepSync(ms: number): void { return; } if (!warnedAboutBusyWait) { - console.warn('[Store] Atomics.wait unavailable, falling back to busy-wait for lock retries'); + log.warn('Atomics.wait unavailable, falling back to busy-wait for lock retries'); warnedAboutBusyWait = true; } const end = Date.now() + ms; @@ -92,7 +95,7 @@ export class Store { // Repair the corrupted/missing primary from backup so the next read // doesn't have to fall through again. this.persistStore(backup.data); - console.error(`[Store] Recovered in-memory state for ${this.agentName} from backup store.`); + log.error(`Recovered in-memory state for ${this.agentName} from backup store.`); return; } @@ -102,7 +105,7 @@ export class Store { } // Keep current in-memory state if disk files exist but are unreadable. - console.error(`[Store] Keeping in-memory state for ${this.agentName}; on-disk store could not be read.`); + log.error(`Keeping in-memory state for ${this.agentName}; on-disk store could not be read.`); } private normalizeStore(rawData: any): ParsedStore { @@ -137,7 +140,7 @@ export class Store { try { return this.readStoreFromPath(filePath); } catch (error) { - console.error(`[Store] Failed to read ${label} store at ${filePath}:`, error); + log.error(`Failed to read ${label} store at ${filePath}:`, error); return null; } } @@ -153,7 +156,7 @@ export class Store { const backup = this.tryReadStore(this.backupPath, 'backup'); if (backup) { - console.error(`[Store] Recovered agent store from backup: ${this.backupPath}`); + log.error(`Recovered agent store from backup: ${this.backupPath}`); this.persistStore(backup.data); return backup.data; } @@ -222,7 +225,7 @@ export class Store { } catch (error) { const err = error as NodeJS.ErrnoException; if (err.code !== 'ENOENT') { - console.error(`[Store] Failed to release lock ${this.lockPath}:`, error); + log.error(`Failed to release lock ${this.lockPath}:`, error); } } } @@ -263,7 +266,7 @@ export class Store { const backup = this.tryReadStore(this.backupPath, 'backup'); if (backup) { - console.error(`[Store] Using backup store for merge due to unreadable primary store.`); + log.error(`Using backup store for merge due to unreadable primary store.`); return backup.data; } @@ -274,7 +277,7 @@ export class Store { try { this.withLock(() => this.writeStoreFiles(data)); } catch (error) { - console.error('Failed to persist agent store:', error); + log.error('Failed to persist agent store:', error); } } @@ -287,7 +290,7 @@ export class Store { this.data = current; }); } catch (error) { - console.error('Failed to save agent store:', error); + log.error('Failed to save agent store:', error); } } diff --git a/src/cron/heartbeat.ts b/src/cron/heartbeat.ts index 911b39b..3f94432 100644 --- a/src/cron/heartbeat.ts +++ b/src/cron/heartbeat.ts @@ -16,6 +16,9 @@ import { getCronLogPath } from '../utils/paths.js'; import { listActionableTodos } from '../todo/store.js'; +import { createLogger } from '../logger.js'; + +const log = createLogger('Heartbeat'); // Log file const LOG_PATH = getCronLogPath(); @@ -33,7 +36,7 @@ function logEvent(event: string, data: Record): void { // Ignore } - console.log(`[Heartbeat] ${event}:`, JSON.stringify(data)); + log.info(`${event}:`, JSON.stringify(data)); } /** @@ -85,19 +88,19 @@ export class HeartbeatService { */ start(): void { if (!this.config.enabled) { - console.log('[Heartbeat] Disabled'); + log.info('Disabled'); return; } if (this.intervalId) { - console.log('[Heartbeat] Already running'); + log.info('Already running'); return; } const intervalMs = this.config.intervalMinutes * 60 * 1000; - console.log(`[Heartbeat] Starting in SILENT MODE (every ${this.config.intervalMinutes} minutes)`); - console.log(`[Heartbeat] First heartbeat in ${this.config.intervalMinutes} minutes`); + log.info(`Starting in SILENT MODE (every ${this.config.intervalMinutes} minutes)`); + log.info(`First heartbeat in ${this.config.intervalMinutes} minutes`); // Wait full interval before first heartbeat (don't fire on startup) this.intervalId = setInterval(() => this.runHeartbeat(), intervalMs); @@ -116,7 +119,7 @@ export class HeartbeatService { if (this.intervalId) { clearInterval(this.intervalId); this.intervalId = null; - console.log('[Heartbeat] Stopped'); + log.info('Stopped'); } } @@ -125,7 +128,7 @@ export class HeartbeatService { * Bypasses the "recently messaged" check since user explicitly requested it */ async trigger(): Promise { - console.log('[Heartbeat] Manual trigger requested'); + log.info('Manual trigger requested'); await this.runHeartbeat(true); // skipRecentCheck = true } @@ -142,9 +145,9 @@ export class HeartbeatService { const formattedTime = now.toLocaleString(); const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; - console.log(`\n${'='.repeat(60)}`); - console.log(`[Heartbeat] ⏰ RUNNING at ${formattedTime} [SILENT MODE]`); - console.log(`${'='.repeat(60)}\n`); + log.info(`${'='.repeat(60)}`); + log.info(`⏰ RUNNING at ${formattedTime} [SILENT MODE]`); + log.info(`${'='.repeat(60)}`); // Skip if user sent a message in the configured window (unless manual trigger) if (!skipRecentCheck) { @@ -155,7 +158,7 @@ export class HeartbeatService { if (msSinceLastMessage < skipWindowMs) { const minutesAgo = Math.round(msSinceLastMessage / 60000); - console.log(`[Heartbeat] User messaged ${minutesAgo}m ago - skipping heartbeat`); + log.info(`User messaged ${minutesAgo}m ago - skipping heartbeat`); logEvent('heartbeat_skipped_recent_user', { lastUserMessage: lastUserMessage.toISOString(), minutesAgo, @@ -165,7 +168,7 @@ export class HeartbeatService { } } - console.log(`[Heartbeat] Sending heartbeat to agent...`); + log.info(`Sending heartbeat to agent...`); logEvent('heartbeat_running', { time: now.toISOString(), @@ -185,7 +188,7 @@ export class HeartbeatService { const todoAgentKey = this.bot.getStatus().agentId || this.config.agentKey; const actionableTodos = listActionableTodos(todoAgentKey, now); if (actionableTodos.length > 0) { - console.log(`[Heartbeat] Loaded ${actionableTodos.length} actionable to-do(s).`); + log.info(`Loaded ${actionableTodos.length} actionable to-do(s).`); } // Resolve custom prompt: inline config > promptFile (re-read each tick) > default @@ -195,7 +198,7 @@ export class HeartbeatService { const promptPath = resolve(this.config.workingDir, this.config.promptFile); customPrompt = readFileSync(promptPath, 'utf-8').trim(); } catch (err) { - console.error(`[Heartbeat] Failed to read promptFile "${this.config.promptFile}":`, err); + log.error(`Failed to read promptFile "${this.config.promptFile}":`, err); } } @@ -203,18 +206,18 @@ export class HeartbeatService { ? buildCustomHeartbeatPrompt(customPrompt, formattedTime, timezone, this.config.intervalMinutes, actionableTodos, now) : buildHeartbeatPrompt(formattedTime, timezone, this.config.intervalMinutes, actionableTodos, now); - console.log(`[Heartbeat] Sending prompt (SILENT MODE):\n${'─'.repeat(50)}\n${message}\n${'─'.repeat(50)}\n`); + log.info(`Sending prompt (SILENT MODE):\n${'─'.repeat(50)}\n${message}\n${'─'.repeat(50)}\n`); // Send to agent - response text is NOT delivered (silent mode) // Agent must use `lettabot-message` CLI via Bash to send messages const response = await this.bot.sendToAgent(message, triggerContext); // Log results - console.log(`[Heartbeat] Agent finished.`); - console.log(` - Response text: ${response?.length || 0} chars (NOT delivered - silent mode)`); + log.info(`Agent finished.`); + log.info(` - Response text: ${response?.length || 0} chars (NOT delivered - silent mode)`); if (response && response.trim()) { - console.log(` - Response preview: "${response.slice(0, 100)}${response.length > 100 ? '...' : ''}"`); + log.info(` - Response preview: "${response.slice(0, 100)}${response.length > 100 ? '...' : ''}"`); } logEvent('heartbeat_completed', { @@ -223,7 +226,7 @@ export class HeartbeatService { }); } catch (error) { - console.error('[Heartbeat] Error:', error); + log.error('Error:', error); logEvent('heartbeat_error', { error: error instanceof Error ? error.message : String(error), }); diff --git a/src/cron/service.ts b/src/cron/service.ts index aff0265..1c9764d 100644 --- a/src/cron/service.ts +++ b/src/cron/service.ts @@ -12,6 +12,9 @@ import type { CronJob, CronJobCreate, CronSchedule, CronConfig, HeartbeatConfig import { DEFAULT_HEARTBEAT_MESSAGES } from './types.js'; import { getCronDataDir, getCronLogPath, getCronStorePath, getLegacyCronStorePath } from '../utils/paths.js'; +import { createLogger } from '../logger.js'; + +const log = createLogger('Cron'); // Log file for cron events const LOG_PATH = getCronLogPath(); @@ -29,7 +32,7 @@ function logEvent(event: string, data: Record): void { // Ignore log errors } - console.log(`[Cron] ${event}:`, JSON.stringify(data)); + log.info(`${event}:`, JSON.stringify(data)); } // Dynamic import for node-schedule @@ -80,7 +83,7 @@ export class CronService { copyFileSync(legacyPath, this.storePath); logEvent('store_migrated', { from: legacyPath, to: this.storePath }); } catch (e) { - console.error('[Cron] Failed to migrate legacy store:', e); + log.error('Failed to migrate legacy store:', e); } } @@ -98,10 +101,10 @@ export class CronService { } this.jobs.set(job.id, job); } - console.log(`[Cron] Loaded ${this.jobs.size} jobs`); + log.info(`Loaded ${this.jobs.size} jobs`); } } catch (e) { - console.error('[Cron] Failed to load jobs:', e); + log.error('Failed to load jobs:', e); } } @@ -115,7 +118,7 @@ export class CronService { mkdirSync(dirname(this.storePath), { recursive: true }); writeFileSync(this.storePath, JSON.stringify(data, null, 2)); } catch (e) { - console.error('[Cron] Failed to save jobs:', e); + log.error('Failed to save jobs:', e); } } @@ -147,7 +150,7 @@ export class CronService { this.started = true; const enabledCount = Array.from(this.jobs.values()).filter(j => j.enabled).length; - console.log(`[Cron] Service started (${enabledCount} jobs, heartbeat: ${heartbeat.enabled ? 'on' : 'off'}, watching for changes)`); + log.info(`Service started (${enabledCount} jobs, heartbeat: ${heartbeat.enabled ? 'on' : 'off'}, watching for changes)`); } stop(): void { @@ -170,7 +173,7 @@ export class CronService { } this.started = false; - console.log('[Cron] Service stopped'); + log.info('Service stopped'); } /** @@ -223,7 +226,7 @@ export class CronService { // Reload jobs this.reloadJobs(); } catch (e) { - console.error('[Cron] Error handling file change:', e); + log.error('Error handling file change:', e); } } @@ -264,11 +267,11 @@ export class CronService { // Agent must use `lettabot-message` CLI to send messages const response = await this.bot.sendToAgent(config.message); - console.log(`[Cron] Heartbeat finished (SILENT MODE)`); - console.log(` - Response: ${response?.slice(0, 100)}${(response?.length || 0) > 100 ? '...' : ''}`); - console.log(` - (Response NOT auto-delivered - agent uses lettabot-message CLI)`); + log.info(`Heartbeat finished (SILENT MODE)`); + log.info(` - Response: ${response?.slice(0, 100)}${(response?.length || 0) > 100 ? '...' : ''}`); + log.info(` - (Response NOT auto-delivered - agent uses lettabot-message CLI)`); } catch (error) { - console.error('[Cron] Heartbeat failed:', error); + log.error('Heartbeat failed:', error); } }); @@ -309,7 +312,7 @@ export class CronService { private scheduleJob(job: CronJob): void { const rule = this.parseSchedule(job.schedule); if (!rule) { - console.warn(`[Cron] Invalid schedule for job ${job.name}`); + log.warn(`Invalid schedule for job ${job.name}`); return; } @@ -326,7 +329,7 @@ export class CronService { job.state.nextRunAt = new Date(nextInvocation.getTime()); this.saveJobs(); - console.log(`[Cron] 📅 Scheduled "${job.name}" - next run: ${job.state.nextRunAt.toLocaleString()}`); + log.info(`📅 Scheduled "${job.name}" - next run: ${job.state.nextRunAt.toLocaleString()}`); logEvent('job_scheduled', { id: job.id, @@ -366,17 +369,17 @@ export class CronService { const job = this.jobs.get(jobId); if (!job) return; - console.log(`\n${'='.repeat(50)}`); + log.info(`${'='.repeat(50)}`); const isEmailCheck = job.name.toLowerCase().includes('email') || job.message.includes('gog gmail'); const icon = isEmailCheck ? '📧' : '⏰'; - console.log(`[Cron] ${icon} RUNNING JOB: ${job.name}`); - console.log(` ID: ${job.id}`); + log.info(`${icon} RUNNING JOB: ${job.name}`); + log.info(` ID: ${job.id}`); if (isEmailCheck) { - console.log(` Checking Gmail for new messages...`); + log.info(` Checking Gmail for new messages...`); } else { - console.log(` Message: ${job.message.slice(0, 100)}${job.message.length > 100 ? '...' : ''}`); + log.info(` Message: ${job.message.slice(0, 100)}${job.message.length > 100 ? '...' : ''}`); } - console.log(`${'='.repeat(50)}\n`); + log.info(`${'='.repeat(50)}`); logEvent('job_running', { id: job.id, name: job.name }); @@ -398,9 +401,9 @@ export class CronService { if (job.deliver && response) { try { await this.bot.deliverToChannel(job.deliver.channel, job.deliver.chatId, { text: response }); - console.log(`[Cron] 📬 Delivered response to ${job.deliver.channel}:${job.deliver.chatId}`); + log.info(`📬 Delivered response to ${job.deliver.channel}:${job.deliver.chatId}`); } catch (deliverError) { - console.error(`[Cron] Failed to deliver response to ${job.deliver.channel}:${job.deliver.chatId}:`, deliverError); + log.error(`Failed to deliver response to ${job.deliver.channel}:${job.deliver.chatId}:`, deliverError); logEvent('job_deliver_failed', { id: job.id, name: job.name, @@ -426,15 +429,15 @@ export class CronService { } } - console.log(`\n${'='.repeat(50)}`); - console.log(`[Cron] ✅ JOB COMPLETED: ${job.name} [${deliverMode.toUpperCase()} MODE]`); - console.log(` Response: ${response?.slice(0, 200)}${(response?.length || 0) > 200 ? '...' : ''}`); + log.info(`${'='.repeat(50)}`); + log.info(`✅ JOB COMPLETED: ${job.name} [${deliverMode.toUpperCase()} MODE]`); + log.info(` Response: ${response?.slice(0, 200)}${(response?.length || 0) > 200 ? '...' : ''}`); if (deliverMode === 'silent') { - console.log(` (Response NOT auto-delivered - agent uses lettabot-message CLI)`); + log.info(` (Response NOT auto-delivered - agent uses lettabot-message CLI)`); } else { - console.log(` (Response delivered to ${job.deliver!.channel}:${job.deliver!.chatId})`); + log.info(` (Response delivered to ${job.deliver!.channel}:${job.deliver!.chatId})`); } - console.log(`${'='.repeat(50)}\n`); + log.info(`${'='.repeat(50)}`); logEvent('job_completed', { id: job.id, @@ -482,7 +485,7 @@ export class CronService { this.scheduleJob(job); } - console.log(`[Cron] Added job: ${job.name}`); + log.info(`Added job: ${job.name}`); return job; } @@ -496,7 +499,7 @@ export class CronService { const deleted = this.jobs.delete(jobId); if (deleted) { this.saveJobs(); - console.log(`[Cron] Removed job: ${jobId}`); + log.info(`Removed job: ${jobId}`); } return deleted; } diff --git a/src/logger.ts b/src/logger.ts new file mode 100644 index 0000000..7be0625 --- /dev/null +++ b/src/logger.ts @@ -0,0 +1,164 @@ +/** + * Structured logging for LettaBot. + * + * Uses pino for structured JSON in production and pino-pretty for + * human-readable output during local development. + * + * The Logger interface accepts console-style calling conventions: + * log.info('message') - simple message + * log.info('message:', data) - message with extra context + * log.error('failed:', err) - errors are serialized properly + * + * Environment variables: + * LOG_LEVEL - Set log level (fatal|error|warn|info|debug|trace) + * LETTABOT_LOG_LEVEL - Alias for LOG_LEVEL + * LOG_FORMAT=json - Force structured JSON output (for Railway/production) + * + * Config (lettabot.yaml): + * server.logLevel - Set log level from config (env vars take precedence) + */ + +import pino from 'pino'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); + +// Resolve initial log level from environment +const envLevel = process.env.LOG_LEVEL || process.env.LETTABOT_LOG_LEVEL; +const initialLevel = envLevel || 'info'; + +// Determine transport: JSON for production, pretty for local dev +function resolveTransport(): pino.TransportSingleOptions | undefined { + // Explicit JSON mode -- no transport (raw pino JSON) + if (process.env.LOG_FORMAT === 'json') return undefined; + + // Try pino-pretty; fall back to raw JSON if not installed (e.g. production) + try { + require.resolve('pino-pretty'); + return { + target: 'pino-pretty', + options: { + colorize: true, + translateTime: 'HH:MM:ss', + ignore: 'pid,hostname,service,module', + messageFormat: '[{module}] {msg}', + singleLine: true, + }, + }; + } catch { + return undefined; + } +} + +export const rootLogger = pino({ + level: initialLevel, + transport: resolveTransport(), + base: { service: 'lettabot' }, +}); + +/** + * Logger interface that accepts console-style calling conventions. + * + * Wraps pino child loggers to handle: + * log.info('message') -> pino.info('message') + * log.info('message:', extra) -> pino.info({ extra }, 'message:') + * log.error('failed:', err) -> pino.error({ err }, 'failed:') + * log.info({ key: 1 }, 'msg') -> pino.info({ key: 1 }, 'msg') (native pino style also works) + */ +export interface Logger { + fatal(msg: string, ...args: unknown[]): void; + error(msg: string, ...args: unknown[]): void; + warn(msg: string, ...args: unknown[]): void; + info(msg: string, ...args: unknown[]): void; + debug(msg: string, ...args: unknown[]): void; + trace(msg: string, ...args: unknown[]): void; + /** Access the underlying pino child logger for advanced use */ + pino: pino.Logger; +} + +const LEVELS = ['fatal', 'error', 'warn', 'info', 'debug', 'trace'] as const; + +/** + * Wrap a pino child logger to accept console-style arguments. + * + * When extra arguments follow the message string, they are folded into + * a structured object: + * - Error instances go under `err` (pino convention for serialization) + * - Single non-error values go under `data` + * - Multiple values go under `data` as an array + * + * If the first argument is already an object (native pino style), it + * passes through unchanged. + */ +function wrapChild(child: pino.Logger): Logger { + const wrapper: Record = { pino: child }; + + for (const level of LEVELS) { + wrapper[level] = (first: unknown, ...rest: unknown[]) => { + // Native pino style: log.info({ key: 1 }, 'message') + if (typeof first === 'object' && first !== null && !(first instanceof Error)) { + (child[level] as Function)(first, ...rest); + return; + } + + // Console style with no extra args: log.info('message') + if (rest.length === 0) { + (child[level] as Function)(String(first)); + return; + } + + // Console style with extra args: log.info('message:', data) + // Build a merge object for structured output + const merge: Record = {}; + const extras: unknown[] = []; + + for (const arg of rest) { + if (arg instanceof Error) { + merge.err = arg; + } else { + extras.push(arg); + } + } + + if (extras.length === 1) { + merge.data = extras[0]; + } else if (extras.length > 1) { + merge.data = extras; + } + + if (Object.keys(merge).length > 0) { + (child[level] as Function)(merge, String(first)); + } else { + (child[level] as Function)(String(first)); + } + }; + } + + return wrapper as unknown as Logger; +} + +/** + * Create a child logger scoped to a module. + * + * The module name appears in pretty output as a prefix and is a + * searchable field in JSON output. + * + * @example + * const log = createLogger('Bot'); + * log.info('Initialized'); // [INFO] [Bot] Initialized + * log.error('Failed:', err); // [ERROR] [Bot] Failed: { err: ... } + * log.debug({ key: 'abc' }, 'Queue tick'); // native pino style also works + */ +export function createLogger(module: string): Logger { + return wrapChild(rootLogger.child({ module })); +} + +/** + * Update the root log level at runtime. + * + * Called from main.ts after config is loaded so that server.logLevel + * from lettabot.yaml takes effect (env vars still win). + */ +export function setLogLevel(level: string): void { + rootLogger.level = level; +} diff --git a/src/main.ts b/src/main.ts index 52f36e2..4cc2bd0 100644 --- a/src/main.ts +++ b/src/main.ts @@ -24,26 +24,35 @@ import { } from './config/index.js'; import { isLettaApiUrl } from './utils/server.js'; import { getDataDir, getWorkingDir, hasRailwayVolume } from './utils/paths.js'; +import { createLogger, setLogLevel } from './logger.js'; + +const log = createLogger('Config'); + const yamlConfig = loadAppConfigOrExit(); const configSource = existsSync(resolveConfigPath()) ? resolveConfigPath() : 'defaults + environment variables'; -console.log(`[Config] Loaded from ${configSource}`); +log.info(`Loaded from ${configSource}`); if (yamlConfig.agents?.length) { - console.log(`[Config] Mode: ${serverModeLabel(yamlConfig.server.mode)}, Agents: ${yamlConfig.agents.map(a => a.name).join(', ')}`); + log.info(`Mode: ${serverModeLabel(yamlConfig.server.mode)}, Agents: ${yamlConfig.agents.map(a => a.name).join(', ')}`); } else { - console.log(`[Config] Mode: ${serverModeLabel(yamlConfig.server.mode)}, Agent: ${yamlConfig.agent.name}`); + log.info(`Mode: ${serverModeLabel(yamlConfig.server.mode)}, Agent: ${yamlConfig.agent.name}`); } if (yamlConfig.agent?.model) { - console.warn('[Config] WARNING: agent.model in lettabot.yaml is deprecated and ignored. Use `lettabot model set ` instead.'); + log.warn('WARNING: agent.model in lettabot.yaml is deprecated and ignored. Use `lettabot model set ` instead.'); } applyConfigToEnv(yamlConfig); +// Apply configured log level (env vars take precedence -- handled inside setLogLevel) +if (yamlConfig.server.logLevel && !process.env.LOG_LEVEL && !process.env.LETTABOT_LOG_LEVEL) { + setLogLevel(yamlConfig.server.logLevel); +} + // Bridge DEBUG=1 to DEBUG_SDK so SDK-level dropped wire messages are visible if (process.env.DEBUG === '1' && !process.env.DEBUG_SDK) { process.env.DEBUG_SDK = '1'; } // Sync BYOK providers on startup (async, don't block) -syncProviders(yamlConfig).catch(err => console.error('[Config] Failed to sync providers:', err)); +syncProviders(yamlConfig).catch(err => log.error('Failed to sync providers:', err)); // Load agent ID from store and set as env var (SDK needs this) // Load agent ID from store file, or use LETTA_AGENT_ID env var as fallback @@ -66,11 +75,11 @@ if (existsSync(STORE_PATH)) { const currentUrl = currentBaseUrl.replace(/\/$/, ''); if (storedUrl !== currentUrl) { - console.warn(`\n⚠️ Server mismatch detected!`); - console.warn(` Stored agent was created on: ${storedUrl}`); - console.warn(` Current server: ${currentUrl}`); - console.warn(` The agent ${firstAgent.agentId} may not exist on this server.`); - console.warn(` Run 'lettabot onboard' to select or create an agent for this server.\n`); + log.warn(`⚠️ Server mismatch detected!`); + log.warn(` Stored agent was created on: ${storedUrl}`); + log.warn(` Current server: ${currentUrl}`); + log.warn(` The agent ${firstAgent.agentId} may not exist on this server.`); + log.warn(` Run 'lettabot onboard' to select or create an agent for this server.`); } } } else if (raw.agentId) { @@ -82,11 +91,11 @@ if (existsSync(STORE_PATH)) { const currentUrl = currentBaseUrl.replace(/\/$/, ''); if (storedUrl !== currentUrl) { - console.warn(`\n⚠️ Server mismatch detected!`); - console.warn(` Stored agent was created on: ${storedUrl}`); - console.warn(` Current server: ${currentUrl}`); - console.warn(` The agent ${raw.agentId} may not exist on this server.`); - console.warn(` Run 'lettabot onboard' to select or create an agent for this server.\n`); + log.warn(`⚠️ Server mismatch detected!`); + log.warn(` Stored agent was created on: ${storedUrl}`); + log.warn(` Current server: ${currentUrl}`); + log.warn(` The agent ${raw.agentId} may not exist on this server.`); + log.warn(` Run 'lettabot onboard' to select or create an agent for this server.`); } } } @@ -121,7 +130,7 @@ async function refreshTokensIfNeeded(): Promise { // Check if token needs refresh if (isTokenExpired(tokens) && hasRefreshToken(tokens)) { try { - console.log('[OAuth] Refreshing access token...'); + log.info('Refreshing access token...'); const newTokens = await refreshAccessToken( tokens.refreshToken!, tokens.deviceId, @@ -140,10 +149,10 @@ async function refreshTokensIfNeeded(): Promise { // Update env var with new token process.env.LETTA_API_KEY = newTokens.access_token; - console.log('[OAuth] Token refreshed successfully'); + log.info('Token refreshed successfully'); } catch (err) { - console.error('[OAuth] Failed to refresh token:', err instanceof Error ? err.message : err); - console.error('[OAuth] You may need to re-authenticate with `lettabot onboard`'); + log.error('Failed to refresh token:', err instanceof Error ? err.message : err); + log.error('You may need to re-authenticate with `lettabot onboard`'); } } } @@ -173,7 +182,7 @@ import { agentExists, findAgentByName, ensureNoToolApprovals } from './tools/let const configPath = resolveConfigPath(); const isContainerDeploy = !!(process.env.RAILWAY_ENVIRONMENT || process.env.RENDER || process.env.FLY_APP_NAME || process.env.DOCKER_DEPLOY); if (!existsSync(configPath) && !isContainerDeploy) { - console.log(` + log.info(` No config file found. Searched locations: 1. LETTABOT_CONFIG env var (not set) 2. ./lettabot.yaml (project-local - recommended) @@ -322,7 +331,7 @@ async function pruneAttachmentsDir(baseDir: string, maxAgeDays: number): Promise await walk(baseDir); if (deleted > 0) { - console.log(`[Attachments] Pruned ${deleted} file(s) older than ${maxAgeDays} days.`); + log.info(`Pruned ${deleted} file(s) older than ${maxAgeDays} days.`); } } @@ -341,9 +350,9 @@ function createChannelsForAgent( const hasTelegramMtproto = !!(agentConfig.channels['telegram-mtproto'] as any)?.apiId; if (hasTelegramBot && hasTelegramMtproto) { - console.error(`\n Error: Agent "${agentConfig.name}" has both telegram and telegram-mtproto configured.`); - console.error(' The Bot API adapter and MTProto adapter cannot run together.'); - console.error(' Choose one: telegram (bot token) or telegram-mtproto (user account).\n'); + log.error(`Agent "${agentConfig.name}" has both telegram and telegram-mtproto configured.`); + log.error(' The Bot API adapter and MTProto adapter cannot run together.'); + log.error('Choose one: telegram (bot token) or telegram-mtproto (user account).'); process.exit(1); } @@ -394,8 +403,8 @@ function createChannelsForAgent( if (agentConfig.channels.whatsapp?.enabled) { const selfChatMode = agentConfig.channels.whatsapp.selfChat ?? true; if (!selfChatMode) { - console.warn('[WhatsApp] WARNING: selfChatMode is OFF - bot will respond to ALL incoming messages!'); - console.warn('[WhatsApp] Only use this if this is a dedicated bot number, not your personal WhatsApp.'); + log.warn('WARNING: selfChatMode is OFF - bot will respond to ALL incoming messages!'); + log.warn('Only use this if this is a dedicated bot number, not your personal WhatsApp.'); } adapters.push(new WhatsAppAdapter({ sessionPath: agentConfig.channels.whatsapp.sessionPath || process.env.WHATSAPP_SESSION_PATH || './data/whatsapp-session', @@ -414,8 +423,8 @@ function createChannelsForAgent( if (agentConfig.channels.signal?.phone) { const selfChatMode = agentConfig.channels.signal.selfChat ?? true; if (!selfChatMode) { - console.warn('[Signal] WARNING: selfChatMode is OFF - bot will respond to ALL incoming messages!'); - console.warn('[Signal] Only use this if this is a dedicated bot number, not your personal Signal.'); + log.warn('WARNING: selfChatMode is OFF - bot will respond to ALL incoming messages!'); + log.warn('Only use this if this is a dedicated bot number, not your personal Signal.'); } adapters.push(new SignalAdapter({ phoneNumber: agentConfig.channels.signal.phone, @@ -460,10 +469,10 @@ function createGroupBatcher( const { intervals, instantIds, listeningIds } = collectGroupBatchingConfig(agentConfig.channels); if (instantIds.size > 0) { - console.log(`[Groups] Instant groups: ${[...instantIds].join(', ')}`); + log.info(`Instant groups: ${[...instantIds].join(', ')}`); } if (listeningIds.size > 0) { - console.log(`[Groups] Listening groups: ${[...listeningIds].join(', ')}`); + log.info(`Listening groups: ${[...listeningIds].join(', ')}`); } const batcher = intervals.size > 0 ? new GroupBatcher((msg, adapter) => { @@ -514,44 +523,44 @@ const globalConfig = { // Validate LETTA_API_KEY is set for API mode (docker mode doesn't require it) if (!isDockerServerMode(yamlConfig.server.mode) && !process.env.LETTA_API_KEY) { - console.error('\n Error: LETTA_API_KEY is required for Letta API.'); - console.error(' Get your API key from https://app.letta.com and set it as an environment variable.'); - console.error(' Or use docker mode: run "lettabot onboard" and select "Enter Docker server URL".\n'); + log.error('LETTA_API_KEY is required for Letta API.'); + log.error(' Get your API key from https://app.letta.com and set it as an environment variable.'); + log.error('Or use docker mode: run "lettabot onboard" and select "Enter Docker server URL".'); process.exit(1); } async function main() { - console.log('Starting LettaBot...\n'); + log.info('Starting LettaBot...'); // Log storage locations (helpful for Railway debugging) const dataDir = getDataDir(); if (hasRailwayVolume()) { - console.log(`[Storage] Railway volume detected at ${process.env.RAILWAY_VOLUME_MOUNT_PATH}`); + log.info(`Railway volume detected at ${process.env.RAILWAY_VOLUME_MOUNT_PATH}`); } - console.log(`[Storage] Data directory: ${dataDir}`); - console.log(`[Storage] Working directory: ${globalConfig.workingDir}`); + log.info(`Data directory: ${dataDir}`); + log.info(`Working directory: ${globalConfig.workingDir}`); // Normalize config to agents array const agents = normalizeAgents(yamlConfig); const isMultiAgent = agents.length > 1; - console.log(`[Config] ${agents.length} agent(s) configured: ${agents.map(a => a.name).join(', ')}`); + log.info(`${agents.length} agent(s) configured: ${agents.map(a => a.name).join(', ')}`); // Validate at least one agent has channels const totalChannels = agents.reduce((sum, a) => sum + Object.keys(a.channels).length, 0); if (totalChannels === 0) { - console.error('\n Error: No channels configured in any agent.'); - console.error(' Configure channels in lettabot.yaml or set environment variables.\n'); + log.error('No channels configured in any agent.'); + log.error('Configure channels in lettabot.yaml or set environment variables.'); process.exit(1); } const attachmentsDir = resolve(globalConfig.workingDir, 'attachments'); pruneAttachmentsDir(attachmentsDir, globalConfig.attachmentsMaxAgeDays).catch((err) => { - console.warn('[Attachments] Prune failed:', err); + log.warn('Prune failed:', err); }); if (globalConfig.attachmentsMaxAgeDays > 0) { const timer = setInterval(() => { pruneAttachmentsDir(attachmentsDir, globalConfig.attachmentsMaxAgeDays).catch((err) => { - console.warn('[Attachments] Prune failed:', err); + log.warn('Prune failed:', err); }); }, ATTACHMENTS_PRUNE_INTERVAL_MS); timer.unref?.(); @@ -571,7 +580,7 @@ async function main() { }; for (const agentConfig of agents) { - console.log(`\n[Setup] Configuring agent: ${agentConfig.name}`); + log.info(`Configuring agent: ${agentConfig.name}`); // Resolve memfs: YAML config takes precedence, then env var, then default false. // Default false prevents the SDK from auto-enabling memfs, which crashes on @@ -608,7 +617,7 @@ async function main() { // Apply explicit agent ID from config (before store verification) let initialStatus = bot.getStatus(); if (agentConfig.id && !initialStatus.agentId) { - console.log(`[Agent:${agentConfig.name}] Using configured agent ID: ${agentConfig.id}`); + log.info(`Using configured agent ID: ${agentConfig.id}`); bot.setAgentId(agentConfig.id); initialStatus = bot.getStatus(); } @@ -617,7 +626,7 @@ async function main() { if (initialStatus.agentId) { const exists = await agentExists(initialStatus.agentId); if (!exists) { - console.log(`[Agent:${agentConfig.name}] Stored agent ${initialStatus.agentId} not found on server`); + log.info(`Stored agent ${initialStatus.agentId} not found on server`); bot.reset(); initialStatus = bot.getStatus(); } @@ -633,27 +642,27 @@ async function main() { const found = await findAgentByName(agentConfig.name); if (found) { - console.log(`[Agent:${agentConfig.name}] Found existing agent: ${found.id}`); + log.info(`Found existing agent: ${found.id}`); bot.setAgentId(found.id); initialStatus = bot.getStatus(); } }); } catch (error) { - console.warn( - `[Agent:${agentConfig.name}] Discovery lock failed:`, + log.warn( + `Discovery lock failed for ${agentConfig.name}:`, error instanceof Error ? error.message : error ); } } if (!initialStatus.agentId) { - console.log(`[Agent:${agentConfig.name}] No agent found - will create on first message`); + log.info(`No agent found - will create on first message`); } // Disable tool approvals if (initialStatus.agentId) { ensureNoToolApprovals(initialStatus.agentId).catch(err => { - console.warn(`[Agent:${agentConfig.name}] Failed to check tool approvals:`, err); + log.warn(`Failed to check tool approvals:`, err); }); } @@ -747,7 +756,7 @@ async function main() { // Load/generate API key for CLI authentication const apiKey = loadOrGenerateApiKey(); - console.log(`[API] Key: ${apiKey.slice(0, 8)}... (set LETTABOT_API_KEY to customize)`); + log.info(`Key: ${apiKey.slice(0, 8)}... (set LETTABOT_API_KEY to customize)`); // Start API server - uses gateway for delivery const apiPort = parseInt(process.env.PORT || '8080', 10); @@ -781,7 +790,7 @@ async function main() { // Shutdown const shutdown = async () => { - console.log('\nShutting down...'); + log.info('Shutting down...'); services.groupBatchers.forEach(b => b.stop()); services.heartbeatServices.forEach(h => h.stop()); services.cronServices.forEach(c => c.stop()); @@ -795,6 +804,6 @@ async function main() { } main().catch((e) => { - console.error('Fatal error:', e); + log.error('Fatal error:', e); process.exit(1); }); diff --git a/src/polling/service.ts b/src/polling/service.ts index 626a49c..9f8445e 100644 --- a/src/polling/service.ts +++ b/src/polling/service.ts @@ -10,6 +10,9 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import type { AgentSession } from '../core/interfaces.js'; +import { createLogger } from '../logger.js'; + +const log = createLogger('Polling'); /** * Parse Gmail accounts from a string (comma-separated) or string array. * Deduplicates and trims whitespace. @@ -67,7 +70,7 @@ export class PollingService { : []; this.seenEmailIdsByAccount.set(account, new Set(ids)); } - console.log(`[Polling] Loaded seen email IDs for ${this.seenEmailIdsByAccount.size} account(s)`); + log.info(`Loaded seen email IDs for ${this.seenEmailIdsByAccount.size} account(s)`); return; } @@ -77,12 +80,12 @@ export class PollingService { const targetAccount = accounts[0]; if (targetAccount) { this.seenEmailIdsByAccount.set(targetAccount, new Set(data.ids)); - console.log(`[Polling] Migrated legacy seen emails to ${targetAccount}`); + log.info(`Migrated legacy seen emails to ${targetAccount}`); } } } } catch (e) { - console.error('[Polling] Failed to load seen emails:', e); + log.error('Failed to load seen emails:', e); } } @@ -104,7 +107,7 @@ export class PollingService { updatedAt: now, }, null, 2)); } catch (e) { - console.error('[Polling] Failed to save seen emails:', e); + log.error('Failed to save seen emails:', e); } } @@ -113,7 +116,7 @@ export class PollingService { */ start(): void { if (this.intervalId) { - console.log('[Polling] Already running'); + log.info('Already running'); return; } @@ -122,16 +125,16 @@ export class PollingService { if (this.config.gmail.accounts.length > 0) { enabledPollers.push(`Gmail (${this.config.gmail.accounts.length} account${this.config.gmail.accounts.length === 1 ? '' : 's'})`); } else { - console.log('[Polling] Gmail enabled but no accounts configured'); + log.info('Gmail enabled but no accounts configured'); } } if (enabledPollers.length === 0) { - console.log('[Polling] No pollers enabled'); + log.info('No pollers enabled'); return; } - console.log(`[Polling] Starting (every ${this.config.intervalMs / 1000}s): ${enabledPollers.join(', ')}`); + log.info(`Starting (every ${this.config.intervalMs / 1000}s): ${enabledPollers.join(', ')}`); // Run immediately on start this.poll(); @@ -147,7 +150,7 @@ export class PollingService { if (this.intervalId) { clearInterval(this.intervalId); this.intervalId = null; - console.log('[Polling] Stopped'); + log.info('Stopped'); } } @@ -185,7 +188,7 @@ export class PollingService { }); if (result.status !== 0) { - console.log(`[Polling] Gmail check failed for ${account}: ${result.stderr || 'unknown error'}`); + log.info(`Gmail check failed for ${account}: ${result.stderr || 'unknown error'}`); return; } @@ -216,11 +219,11 @@ export class PollingService { // Only notify if there are NEW emails we haven't seen before if (newEmails.length === 0) { - console.log(`[Polling] No new emails for ${account} (${currentEmailIds.size} unread total)`); + log.info(`No new emails for ${account} (${currentEmailIds.size} unread total)`); return; } - console.log(`[Polling] Found ${newEmails.length} NEW email(s) for ${account}!`); + log.info(`Found ${newEmails.length} NEW email(s) for ${account}!`); // Build output with header + new emails only const header = lines[0]; @@ -244,11 +247,11 @@ export class PollingService { const response = await this.bot.sendToAgent(message); // Log response but do NOT auto-deliver (silent mode) - console.log(`[Polling] Agent finished (SILENT MODE)`); - console.log(` - Response: ${response?.slice(0, 100)}${(response?.length || 0) > 100 ? '...' : ''}`); - console.log(` - (Response NOT auto-delivered - agent uses lettabot-message CLI)`) + log.info(`Agent finished (SILENT MODE)`); + log.info(` - Response: ${response?.slice(0, 100)}${(response?.length || 0) > 100 ? '...' : ''}`); + log.info(` - (Response NOT auto-delivered - agent uses lettabot-message CLI)`) } catch (e) { - console.error(`[Polling] Gmail error for ${account}:`, e); + log.error(`Gmail error for ${account}:`, e); } } } diff --git a/src/skills/loader.ts b/src/skills/loader.ts index cca76a4..b4112bd 100644 --- a/src/skills/loader.ts +++ b/src/skills/loader.ts @@ -17,6 +17,9 @@ export const SKILLS_SH_DIR = join(HOME, '.agents', 'skills'); // skills.sh globa // Bundled skills from the lettabot repo itself import { fileURLToPath } from 'node:url'; import { dirname } from 'node:path'; +import { createLogger } from '../logger.js'; + +const log = createLogger('Skills'); const __dirname = dirname(fileURLToPath(import.meta.url)); export const BUNDLED_SKILLS_DIR = resolve(__dirname, '../../skills'); // lettabot/skills/ @@ -87,7 +90,7 @@ export function parseSkillFile(filePath: string): SkillEntry | null { clawdbot, }; } catch (e) { - console.error(`Failed to parse skill at ${filePath}:`, e); + log.error(`Failed to parse skill at ${filePath}:`, e); return null; } } @@ -115,7 +118,7 @@ export function loadSkillsFromDir(skillsDir: string): SkillEntry[] { } } } catch (e) { - console.error(`Failed to load skills from ${skillsDir}:`, e); + log.error(`Failed to load skills from ${skillsDir}:`, e); } return skills; @@ -193,7 +196,7 @@ function installSkillsFromDir(sourceDir: string, targetDir: string): string[] { } } } catch (e) { - console.error(`[Skills] Failed to install from ${sourceDir}:`, e); + log.error(`Failed to install from ${sourceDir}:`, e); } return installed; @@ -276,7 +279,7 @@ export function installSkillsToWorkingDir(workingDir: string, config: SkillsInst } if (skillsToInstall.length === 0) { - console.log('[Skills] No feature-gated skills to install'); + log.info('No feature-gated skills to install'); return; } @@ -287,7 +290,7 @@ export function installSkillsToWorkingDir(workingDir: string, config: SkillsInst const installed = installSpecificSkills(skillsToInstall, sourceDirs, targetDir); if (installed.length > 0) { - console.log(`[Skills] Installed ${installed.length} skill(s): ${installed.join(', ')}`); + log.info(`Installed ${installed.length} skill(s): ${installed.join(', ')}`); } } @@ -335,6 +338,6 @@ export function installSkillsToAgent(agentId: string, config: SkillsInstallConfi const installed = installSpecificSkills(skillsToInstall, sourceDirs, targetDir); if (installed.length > 0) { - console.log(`[Skills] Installed ${installed.length} skill(s) to agent: ${installed.join(', ')}`); + log.info(`Installed ${installed.length} skill(s) to agent: ${installed.join(', ')}`); } } diff --git a/src/skills/wizard.ts b/src/skills/wizard.ts index de39551..6e1b571 100644 --- a/src/skills/wizard.ts +++ b/src/skills/wizard.ts @@ -9,6 +9,9 @@ import { installSkillDeps } from './install.js'; import { hasBinary, GLOBAL_SKILLS_DIR, SKILLS_SH_DIR } from './loader.js'; import type { NodeManager, SkillStatus } from './types.js'; +import { createLogger } from '../logger.js'; + +const log = createLogger('Wizard'); // Skills in working directory (where Letta Code looks) const WORKING_DIR = process.env.WORKING_DIR || '/tmp/lettabot'; const WORKING_SKILLS_DIR = join(WORKING_DIR, '.skills'); @@ -205,14 +208,14 @@ export async function listSkills(): Promise { const summary = getSkillsSummary([WORKING_SKILLS_DIR]); if (summary.total === 0) { - console.log('\nNo skills installed.\n'); - console.log('Install skills from ClawdHub:'); - console.log(' npm run skill:install weather'); - console.log(' npm run skill:install obsidian\n'); + log.info('No skills installed.'); + log.info('Install skills from ClawdHub:'); + log.info(' npm run skill:install weather'); + log.info(' npm run skill:install obsidian'); return; } - console.log(`\n📦 Installed Skills (${summary.total}):\n`); + log.info(`📦 Installed Skills (${summary.total}):`); for (const status of summary.skills) { const emoji = status.skill.emoji || '🧩'; @@ -239,17 +242,17 @@ export async function listSkills(): Promise { statusText = ''; } - console.log(` ${statusIcon} ${emoji} ${name}${statusText}`); + log.info(` ${statusIcon} ${emoji} ${name}${statusText}`); if (desc) { - console.log(` ${desc}`); + log.info(` ${desc}`); } } - console.log(`\nEligible: ${summary.eligible}/${summary.total}`); + log.info(`Eligible: ${summary.eligible}/${summary.total}`); if (summary.missingDeps > 0) { - console.log(`Run 'npm run skills' to install missing dependencies.\n`); + log.info(`Run 'npm run skills' to install missing dependencies.`); } else { - console.log(''); + log.info(''); } } @@ -264,36 +267,36 @@ export async function showStatus(): Promise { const enabledNames = new Set(enabledSummary.skills.map(s => s.skill.name)); const availableToImport = availableSummary.skills.filter(s => !enabledNames.has(s.skill.name)); - console.log('\n📊 Skills Status:\n'); + log.info('📊 Skills Status:'); // Currently enabled (agent-scoped) - console.log(` Enabled (${enabledSummary.total}):`); + log.info(` Enabled (${enabledSummary.total}):`); if (enabledSummary.total === 0) { - console.log(' (none)'); + log.info(' (none)'); } else { for (const status of enabledSummary.skills) { const emoji = status.skill.emoji || '🧩'; const name = status.skill.name; const icon = status.eligible ? '✓' : '✗'; - console.log(` ${icon} ${emoji} ${name}`); + log.info(` ${icon} ${emoji} ${name}`); } } - console.log(''); + log.info(''); // Available to import - console.log(` Available to import (${availableToImport.length}):`); + log.info(` Available to import (${availableToImport.length}):`); if (availableToImport.length === 0) { - console.log(' (none)'); + log.info(' (none)'); } else { for (const status of availableToImport) { const emoji = status.skill.emoji || '🧩'; const name = status.skill.name; - console.log(` ${emoji} ${name}`); + log.info(` ${emoji} ${name}`); } } - console.log(''); - console.log(` To enable: lettabot skills enable `); - console.log(` Skills dir: ${WORKING_SKILLS_DIR}\n`); + log.info(''); + log.info(` To enable: lettabot skills enable `); + log.info(` Skills dir: ${WORKING_SKILLS_DIR}`); } diff --git a/src/tools/letta-api.ts b/src/tools/letta-api.ts index 153b071..4b38cf1 100644 --- a/src/tools/letta-api.ts +++ b/src/tools/letta-api.ts @@ -6,6 +6,9 @@ import { Letta } from '@letta-ai/letta-client'; +import { createLogger } from '../logger.js'; + +const log = createLogger('Letta-api'); const LETTA_BASE_URL = process.env.LETTA_BASE_URL || 'https://api.letta.com'; function getClient(): Letta { @@ -110,7 +113,7 @@ export async function getAgentModel(agentId: string): Promise { const agent = await client.agents.retrieve(agentId); return agent.model ?? null; } catch (e) { - console.error('[Letta API] Failed to get agent model:', e); + log.error('Failed to get agent model:', e); return null; } } @@ -124,7 +127,7 @@ export async function updateAgentModel(agentId: string, model: string): Promise< await client.agents.update(agentId, { model }); return true; } catch (e) { - console.error('[Letta API] Failed to update agent model:', e); + log.error('Failed to update agent model:', e); return false; } } @@ -138,7 +141,7 @@ export async function updateAgentName(agentId: string, name: string): Promise { } return null; } catch (e) { - console.error('[Letta API] Failed to get last run time:', e); + log.error('Failed to get last run time:', e); return null; } } @@ -208,7 +211,7 @@ export async function listAgents(query?: string): Promise and ToolCallDelta formats const rawToolCalls = pending.tool_calls; @@ -311,11 +314,11 @@ export async function getPendingApprovals( messageId: pending.id, }); } - console.log(`[Letta API] Extracted ${approvals.length} pending approval(s): ${approvals.map(a => a.toolName).join(', ')}`); + log.info(`Extracted ${approvals.length} pending approval(s): ${approvals.map(a => a.toolName).join(', ')}`); return approvals; } } catch (e) { - console.warn('[Letta API] Failed to retrieve agent pending_approval, falling back to run scan:', e); + log.warn('Failed to retrieve agent pending_approval, falling back to run scan:', e); } // First, check for runs with 'requires_approval' stop reason @@ -391,7 +394,7 @@ export async function getPendingApprovals( return pendingApprovals; } catch (e) { - console.error('[Letta API] Failed to get pending approvals:', e); + log.error('Failed to get pending approvals:', e); return []; } } @@ -425,16 +428,16 @@ export async function rejectApproval( streaming: false, }); - console.log(`[Letta API] Rejected approval for tool call ${approval.toolCallId}`); + log.info(`Rejected approval for tool call ${approval.toolCallId}`); return true; } catch (e) { const err = e as { status?: number; error?: { detail?: string } }; const detail = err?.error?.detail || ''; if (err?.status === 400 && detail.includes('No tool call is currently awaiting approval')) { - console.warn(`[Letta API] Approval already resolved for tool call ${approval.toolCallId}`); + log.warn(`Approval already resolved for tool call ${approval.toolCallId}`); return true; } - console.error('[Letta API] Failed to reject approval:', e); + log.error('Failed to reject approval:', e); return false; } } @@ -453,10 +456,10 @@ export async function cancelRuns( await client.agents.messages.cancel(agentId, { run_ids: runIds, }); - console.log(`[Letta API] Cancelled runs for agent ${agentId}${runIds ? ` (${runIds.join(', ')})` : ''}`); + log.info(`Cancelled runs for agent ${agentId}${runIds ? ` (${runIds.join(', ')})` : ''}`); return true; } catch (e) { - console.error('[Letta API] Failed to cancel runs:', e); + log.error('Failed to cancel runs:', e); return false; } } @@ -556,10 +559,10 @@ export async function disableToolApproval( agent_id: agentId, requires_approval: false, } as unknown as Parameters[1]); - console.log(`[Letta API] Disabled approval requirement for tool ${toolName} on agent ${agentId}`); + log.info(`Disabled approval requirement for tool ${toolName} on agent ${agentId}`); return true; } catch (e) { - console.error(`[Letta API] Failed to disable tool approval for ${toolName}:`, e); + log.error(`Failed to disable tool approval for ${toolName}:`, e); return false; } } @@ -589,7 +592,7 @@ export async function getAgentTools(agentId: string): Promise { const tools = await getAgentTools(agentId); const approvalTools = tools.filter(t => t.requiresApproval); if (approvalTools.length > 0) { - console.log(`[Letta API] Found ${approvalTools.length} tool(s) requiring approval: ${approvalTools.map(t => t.name).join(', ')}`); - console.log('[Letta API] Disabling tool approvals for headless operation...'); + log.info(`Found ${approvalTools.length} tool(s) requiring approval: ${approvalTools.map(t => t.name).join(', ')}`); + log.info('Disabling tool approvals for headless operation...'); await disableAllToolApprovals(agentId); } } catch (e) { - console.warn('[Letta API] Failed to check/disable tool approvals:', e); + log.warn('Failed to check/disable tool approvals:', e); } } @@ -713,7 +716,7 @@ export async function recoverOrphanedConversationApproval( const isStuckApproval = status === 'running' && stopReason === 'requires_approval'; if (isTerminated || isAbandonedApproval || isStuckApproval) { - console.log(`[Letta API] Found ${approvals.length} blocking approval(s) from ${status}/${stopReason} run ${runId}`); + log.info(`Found ${approvals.length} blocking approval(s) from ${status}/${stopReason} run ${runId}`); // Send denial for each unresolved tool call const approvalResponses = approvals.map(a => ({ @@ -743,10 +746,10 @@ export async function recoverOrphanedConversationApproval( if (activeRunIds.length > 0) { cancelled = await cancelRuns(agentId, activeRunIds); if (cancelled) { - console.log(`[Letta API] Cancelled ${activeRunIds.length} active conversation run(s) after approval denial`); + log.info(`Cancelled ${activeRunIds.length} active conversation run(s) after approval denial`); } } else { - console.log(`[Letta API] No active runs to cancel for conversation ${conversationId}`); + log.info(`No active runs to cancel for conversation ${conversationId}`); } recoveredCount += approvals.length; @@ -756,20 +759,20 @@ export async function recoverOrphanedConversationApproval( details.push(`Run ${runId} is ${status}/${stopReason} - not orphaned`); } } catch (runError) { - console.warn(`[Letta API] Failed to check run ${runId}:`, runError); + log.warn(`Failed to check run ${runId}:`, runError); details.push(`Failed to check run ${runId}`); } } const detailStr = details.join('; '); if (recoveredCount > 0) { - console.log(`[Letta API] Recovered ${recoveredCount} orphaned approval(s): ${detailStr}`); + log.info(`Recovered ${recoveredCount} orphaned approval(s): ${detailStr}`); return { recovered: true, details: detailStr }; } return { recovered: false, details: detailStr }; } catch (e) { - console.error('[Letta API] Failed to recover orphaned conversation approval:', e); + log.error('Failed to recover orphaned conversation approval:', e); return { recovered: false, details: `Error: ${e instanceof Error ? e.message : String(e)}` }; } } @@ -784,10 +787,10 @@ export async function disableAllToolApprovals(agentId: string): Promise if (success) disabled++; } - console.log(`[Letta API] Disabled approval for ${disabled}/${tools.length} tools on agent ${agentId}`); + log.info(`Disabled approval for ${disabled}/${tools.length} tools on agent ${agentId}`); return disabled; } catch (e) { - console.error('[Letta API] Failed to disable all tool approvals:', e); + log.error('Failed to disable all tool approvals:', e); return 0; } } diff --git a/src/transcription/openai.ts b/src/transcription/openai.ts index ff4c99e..d4f4e4e 100644 --- a/src/transcription/openai.ts +++ b/src/transcription/openai.ts @@ -14,6 +14,9 @@ import { writeFileSync, readFileSync, unlinkSync, mkdirSync, readdirSync } from import { join } from 'node:path'; import { tmpdir } from 'node:os'; +import { createLogger } from '../logger.js'; + +const log = createLogger('Transcription'); // Whisper API limit is 25MB, we use 20MB to be safe const MAX_FILE_SIZE = 20 * 1024 * 1024; // Chunk duration in seconds (10 minutes) @@ -59,7 +62,7 @@ function isFfmpegAvailable(): boolean { ffmpegAvailable = true; } catch { ffmpegAvailable = false; - console.warn('[Transcription] ffmpeg not found - audio conversion will be skipped'); + log.warn('ffmpeg not found - audio conversion will be skipped'); } } return ffmpegAvailable; @@ -91,7 +94,7 @@ export async function transcribeAudio( // Tier 1: Try format mapping first (just rename, no conversion) const mapped = FORMAT_MAP[ext]; if (mapped) { - console.log(`[Transcription] Trying .${ext} as .${mapped} (no conversion)`); + log.info(`Trying .${ext} as .${mapped} (no conversion)`); finalExt = mapped; // Try without conversion first @@ -99,20 +102,20 @@ export async function transcribeAudio( const text = await attemptTranscription(finalBuffer, filename, finalExt); return { success: true, text }; } catch (renameError) { - console.log(`[Transcription] Rename approach failed: ${renameError instanceof Error ? renameError.message : renameError}`); + log.info(`Rename approach failed: ${renameError instanceof Error ? renameError.message : renameError}`); // Tier 2: Try ffmpeg conversion if available if (isFfmpegAvailable()) { - console.log(`[Transcription] Attempting ffmpeg conversion .${ext} → .mp3`); + log.info(`Attempting ffmpeg conversion .${ext} → .mp3`); try { finalBuffer = convertAudioToMp3(audioBuffer, ext); finalExt = 'mp3'; const text = await attemptTranscription(finalBuffer, filename, finalExt); - console.log(`[Transcription] Success after conversion, text length: ${text?.length || 0}`); + log.info(`Success after conversion, text length: ${text?.length || 0}`); return { success: true, text }; } catch (conversionError: unknown) { // Both approaches failed - console.error(`[Transcription] Failed after conversion:`, conversionError); + log.error(`Failed after conversion:`, conversionError); const errorMsg = conversionError instanceof Error ? conversionError.message : (conversionError ? String(conversionError) : 'Unknown error after conversion'); @@ -134,7 +137,7 @@ export async function transcribeAudio( } else { // No mapping available if (isFfmpegAvailable()) { - console.log(`[Transcription] Converting .${ext} to .mp3 with ffmpeg`); + log.info(`Converting .${ext} to .mp3 with ffmpeg`); finalBuffer = convertAudioToMp3(audioBuffer, ext); finalExt = 'mp3'; } else { @@ -149,7 +152,7 @@ export async function transcribeAudio( // Check file size and chunk if needed if (finalBuffer.length > MAX_FILE_SIZE) { - console.log(`[Transcription] File too large (${(finalBuffer.length / 1024 / 1024).toFixed(1)}MB), splitting into chunks`); + log.info(`File too large (${(finalBuffer.length / 1024 / 1024).toFixed(1)}MB), splitting into chunks`); const text = await transcribeInChunks(finalBuffer, finalExt); return { success: true, text }; } @@ -216,7 +219,7 @@ async function transcribeInChunks(audioBuffer: Buffer, ext: string): Promise f.startsWith('chunk-') && f.endsWith('.mp3')) .sort(); - console.log(`[Transcription] Split into ${chunkFiles.length} chunks`); + log.info(`Split into ${chunkFiles.length} chunks`); if (chunkFiles.length === 0) { throw new Error('Failed to split audio into chunks'); @@ -228,7 +231,7 @@ async function transcribeInChunks(audioBuffer: Buffer, ext: string): Promise