tiger-agent 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,15 +1,30 @@
1
1
  # 🐯 Tiger Agent
2
2
 
3
- **Made by AI Research Group, Department of Civil Engineering, King Mongkut's University of Technology Thonburi (KMUTT)**
3
+ [![npm version](https://img.shields.io/npm/v/tiger-agent.svg)](https://www.npmjs.com/package/tiger-agent)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
5
+ [![Node.js](https://img.shields.io/badge/node-%3E%3D18-brightgreen.svg)](https://nodejs.org)
6
+
7
+ **Cognitive AI Agent** with persistent long-term memory, multi-provider LLM support, self-learning, and Telegram bot integration — designed for 24/7 autonomous operation on Linux.
4
8
 
5
- Tiger is a **Cognitive AI Agent** with persistent long-term memory, multi-provider LLM support, self-learning, and Telegram bot integration — designed for 24/7 autonomous operation on Linux.
9
+ Made by **AI Research Group, Department of Civil Engineering, KMUTT**
10
+
11
+ ---
12
+
13
+ ## 🆕 What's New — v0.2.0
14
+
15
+ - **npm global install** — `npm install -g tiger-agent`, no git clone needed
16
+ - **Multi-provider LLM** — 5 providers (Kimi, Z.ai, MiniMax, Claude, Moonshot) with auto-fallback
17
+ - **Daily token limits** — per-provider limits with automatic switching at UTC midnight
18
+ - **`tiger` CLI** — unified command: `tiger onboard`, `tiger start`, `tiger telegram`, `tiger stop`
19
+ - **Telegram `/api` & `/tokens`** — switch providers and monitor usage from chat
20
+ - **Encrypted secrets** — optional at-rest encryption for API keys
6
21
 
7
22
  ---
8
23
 
9
24
  ## 🎯 Why Tiger?
10
25
 
11
- | Feature | Tiger Bot | Generic AI Assistants |
12
- |---------|-----------|----------------------|
26
+ | Feature | Tiger | Generic AI Assistants |
27
+ |---------|-------|-----------------------|
13
28
  | **Memory** | Persistent lifetime memory (Vector DB) | Forgets when session ends |
14
29
  | **Learning** | Self-training every 12 hours | Static, never improves |
15
30
  | **Security** | Audit logs + Encryption + Hardened perms | No audit trail |
@@ -24,24 +39,22 @@ Tiger is a **Cognitive AI Agent** with persistent long-term memory, multi-provid
24
39
  npm install -g tiger-agent
25
40
  ```
26
41
 
27
- No git clone needed.
28
-
29
42
  ---
30
43
 
31
44
  ## 🚀 Quick Start
32
45
 
33
46
  ```bash
34
- tiger onboard # First-time setup wizard
47
+ tiger onboard # First-time setup wizard (run once)
35
48
  tiger start # Start CLI chat
36
49
  ```
37
50
 
38
51
  CLI exit: `/exit` or `/quit`
39
52
 
40
- For Telegram:
53
+ **Telegram bot:**
41
54
 
42
55
  ```bash
43
- tiger telegram # Foreground
44
- tiger telegram --background # Background daemon
56
+ tiger telegram # Start in foreground
57
+ tiger telegram --background # Start as background daemon
45
58
  tiger stop # Stop daemon
46
59
  tiger status # Check daemon status
47
60
  ```
@@ -58,13 +71,13 @@ tiger status # Check daemon status
58
71
 
59
72
  ## 🎮 Run Modes
60
73
 
61
- | Mode | Global install | From source | Use Case |
62
- |------|---------------|-------------|----------|
63
- | **CLI** | `tiger start` | `npm run cli` | Interactive terminal |
64
- | **Telegram** | `tiger telegram` | `npm run telegram` | Bot foreground |
65
- | **Background** | `tiger telegram --background` | `npm run telegram:bg` | 24/7 daemon |
66
- | **Stop** | `tiger stop` | `npm run telegram:stop` | Kill daemon |
67
- | **Status** | `tiger status` | | Check daemon |
74
+ | Mode | Command | Use Case |
75
+ |------|---------|----------|
76
+ | **CLI** | `tiger start` | Interactive terminal chat |
77
+ | **Telegram** | `tiger telegram` | Telegram bot (foreground) |
78
+ | **Background** | `tiger telegram --background` | 24/7 daemon |
79
+ | **Stop** | `tiger stop` | Kill background daemon |
80
+ | **Status** | `tiger status` | Check if daemon is running |
68
81
 
69
82
  Background logs: `~/.tiger/logs/telegram-supervisor.log`
70
83
 
@@ -72,12 +85,13 @@ Background logs: `~/.tiger/logs/telegram-supervisor.log`
72
85
 
73
86
  ## 🔧 Setup Wizard
74
87
 
75
- `tiger onboard` (global) or `npm run setup` (from source) configures:
88
+ `tiger onboard` creates and configures:
76
89
 
77
- - `~/.tiger/.env` — non-secret settings
78
- - `~/.tiger/.env.secrets` — API keys and tokens (mode 600)
90
+ - `~/.tiger/.env` — settings and provider config
91
+ - `~/.tiger/.env.secrets` — API keys (mode 600, gitignored)
79
92
 
80
- Options during setup:
93
+ Setup options:
94
+ - Choose LLM provider and API keys
81
95
  - Persistent vs temporary vector DB
82
96
  - Optional `sqlite-vec` acceleration
83
97
  - Optional encrypted secrets file
@@ -95,6 +109,13 @@ Options during setup:
95
109
  | `ALLOW_SKILL_INSTALL` | `false` | Enable ClawHub skill install |
96
110
  | `VECTOR_DB_PATH` | `~/.tiger/db/memory.sqlite` | SQLite vector DB path |
97
111
  | `DATA_DIR` | `~/.tiger/data` | Context files directory |
112
+ | `OWN_SKILL_UPDATE_HOURS` | `24` | Hours between `ownskill.md` regenerations (min 1) |
113
+ | `SOUL_UPDATE_HOURS` | `24` | Hours between `soul.md` regenerations (min 1) |
114
+ | `REFLECTION_UPDATE_HOURS` | `12` | Hours between reflection cycles (min 1) |
115
+ | `MEMORY_INGEST_EVERY_TURNS` | `2` | Ingest durable memory every N conversation turns |
116
+ | `MEMORY_INGEST_MIN_CHARS` | `140` | Minimum combined chars in a turn to trigger memory ingest |
117
+
118
+ Config lives in `~/.tiger/.env` after running `tiger onboard`.
98
119
 
99
120
  ---
100
121
 
@@ -104,15 +125,15 @@ Tiger supports **5 providers** with automatic fallback and daily token limits.
104
125
 
105
126
  ### Supported Providers
106
127
 
107
- | Provider | ID | Default Model | API Key |
108
- |----------|----|--------------|---------|
128
+ | Provider | ID | Default Model | API Key Variable |
129
+ |----------|----|--------------|-----------------|
109
130
  | Kimi Code | `kimi` | `k2p5` | `KIMI_CODE_API_KEY` |
110
131
  | Kimi Moonshot | `moonshot` | `kimi-k1` | `MOONSHOT_API_KEY` |
111
- | Z.ai (Zhipu) | `zai` | `glm-5` | `ZAI_API_KEY` (format: `id.secret`) |
132
+ | Z.ai (Zhipu) | `zai` | `glm-4.7` | `ZAI_API_KEY` (format: `id.secret`) |
112
133
  | MiniMax | `minimax` | `abab6.5s-chat` | `MINIMAX_API_KEY` |
113
134
  | Claude (Anthropic) | `claude` | `claude-sonnet-4-6` | `CLAUDE_API_KEY` |
114
135
 
115
- ### `.env` Configuration
136
+ ### `.env` Example
116
137
 
117
138
  ```env
118
139
  ACTIVE_PROVIDER=zai
@@ -137,8 +158,8 @@ MOONSHOT_TOKEN_LIMIT=100000
137
158
  1. Uses `ACTIVE_PROVIDER` for all requests
138
159
  2. On **429** (rate limit) or **403** (quota exceeded) — switches to next in `PROVIDER_ORDER`
139
160
  3. When a provider's daily token limit is reached — skipped for the rest of the day
140
- 4. Providers with no API key are silently skipped
141
- 5. Token usage tracked in `~/.tiger/db/token_usage.json`, resets at UTC midnight
161
+ 4. Providers with no API key configured are silently skipped
162
+ 5. Token usage resets at UTC midnight (`~/.tiger/db/token_usage.json`)
142
163
 
143
164
  ---
144
165
 
@@ -147,44 +168,60 @@ MOONSHOT_TOKEN_LIMIT=100000
147
168
  | Command | Description |
148
169
  |---------|-------------|
149
170
  | `/api` | Show all providers with token usage |
150
- | `/api <id>` | Switch provider (e.g. `/api claude`) |
171
+ | `/api <id>` | Switch active provider (e.g. `/api claude`) |
151
172
  | `/tokens` | Show today's token usage per provider |
173
+ | `/limit` | Show daily token limits per provider |
174
+ | `/limit <provider> <n>` | Set daily token limit (0 = unlimited, e.g. `/limit zai 100000`) |
152
175
  | `/help` | Show all commands |
153
176
 
154
177
  ---
155
178
 
156
179
  ## 🧠 Memory & Context
157
180
 
158
- Context files loaded every turn (from `~/.tiger/data/`):
181
+ ### Context Files
182
+
183
+ Loaded on every turn from `~/.tiger/data/`:
159
184
 
160
- - `soul.md` Agent personality
161
- - `human.md` / `human2.md` — User profile
162
- - `ownskill.md` Known skills (auto-refreshed every 24h)
185
+ | File | Purpose |
186
+ |------|---------|
187
+ | `soul.md` | Agent identity, principles, and stable preferences |
188
+ | `human.md` | User profile — goals, patterns, preferences |
189
+ | `human2.md` | Running update log written after every conversation turn |
190
+ | `ownskill.md` | Known skills, workflows, and lessons learned |
163
191
 
164
- Auto-refresh cycles (configurable via `.env`):
192
+ ### Auto-Refresh Cycles
165
193
 
166
- | Cycle | Variable | Default |
167
- |-------|----------|---------|
168
- | Skill summary | `OWN_SKILL_UPDATE_HOURS` | 24h |
169
- | Soul refresh | `SOUL_UPDATE_HOURS` | 24h |
170
- | Reflection | `REFLECTION_UPDATE_HOURS` | 12h |
171
- | Memory ingest | `MEMORY_INGEST_EVERY_TURNS` | every N turns |
194
+ Tiger periodically regenerates these files using the LLM. All durations are configurable in `.env` (minimum 1 hour).
172
195
 
173
- Vector memory DB: `~/.tiger/db/memory.sqlite`
196
+ | Cycle | `.env` Variable | Default | What It Does |
197
+ |-------|----------------|---------|--------------|
198
+ | **Skill summary** | `OWN_SKILL_UPDATE_HOURS` | `24` | Rewrites `ownskill.md` with updated skills, workflows, and lessons derived from recent conversations |
199
+ | **Soul refresh** | `SOUL_UPDATE_HOURS` | `24` | Rewrites `soul.md` to reflect any evolved identity, operating rules, or preferences |
200
+ | **Reflection** | `REFLECTION_UPDATE_HOURS` | `12` | Extracts long-term memory bullets from recent messages and appends them to `soul.md`, `human.md`, `ownskill.md`, and the vector DB |
201
+ | **Memory ingest** | `MEMORY_INGEST_EVERY_TURNS` | `2` | After every N conversation turns, distils durable preference or workflow facts into the vector DB |
202
+
203
+ > **Note:** Refresh timers for `soul.md` and `ownskill.md` are tracked in the DB (not file modification time), so reflection appends do not reset the 24-hour clock.
204
+
205
+ Example `.env` — tighten cycles for an active bot:
174
206
 
175
- Optional `sqlite-vec` acceleration:
176
207
  ```env
177
- SQLITE_VEC_EXTENSION=/path/to/sqlite_vec
208
+ OWN_SKILL_UPDATE_HOURS=12
209
+ SOUL_UPDATE_HOURS=12
210
+ REFLECTION_UPDATE_HOURS=6
211
+ MEMORY_INGEST_EVERY_TURNS=2
212
+ MEMORY_INGEST_MIN_CHARS=140
178
213
  ```
179
214
 
180
- Memory commands (from source):
181
- ```bash
182
- npm run memory:init # Initialize DB
183
- npm run memory:stats # Show stats
184
- npm run memory:migrate # Migrate from /tmp/
185
- npm run memory:vec:check # Check sqlite-vec
215
+ ### Vector Memory
216
+
217
+ Stored in `~/.tiger/db/memory.sqlite`. Optional `sqlite-vec` extension enables fast ANN search:
218
+
219
+ ```env
220
+ SQLITE_VEC_EXTENSION=/path/to/sqlite_vec
186
221
  ```
187
222
 
223
+ Without it, Tiger falls back to cosine similarity in Python — slower but fully functional.
224
+
188
225
  ---
189
226
 
190
227
  ## 🛠️ Built-in Tools
@@ -202,17 +239,19 @@ npm run memory:vec:check # Check sqlite-vec
202
239
 
203
240
  | Feature | Detail |
204
241
  |---------|--------|
205
- | **Credential Storage** | Externalized to `~/.tiger/.env.secrets` (mode 600) |
242
+ | **Credential Storage** | `~/.tiger/.env.secrets` with mode 600 |
206
243
  | **Database Security** | `~/.tiger/db/` with hardened permissions |
207
244
  | **Audit Logging** | Sanitized skill logs at `~/.tiger/logs/audit.log` |
208
245
  | **Auto Backup** | Daily SQLite backups, 30-day retention |
209
246
  | **Secret Rotation** | Built-in 90-day rotation reminders |
210
247
 
211
- ### Encrypted Secrets (optional)
248
+ ### Optional: Encrypted Secrets
212
249
 
213
250
  ```bash
214
- export SECRETS_PASSPHRASE='your-long-passphrase'
215
- node scripts/encrypt-env.js --in .env.secrets --out .env.secrets.enc
251
+ # Run from ~/.tiger after onboard
252
+ export SECRETS_PASSPHRASE='your-passphrase'
253
+ node $(npm root -g)/tiger-agent/scripts/encrypt-env.js \
254
+ --in .env.secrets --out .env.secrets.enc
216
255
  rm .env.secrets
217
256
  ```
218
257
 
@@ -239,18 +278,18 @@ rm .env.secrets
239
278
  | Bot stuck on one provider | `/api <name>` in Telegram to switch manually |
240
279
  | Provider silently skipped | No API key set, or daily limit reached — check `/tokens` |
241
280
  | `401` auth error | Wrong or missing API key |
242
- | `403` quota error | Daily quota exhausted — auto-switches; top up billing or raise `*_TOKEN_LIMIT` |
281
+ | `403` quota error | Daily quota exhausted — auto-switches; raise `*_TOKEN_LIMIT` |
243
282
  | `429` rate limit | Auto-switches to next provider in `PROVIDER_ORDER` |
244
283
  | Z.ai auth fails | Key must be `id.secret` format (from Zhipu/BigModel console) |
245
- | Shell tool disabled | Set `ALLOW_SHELL=true` in `.env` |
246
- | Stuck processes | `pkill -f "node src/cli.js"` then restart |
284
+ | Shell tool disabled | Set `ALLOW_SHELL=true` in `~/.tiger/.env` |
285
+ | Stuck processes | `pkill -f tiger-agent` then restart |
247
286
  | Reset token counters | Delete `~/.tiger/db/token_usage.json` and restart |
248
287
 
249
288
  ---
250
289
 
251
290
  ## 📁 Data Directory
252
291
 
253
- After global install, all runtime data lives in `~/.tiger/`:
292
+ All runtime data lives in `~/.tiger/`:
254
293
 
255
294
  ```
256
295
  ~/.tiger/
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tiger-agent",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Cognitive AI agent with persistent memory, multi-provider LLM, and Telegram bot",
5
5
  "type": "commonjs",
6
6
  "main": "src/cli.js",
@@ -42,7 +42,7 @@ async function askHidden(prompt) {
42
42
  buf += ch; stdout.write('*');
43
43
  }
44
44
  }
45
- function cleanup() { stdin.off('data', onData); stdin.setRawMode(false); stdin.pause(); }
45
+ function cleanup() { stdin.off('data', onData); stdin.setRawMode(false); stdin.resume(); }
46
46
  stdin.on('data', onData);
47
47
  });
48
48
  }
@@ -181,7 +181,7 @@ Config will be saved to: ${TIGER_HOME}
181
181
  }
182
182
 
183
183
  // ── Active provider ────────────────────────────────────────────────────────
184
- console.log('\nAvailable providers: kimi, zai (Zhipu GLM-5), minimax, claude, moonshot');
184
+ console.log('\nAvailable providers: kimi, zai (Zhipu GLM-4.7), minimax, claude, moonshot');
185
185
  const activeProv = (await ask('Active provider (zai): ')).trim() || 'zai';
186
186
  const provOrder = (await ask(`Provider fallback order (${activeProv},claude,kimi,minimax,moonshot): `)).trim()
187
187
  || `${activeProv},claude,kimi,minimax,moonshot`;
@@ -231,8 +231,8 @@ Config will be saved to: ${TIGER_HOME}
231
231
  '',
232
232
  '# ── Z.ai (Zhipu GLM)',
233
233
  envLine('ZAI_API_KEY', zaiKey),
234
- envLine('ZAI_BASE_URL', 'https://open.bigmodel.cn/api/paas/v4'),
235
- envLine('ZAI_MODEL', 'glm-5'),
234
+ envLine('ZAI_BASE_URL', 'https://api.z.ai/api/coding/paas/v4'),
235
+ envLine('ZAI_MODEL', 'glm-4.7'),
236
236
  envLine('ZAI_TIMEOUT_MS', '30000'),
237
237
  '',
238
238
  '# ── MiniMax',
@@ -124,22 +124,21 @@ function buildSystemPrompt(contextText, memoriesText) {
124
124
  .join('\n');
125
125
  }
126
126
 
127
- function shouldRefreshFile(filePath, updateHours) {
128
- try {
129
- const stat = fs.statSync(filePath);
130
- const maxAgeMs = updateHours * 60 * 60 * 1000;
131
- return Date.now() - stat.mtimeMs >= maxAgeMs;
132
- } catch (err) {
133
- return true;
134
- }
127
+ const OWNSKILL_META_KEY = 'ownskill_last_updated_ts';
128
+ const SOUL_META_KEY = 'soul_last_updated_ts';
129
+
130
+ function shouldRefreshByMeta(metaKey, updateHours) {
131
+ const lastTs = Number(getMeta(metaKey, 0) || 0);
132
+ if (!lastTs) return true;
133
+ return Date.now() - lastTs >= updateHours * 60 * 60 * 1000;
135
134
  }
136
135
 
137
136
  function shouldRefreshOwnSkill() {
138
- return shouldRefreshFile(ownSkillPath, ownSkillUpdateHours);
137
+ return shouldRefreshByMeta(OWNSKILL_META_KEY, ownSkillUpdateHours);
139
138
  }
140
139
 
141
140
  async function maybeUpdateOwnSkillSummary(conversationIdValue) {
142
- if (!shouldRefreshOwnSkill()) return;
141
+ if (!shouldRefreshByMeta(OWNSKILL_META_KEY, ownSkillUpdateHours)) return;
143
142
 
144
143
  const recent = getRecentMessages(conversationIdValue, 80);
145
144
  const transcript = recent
@@ -175,10 +174,11 @@ async function maybeUpdateOwnSkillSummary(conversationIdValue) {
175
174
  const next = String(message.content || '').trim();
176
175
  if (!next) return;
177
176
  fs.writeFileSync(path.resolve(ownSkillPath), `${next}\n`, 'utf8');
177
+ setMeta(OWNSKILL_META_KEY, Date.now());
178
178
  }
179
179
 
180
180
  async function maybeUpdateSoulSummary(conversationIdValue) {
181
- if (!shouldRefreshFile(soulPath, soulUpdateHours)) return;
181
+ if (!shouldRefreshByMeta(SOUL_META_KEY, soulUpdateHours)) return;
182
182
 
183
183
  const recent = getRecentMessages(conversationIdValue, 80);
184
184
  const transcript = recent
@@ -210,6 +210,7 @@ async function maybeUpdateSoulSummary(conversationIdValue) {
210
210
  const next = String(message.content || '').trim();
211
211
  if (!next) return;
212
212
  fs.writeFileSync(path.resolve(soulPath), `${next}\n`, 'utf8');
213
+ setMeta(SOUL_META_KEY, Date.now());
213
214
  }
214
215
 
215
216
  async function maybeIngestTurnMemory(conversationIdValue, userText, assistantText) {
@@ -155,13 +155,18 @@ function buildProviders(env) {
155
155
 
156
156
  zai: {
157
157
  id: 'zai',
158
- name: 'Z.ai (Zhipu)',
159
- baseUrl: (env.ZAI_BASE_URL || 'https://open.bigmodel.cn/api/paas/v4').replace(/\/$/, ''),
160
- chatModel: env.ZAI_MODEL || 'glm-5',
158
+ name: 'Z.ai',
159
+ baseUrl: (env.ZAI_BASE_URL || 'https://api.z.ai/api/coding/paas/v4').replace(/\/$/, ''),
160
+ chatModel: env.ZAI_MODEL || 'glm-4.7',
161
161
  embedModel: env.ZAI_EMBED_MODEL || '',
162
162
  apiKey: env.ZAI_API_KEY || '',
163
163
  userAgent: '',
164
- authHeaders: (key) => ({ Authorization: `Bearer ${zhipuJwt(key)}` }),
164
+ // api.z.ai uses plain Bearer; old bigmodel.cn used Zhipu JWT
165
+ authHeaders: (key) => {
166
+ const baseUrl = (env.ZAI_BASE_URL || '').toLowerCase();
167
+ if (baseUrl.includes('bigmodel.cn')) return { Authorization: `Bearer ${zhipuJwt(key)}` };
168
+ return { Authorization: `Bearer ${key}` };
169
+ },
165
170
  chatPath: '/chat/completions',
166
171
  embedPath: '/embeddings',
167
172
  formatRequest: standardFormat,
package/src/config.js CHANGED
@@ -79,16 +79,8 @@ const rawApiKey =
79
79
  ? process.env.KIMI_CODE_API_KEY || process.env.KIMI_API_KEY || ''
80
80
  : process.env.MOONSHOT_API_KEY || process.env.KIMI_API_KEY || '';
81
81
  const kimiApiKey = cleanEnvValue(rawApiKey);
82
- // Only hard-fail on missing kimi key when kimi/moonshot is the active provider.
83
- // If ACTIVE_PROVIDER is set to another provider, the key is optional.
84
82
  const activeProvider = cleanEnvValue(process.env.ACTIVE_PROVIDER || '').toLowerCase();
85
- const kimiIsActive = !activeProvider || activeProvider === 'kimi' || activeProvider === 'moonshot';
86
- if (!kimiApiKey && kimiIsActive) {
87
- if (kimiProvider === 'code') {
88
- throw new Error('Missing required env: KIMI_CODE_API_KEY (or KIMI_API_KEY)');
89
- }
90
- throw new Error('Missing required env: MOONSHOT_API_KEY (or KIMI_API_KEY)');
91
- }
83
+ // Missing API keys are allowed providers with no key are silently skipped at request time.
92
84
 
93
85
  const defaultBaseUrl =
94
86
  kimiProvider === 'code' ? 'https://api.kimi.com/coding/v1' : 'https://api.moonshot.cn/v1';
package/src/llmClient.js CHANGED
@@ -21,39 +21,56 @@ const tokenManager = require('./tokenManager');
21
21
 
22
22
  // ─── Low-level fetch wrapper ─────────────────────────────────────────────────
23
23
 
24
- async function fetchProvider(provider, endpoint, body) {
24
+ function sleep(ms) {
25
+ return new Promise((r) => setTimeout(r, ms));
26
+ }
27
+
28
+ async function fetchProvider(provider, endpoint, body, maxRetries = 3) {
25
29
  const key = provider.apiKey;
26
30
  const headers = { 'Content-Type': 'application/json', ...provider.authHeaders(key) };
27
31
  if (provider.userAgent) headers['User-Agent'] = provider.userAgent;
28
32
 
29
- const timeout = provider.timeout || 30000;
30
- const ctrl = new AbortController();
31
- const timer = setTimeout(() => ctrl.abort(), timeout);
32
-
33
- let res;
34
- try {
35
- res = await fetch(`${provider.baseUrl}${endpoint}`, {
36
- method: 'POST',
37
- headers,
38
- body: JSON.stringify(body),
39
- signal: ctrl.signal
40
- });
41
- } catch (err) {
42
- clearTimeout(timer);
43
- if (err && err.name === 'AbortError') {
44
- throw Object.assign(new Error(`Timeout after ${timeout}ms (${provider.name})`), { status: 0 });
33
+ let lastErr;
34
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
35
+ if (attempt > 0) {
36
+ const delay = Math.min(attempt * 2000, 10000); // 2s, 4s, 6s … capped at 10s
37
+ process.stderr.write(`[llm] 429 on ${provider.name}, retrying in ${delay}ms (attempt ${attempt}/${maxRetries})\n`);
38
+ await sleep(delay);
39
+ }
40
+
41
+ const timeout = provider.timeout || 30000;
42
+ const ctrl = new AbortController();
43
+ const timer = setTimeout(() => ctrl.abort(), timeout);
44
+
45
+ let res;
46
+ try {
47
+ res = await fetch(`${provider.baseUrl}${endpoint}`, {
48
+ method: 'POST',
49
+ headers,
50
+ body: JSON.stringify(body),
51
+ signal: ctrl.signal
52
+ });
53
+ } catch (err) {
54
+ clearTimeout(timer);
55
+ if (err && err.name === 'AbortError') {
56
+ throw Object.assign(new Error(`Timeout after ${timeout}ms (${provider.name})`), { status: 0 });
57
+ }
58
+ throw Object.assign(new Error(`Network error (${provider.name}): ${err.message}`), { status: 0 });
59
+ } finally {
60
+ clearTimeout(timer);
61
+ }
62
+
63
+ if (!res.ok) {
64
+ const text = await res.text().catch(() => '');
65
+ lastErr = Object.assign(new Error(`HTTP ${res.status} from ${provider.name}: ${text}`), { status: res.status });
66
+ if (res.status === 429 && attempt < maxRetries) continue; // retry on rate limit
67
+ throw lastErr;
45
68
  }
46
- throw Object.assign(new Error(`Network error (${provider.name}): ${err.message}`), { status: 0 });
47
- } finally {
48
- clearTimeout(timer);
49
- }
50
69
 
51
- if (!res.ok) {
52
- const text = await res.text().catch(() => '');
53
- throw Object.assign(new Error(`HTTP ${res.status} from ${provider.name}: ${text}`), { status: res.status });
70
+ return res.json();
54
71
  }
55
72
 
56
- return res.json();
73
+ throw lastErr;
57
74
  }
58
75
 
59
76
  // ─── chatCompletion ──────────────────────────────────────────────────────────
@@ -63,7 +80,7 @@ async function chatCompletion(messages, options = {}) {
63
80
  const activeId = tokenManager.getCurrentProvider();
64
81
  const candidates = [activeId, ...tokenManager.getNextCandidates(activeId)];
65
82
 
66
- let lastError = null;
83
+ let firstError = null;
67
84
 
68
85
  for (const providerId of candidates) {
69
86
  if (!providerId) continue;
@@ -81,7 +98,7 @@ async function chatCompletion(messages, options = {}) {
81
98
  try {
82
99
  data = await fetchProvider(provider, provider.chatPath, body);
83
100
  } catch (err) {
84
- lastError = err;
101
+ if (!firstError) firstError = err; // keep the active provider's error
85
102
 
86
103
  // 429 = rate limit, 403 = quota exhausted — both warrant a fallback
87
104
  if (err.status === 429 || err.status === 403) {
@@ -93,9 +110,8 @@ async function chatCompletion(messages, options = {}) {
93
110
  continue;
94
111
  }
95
112
 
96
- // Any other error: try next candidate silently; surface only if all fail
97
- lastError = err;
98
- continue;
113
+ // Any other error (auth, network, server error) surface immediately
114
+ throw err;
99
115
  }
100
116
 
101
117
  const { message, tokens } = provider.parseResponse(data);
@@ -114,7 +130,7 @@ async function chatCompletion(messages, options = {}) {
114
130
  return message;
115
131
  }
116
132
 
117
- throw lastError || new Error('All providers failed or exhausted.');
133
+ throw firstError || new Error('All providers failed or exhausted.');
118
134
  }
119
135
 
120
136
  // ─── embedText ───────────────────────────────────────────────────────────────
@@ -76,6 +76,47 @@ function handleApiCommand(arg) {
76
76
  return `✅ Switched to *${p.name}* (\`${target}\`)\nModel: \`${p.chatModel}\``;
77
77
  }
78
78
 
79
+ // ─── /limit command ───────────────────────────────────────────────────────────
80
+
81
+ function handleLimitCommand(arg) {
82
+ if (!arg) {
83
+ const status = tokenManager.getStatus();
84
+ const lines = ['⚙️ *Token Limits* (0 = unlimited)', ''];
85
+ for (const s of status) {
86
+ const limitStr = s.limit > 0 ? s.limit.toLocaleString() : '∞ unlimited';
87
+ const active = s.active ? ' ✅' : '';
88
+ lines.push(`\`${s.id}\`: ${limitStr}${active}`);
89
+ }
90
+ lines.push('');
91
+ lines.push('Use `/limit <provider> <number>` to set a limit.');
92
+ lines.push('Use `/limit <provider> 0` for unlimited.');
93
+ lines.push('Providers: ' + KNOWN_PROVIDERS.map((n) => `\`${n}\``).join(', '));
94
+ return lines.join('\n');
95
+ }
96
+
97
+ const parts = arg.trim().split(/\s+/);
98
+ if (parts.length !== 2) {
99
+ return '❌ Usage: `/limit <provider> <number>`\nExample: `/limit claude 0`';
100
+ }
101
+
102
+ const [providerArg, valueArg] = parts;
103
+ const id = providerArg.toLowerCase();
104
+ if (!KNOWN_PROVIDERS.includes(id)) {
105
+ return `❌ Unknown provider: \`${id}\`\nAvailable: ${KNOWN_PROVIDERS.map((n) => `\`${n}\``).join(', ')}`;
106
+ }
107
+
108
+ const n = Number(valueArg);
109
+ if (isNaN(n) || n < 0 || !Number.isFinite(n)) {
110
+ return '❌ Limit must be a non-negative number. Use `0` for unlimited.';
111
+ }
112
+
113
+ const result = tokenManager.setLimit(id, n);
114
+ if (!result.ok) return `❌ ${result.error}`;
115
+
116
+ const limitStr = n === 0 ? '∞ unlimited' : n.toLocaleString() + ' tokens/day';
117
+ return `✅ *${id}* limit set to *${limitStr}*`;
118
+ }
119
+
79
120
  // ─── /tokens command ──────────────────────────────────────────────────────────
80
121
 
81
122
  function handleTokensCommand() {
@@ -102,6 +143,7 @@ function startTelegramBot() {
102
143
  bot.setMyCommands([
103
144
  { command: 'api', description: 'Show or switch active API provider' },
104
145
  { command: 'tokens', description: 'Show token usage for today' },
146
+ { command: 'limit', description: 'Show or set daily token limit per provider' },
105
147
  { command: 'help', description: 'Show all available commands' }
106
148
  ]).catch((err) => {
107
149
  process.stderr.write(`[telegram] setMyCommands failed: ${err.message}\n`);
@@ -127,8 +169,8 @@ function startTelegramBot() {
127
169
  const MD = { parse_mode: 'Markdown' };
128
170
 
129
171
  // ── Slash commands ────────────────────────────────────────────────────
130
- if (text.startsWith('/api')) {
131
- const arg = text.slice(4).trim() || null;
172
+ if (text.startsWith('/api') || text.startsWith('/ap ') || text === '/ap') {
173
+ const arg = text.startsWith('/api') ? text.slice(4).trim() || null : text.slice(3).trim() || null;
132
174
  await safeSend(bot, chatId, handleApiCommand(arg), MD);
133
175
  return;
134
176
  }
@@ -138,6 +180,12 @@ function startTelegramBot() {
138
180
  return;
139
181
  }
140
182
 
183
+ if (text.startsWith('/limit')) {
184
+ const arg = text.slice(6).trim() || null;
185
+ await safeSend(bot, chatId, handleLimitCommand(arg), MD);
186
+ return;
187
+ }
188
+
141
189
  if (text === '/help' || text === '/start') {
142
190
  const helpText = [
143
191
  '🤖 *Tiger Bot Commands*',
@@ -145,11 +193,11 @@ function startTelegramBot() {
145
193
  '/api \\- Show current provider & token stats',
146
194
  '/api `<name>` \\- Switch active API provider',
147
195
  '/tokens \\- Show token usage for today',
196
+ '/limit \\- Show daily token limits per provider',
197
+ '/limit `<name> <n>` \\- Set limit \\(0 = unlimited\\)',
148
198
  '/help \\- Show this message',
149
199
  '',
150
- '*Available providers:* ' + KNOWN_PROVIDERS.join(', '),
151
- '',
152
- '_Token limits & auto\\-switch configured in .env_'
200
+ '*Available providers:* ' + KNOWN_PROVIDERS.join(', ')
153
201
  ].join('\n');
154
202
  await safeSend(bot, chatId, helpText, { parse_mode: 'MarkdownV2' });
155
203
  return;
@@ -107,6 +107,21 @@ function getCurrentProvider() {
107
107
  return state.activeProvider;
108
108
  }
109
109
 
110
+ /**
111
+ * Set the daily token limit for a provider at runtime.
112
+ * limit = 0 means unlimited. Returns { ok } or { ok: false, error }.
113
+ */
114
+ function setLimit(id, limit) {
115
+ ensureInit();
116
+ const known = ['kimi', 'moonshot', 'zai', 'minimax', 'claude'];
117
+ if (!known.includes(id)) return { ok: false, error: `Unknown provider: ${id}` };
118
+ const n = Number(limit);
119
+ if (isNaN(n) || n < 0) return { ok: false, error: 'Limit must be a non-negative number (0 = unlimited)' };
120
+ state.limits[id] = Math.floor(n);
121
+ saveUsageFile();
122
+ return { ok: true };
123
+ }
124
+
110
125
  /**
111
126
  * Manually switch to a named provider.
112
127
  * Returns { ok, provider } or { ok: false, error }
@@ -214,6 +229,7 @@ module.exports = {
214
229
  init,
215
230
  getCurrentProvider,
216
231
  setProvider,
232
+ setLimit,
217
233
  recordTokens,
218
234
  isOverLimit,
219
235
  getNextCandidates,