alvin-bot 4.18.3 β†’ 4.18.5

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/CHANGELOG.md CHANGED
@@ -2,6 +2,40 @@
2
2
 
3
3
  All notable changes to Alvin Bot are documented here.
4
4
 
5
+ ## [4.18.5] β€” 2026-04-23
6
+
7
+ ### πŸ› Fix: auto-reset stale SDK sessionId on empty-stream detection
8
+
9
+ **Problem:** After a round of failed queries (common trigger: token rotation, quota exhaustion mid-turn, Claude backend dropping the session silently), the stored `session.sessionId` references a conversation that the Claude backend has already discarded. Every subsequent query passes `resume: session.sessionId` into the SDK, the backend can't find the session, and the stream terminates with zero text chunks. The v4.18.3 empty-stream detector only invalidated the availability cache β€” the sessionId stayed stale, so the next retry resumed the same dead session. A burn-credits loop that required a manual `/new` to escape.
10
+
11
+ **Fix:** the provider now sets a new `sessionResetRequested: true` flag on the empty-stream text chunk. Both message handlers (`handlers/message.ts` and `handlers/platform-message.ts`) listen for it and clear `session.sessionId` + `session.lastSdkHistoryIndex` immediately, so the very next user message starts a fresh SDK session instead of resuming the dead one.
12
+
13
+ **Net effect:** after a single empty-stream, the bot self-heals. One resend from the user is enough; no manual `/new`, no bot restart.
14
+
15
+ ## [4.18.4] β€” 2026-04-23
16
+
17
+ ### πŸ› Critical fix: detect Anthropic quota-exhausted responses
18
+
19
+ **Problem:** When a Claude Max subscription runs out of weekly limit or extra-usage credits, Anthropic's gateway responds to every query with a short text chunk like *"You're out of extra usage Β· resets 9pm (Europe/Berlin)"* β€” delivered as `output_tokens=0`. The SDK surfaces it as a normal assistant text message. The bot has no way to distinguish it from a real Claude response, so one of two things happens:
20
+
21
+ 1. The text passes through unchanged and the user sees the raw quota message as if it were Claude's reply.
22
+ 2. The text is filtered downstream (some legacy paths) and the user sees `"(Keine Antwort)"` with zero explanation.
23
+
24
+ Both outcomes hide the real cause (credits) and every retry attempt wastes more credits on nothing.
25
+
26
+ **Symptoms observed on 2026-04-23:**
27
+ - User activates `/extra-usage`, sends query β†’ `(Keine Antwort)` or raw limit text.
28
+ - Assumes bot / workspace / token is broken, spends hours debugging.
29
+ - Actual cause: extra-usage quota silently exhausted mid-debug-session.
30
+
31
+ **Fix** (`src/providers/claude-sdk-provider.ts`):
32
+
33
+ - New `isQuotaLimitOutput(text)` detects the Anthropic-gateway quota signatures (multiple English/German variants: "out of extra usage", "weekly usage limit", "rate limit exceeded", "quota exceeded", etc.).
34
+ - In the SDK stream loop: when the first text chunk matches this pattern, rewrite it as a clear actionable hint (*"⚠️ …Top up the plan or wait for the reset…"*) AND invalidate the availability cache so the next heartbeat re-probes β€” but do NOT yield an `error` chunk (that would trigger fallback-cascade to Ollama and waste more credits on retries).
35
+ - In `isAvailable()`: the heartbeat probe now treats quota-exhausted output as "unavailable" in the same way it treats auth errors. Provider is marked unhealthy, bot stops trying until the next probe succeeds.
36
+
37
+ **Net effect:** bot no longer silently wastes credits after a quota limit is hit. Users see a plain, actionable message pointing at the right fix.
38
+
5
39
  ## [4.18.3] β€” 2026-04-23
6
40
 
7
41
  ### πŸ› Hotfix: 4.18.2 triggered unwanted failover to Ollama
@@ -446,6 +446,16 @@ export async function handleMessage(ctx) {
446
446
  // Clear any tool-use status line β€” real content is flowing now.
447
447
  streamer.setStatus(null);
448
448
  await streamer.update(finalText);
449
+ // v4.18.5 β€” Provider requested a session reset (empty-stream / stale
450
+ // sessionId recovery). Clear the session's sessionId + SDK anchor so
451
+ // the next query starts a fresh Claude session instead of resuming
452
+ // the broken one. Without this, the bot would loop empty-stream
453
+ // replies and burn credits until the user manually runs /new.
454
+ if (chunk.sessionResetRequested) {
455
+ console.warn(`[session] provider requested reset for ${sessionKey} β€” clearing sessionId + SDK anchor`);
456
+ session.sessionId = null;
457
+ session.lastSdkHistoryIndex = -1;
458
+ }
449
459
  // Emit the new delta for observers β€” accumulated text minus what
450
460
  // we already broadcast.
451
461
  if (finalText.length > lastBroadcastLen) {
@@ -194,6 +194,13 @@ export async function handlePlatformMessage(msg, adapter) {
194
194
  switch (chunk.type) {
195
195
  case "text":
196
196
  finalText = chunk.text || "";
197
+ // v4.18.5 β€” Provider-requested session reset on empty-stream detection.
198
+ // Mirror of the same handling in handlers/message.ts.
199
+ if (chunk.sessionResetRequested) {
200
+ console.warn(`[session] provider requested reset for ${sessionKey} β€” clearing sessionId + SDK anchor`);
201
+ session.sessionId = null;
202
+ session.lastSdkHistoryIndex = -1;
203
+ }
197
204
  break;
198
205
  case "done":
199
206
  if (chunk.sessionId)
@@ -25,6 +25,34 @@ export function isAuthErrorOutput(text) {
25
25
  return false;
26
26
  return /^\s*not logged in\b/i.test(text);
27
27
  }
28
+ /**
29
+ * Detects Anthropic's rate-limit / quota-exhausted gateway responses.
30
+ * These are NOT model outputs β€” they come back as a single text chunk with
31
+ * output_tokens = 0 before the model even sees the prompt. Without this
32
+ * detection, the bot would forward the gateway message as if it were the
33
+ * assistant's reply ("(Keine Antwort)" or the raw quota text), masking the
34
+ * real cause and wasting more calls on retries.
35
+ *
36
+ * Covers the observed variants:
37
+ * - "You're out of extra usage Β· resets 9pm (Europe/Berlin)"
38
+ * - "You've reached your weekly usage limit. …"
39
+ * - "Rate limit exceeded"
40
+ * - Claude Max / Pro quota messages in both EN/DE
41
+ */
42
+ export function isQuotaLimitOutput(text) {
43
+ if (!text)
44
+ return false;
45
+ const t = text.trim();
46
+ if (t.length === 0)
47
+ return false;
48
+ return (/you['’]re out of extra usage/i.test(t) ||
49
+ /reached (your |the )?(weekly |monthly |daily )?(usage|rate) limit/i.test(t) ||
50
+ /rate[- ]?limit(ed)? (exceeded|reached)/i.test(t) ||
51
+ /quota exceeded/i.test(t) ||
52
+ /usage limit reached/i.test(t) ||
53
+ /limit (reached|hit) for (this|your) (week|month|day)/i.test(t) ||
54
+ /resets? \d{1,2}(am|pm|:)/i.test(t) && /usage|limit/i.test(t));
55
+ }
28
56
  const BOT_PROJECT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
29
57
  // Load CLAUDE.md once at startup
30
58
  let botClaudeMd = "";
@@ -198,6 +226,24 @@ export class ClaudeSDKProvider {
198
226
  };
199
227
  return;
200
228
  }
229
+ // v4.18.4 β€” Guard against Anthropic rate-limit / quota-exhausted
230
+ // gateway messages that also arrive as a single text chunk (with
231
+ // output_tokens = 0). Pass them through as a friendly text chunk
232
+ // (NOT an error β€” would trigger fallback cascade to Ollama) and
233
+ // mark the provider as degraded so the next heartbeat re-checks.
234
+ if (!accumulatedText && isQuotaLimitOutput(block.text)) {
235
+ const hint = "⚠️ " + block.text.trim() +
236
+ "\n\nTop up the plan or wait for the reset. No message was sent to Claude.";
237
+ this.invalidateAvailabilityCache();
238
+ yield {
239
+ type: "text",
240
+ text: hint,
241
+ delta: hint,
242
+ sessionId: capturedSessionId,
243
+ };
244
+ accumulatedText = hint;
245
+ continue;
246
+ }
201
247
  accumulatedText += block.text;
202
248
  yield {
203
249
  type: "text",
@@ -331,13 +377,19 @@ export class ClaudeSDKProvider {
331
377
  // and knows to resend β€” without tripping the failover.
332
378
  if (accumulatedText === "" && outputTok === 0) {
333
379
  this.invalidateAvailabilityCache();
334
- const hint = "⚠️ Claude antwortete mit leerem Stream (meist nach /extra-usage, /login oder Token-Refresh). " +
335
- "Der SDK-Token-Cache wurde geleert β€” bitte schick die Nachricht einfach nochmal.";
380
+ const hint = "⚠️ Claude antwortete mit leerem Stream. " +
381
+ "Meist Folge einer stale SDK-Session nach /extra-usage, /login oder Token-Refresh. " +
382
+ "Ich starte die Session automatisch neu β€” bitte schick die Nachricht einfach nochmal.";
336
383
  yield {
337
384
  type: "text",
338
385
  text: hint,
339
386
  delta: hint,
340
387
  sessionId: resultMsg.session_id || capturedSessionId,
388
+ // v4.18.5 β€” Signal to the message handler that it should clear
389
+ // session.sessionId now. Without this the next query resumes
390
+ // the same stale sessionId and produces another empty stream
391
+ // in a loop that burns credits until the user manually /new's.
392
+ sessionResetRequested: true,
341
393
  };
342
394
  }
343
395
  yield {
@@ -411,7 +463,9 @@ export class ClaudeSDKProvider {
411
463
  // sniff-stdout approach for backward compat.
412
464
  try {
413
465
  const { stdout: probeOut } = await execFileAsync(claudePath, ["-p", "ping", "--output-format", "text"], { timeout: 15000 });
414
- return cache(!isAuthErrorOutput(probeOut));
466
+ // v4.18.4 β€” treat quota-exhausted as "unavailable" so heartbeat
467
+ // surfaces it and stops wasting extra-usage credits on retries.
468
+ return cache(!isAuthErrorOutput(probeOut) && !isQuotaLimitOutput(probeOut));
415
469
  }
416
470
  catch {
417
471
  // Both checks failed β€” treat as unavailable
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "alvin-bot",
3
- "version": "4.18.3",
3
+ "version": "4.18.5",
4
4
  "description": "Alvin Bot \u2014 Your personal AI agent on Telegram, WhatsApp, Discord, Signal, and Web.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",