alvin-bot 4.18.5 โ 4.19.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/CHANGELOG.md +82 -0
- package/dist/handlers/commands.js +15 -0
- package/dist/handlers/message.js +55 -6
- package/dist/handlers/platform-message.js +36 -7
- package/dist/providers/claude-sdk-provider.js +34 -0
- package/dist/providers/registry.js +12 -2
- package/dist/services/voice.js +16 -4
- package/dist/services/workspaces.js +27 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,88 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to Alvin Bot are documented here.
|
|
4
4
|
|
|
5
|
+
## [4.19.2] โ 2026-04-24
|
|
6
|
+
|
|
7
|
+
### ๐ Fix: workspace switch produced "(no response)" format-kaskade; added empty-stream diagnostics
|
|
8
|
+
|
|
9
|
+
**Symptom.** After v4.19.1 shipped, a workspace/dir switch still produced a broken response โ but this time NOT an empty stream. Claude replied with literal text like `"(no response)\n\nUser: Hallo"`, then the next turn `"(no response)\n\nUser: wie viele tools hast duโฆ"` โ a format-kaskade where every response got worse.
|
|
10
|
+
|
|
11
|
+
**Root cause.** v4.19.1's cwd-change reset set `session.lastSdkHistoryIndex = -1`. That value is consumed by `buildBridgeMessage()` in `handlers/message.ts`, which is designed for the Ollama-fallback path โ its preamble frames past turns as *"the following N message(s) were exchanged with a fallback model"*. When the reset runs on a workspace switch, the ENTIRE conversation history (dozens of turns in a long-running session) gets packaged under that framing and prepended to the next prompt. If the history contains Telegram fallback artifacts (`(Keine Antwort)`, `(no response)`), Claude reads those as the "fallback model's response format" and imitates it. Each imitation lands back in history, poisoning the next bridge. Cascade.
|
|
12
|
+
|
|
13
|
+
Workspace switch is not a fallback event โ it's *"new persona, new task"*. The old conversation belongs to the old workspace and must not be reframed and re-injected.
|
|
14
|
+
|
|
15
|
+
**Fix.** `handlers/message.ts`, `handlers/platform-message.ts`, and `handlers/commands.ts` (`/dir`) now set `session.lastSdkHistoryIndex = session.history.length - 1` on cwd change. `buildBridgeMessage()` returns empty for the next turn, Claude starts the new workspace with a clean slate โ persona, cwd, system prompt, but no inherited conversation.
|
|
16
|
+
|
|
17
|
+
**Additionally โ empty-stream diagnostics.** `src/providers/claude-sdk-provider.ts` now logs a structured JSON dump on empty-stream detection: SDK result `subtype`/`is_error`/`num_turns`/`duration_ms`, the `usage` object, the `session_id` Claude returned vs. the one we passed, model override, cwd, effort, prompt/systemPrompt/history sizes, allowedTools count, and MCP state. Lets future empty-stream events be triaged in one log line instead of guessing.
|
|
18
|
+
|
|
19
|
+
**Net effect.** `/workspace <name>` โ message โ clean response (no Fallback-framed preamble, no format-kaskade). `/dir <path>` โ same. Next empty-stream event will come with actionable diagnostic output instead of a silent symptom.
|
|
20
|
+
|
|
21
|
+
## [4.19.1] โ 2026-04-24
|
|
22
|
+
|
|
23
|
+
### ๐ Critical fix: workspace/dir switch no longer produces empty-stream loop
|
|
24
|
+
|
|
25
|
+
**Problem:** After `/workspace <name>` (or `/dir <path>`), every subsequent SDK message returned `โ ๏ธ Claude antwortete mit leerem Stream โฆ` โ and even switching back to the previous workspace did not recover. The v4.18.5 auto-reset only masked the symptom; the underlying cause survived the recovery attempt.
|
|
26
|
+
|
|
27
|
+
**Root cause โ a two-part bug:**
|
|
28
|
+
|
|
29
|
+
1. **Prevention layer missing.** The Claude Agent SDK's `resume: <sessionId>` is bound to the cwd the session was created in: session files live under `~/.claude/projects/<cwd-hash>/<session-id>.jsonl`. When a workspace switch changes `session.workingDir`, the stored `session.sessionId` points at a file that no longer exists in the new project folder. The CLI silently returns an empty stream.
|
|
30
|
+
2. **Recovery layer broken.** v4.18.5's empty-stream detector correctly cleared `session.sessionId = null` on the `text` chunk โ but the very next `done` chunk of the same stream carried `sessionId: resultMsg.session_id || capturedSessionId`, and the handler's `if (chunk.sessionId) session.sessionId = chunk.sessionId;` restored it. The "reset" was immediately undone by the trailing done chunk, so the next turn resumed the same dead session. Loop.
|
|
31
|
+
|
|
32
|
+
**Fix (defense in depth, three layers):**
|
|
33
|
+
|
|
34
|
+
- **Prevention** (root cause): `handlers/message.ts`, `handlers/platform-message.ts`, and `handlers/commands.ts` (`/dir`) now detect `session.workingDir !== workspace.cwd` (resp. new dir) BEFORE the query and clear `session.sessionId = null` + `session.lastSdkHistoryIndex = -1`. The next SDK turn starts fresh in the new project folder. `markSessionDirty()` is called so the clear persists across restarts.
|
|
35
|
+
- **Recovery**: both handlers now track a local `sessionResetInStream` flag. When the provider signals `sessionResetRequested` on a text chunk, the flag is set, and the subsequent `done` chunk's sessionId is ignored (the original resume token or the CLI's fresh-but-wrong-project fallback โ neither is safe).
|
|
36
|
+
- **Hygiene**: `markSessionDirty()` is also called from the empty-stream reset path so the cleared sessionId is persisted immediately rather than waiting for the next trackProviderUsage debounce.
|
|
37
|
+
|
|
38
|
+
**Net effect:** `/workspace <name>` โ message โ works. `/workspace default` โ message โ works. `/dir ~/Projects/foo` โ message โ works. No manual `/new` needed, no credit burn, no recovery retry.
|
|
39
|
+
|
|
40
|
+
**Files:**
|
|
41
|
+
- `src/handlers/message.ts` โ cwd-change detection, sessionResetInStream flag, done-chunk guard, markSessionDirty import
|
|
42
|
+
- `src/handlers/platform-message.ts` โ same set of changes for non-Telegram platforms (Slack, Discord, WhatsApp)
|
|
43
|
+
- `src/handlers/commands.ts` โ `/dir` now invalidates SDK resume anchor on cwd change
|
|
44
|
+
|
|
45
|
+
## [4.19.0] โ 2026-04-24
|
|
46
|
+
|
|
47
|
+
### ๐งญ Feature: per-workspace runtime overrides (effort ยท provider ยท voice ยท temperature ยท toolset)
|
|
48
|
+
|
|
49
|
+
Workspaces could already override `model` and `cwd`. v4.19.0 extends the YAML frontmatter with five more runtime fields that take effect automatically the moment a user runs `/workspace <name>`.
|
|
50
|
+
|
|
51
|
+
**New workspace frontmatter fields** (`~/.alvin-bot/workspaces/<name>.md`):
|
|
52
|
+
|
|
53
|
+
```yaml
|
|
54
|
+
---
|
|
55
|
+
purpose: my-project
|
|
56
|
+
cwd: ~/path/to/workdir
|
|
57
|
+
model: opus # already existed
|
|
58
|
+
effort: high # NEW โ low | medium | high
|
|
59
|
+
provider: claude-sdk # NEW โ registry key; fallback chain still applies
|
|
60
|
+
voice: iP95p4xoKVk53GoZ742B # NEW โ ElevenLabs voice ID, or Edge-TTS voice name (e.g. "en-US-JennyNeural")
|
|
61
|
+
temperature: 0.3 # NEW โ 0โ2 sampling temperature
|
|
62
|
+
toolset: research # NEW โ full (default) | readonly | research
|
|
63
|
+
---
|
|
64
|
+
Persona body continues here...
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
**Toolset presets** (map to concrete `allowedTools` lists via the new exported `toolsetToAllowedTools` helper):
|
|
68
|
+
- `full` โ provider default (`Read, Write, Edit, Bash, Glob, Grep, WebSearch, WebFetch, Task` + MCP)
|
|
69
|
+
- `readonly` โ `Read, Glob, Grep, WebSearch, WebFetch` (no Write/Edit/Bash)
|
|
70
|
+
- `research` โ `Read, WebSearch, WebFetch, Grep` (pure research mode)
|
|
71
|
+
|
|
72
|
+
**Implementation:**
|
|
73
|
+
|
|
74
|
+
- `src/services/workspaces.ts` โ `Workspace` interface + `parseFrontmatter` extended; numeric parsing + enum validation so a malformed value is silently dropped and the session-default wins.
|
|
75
|
+
- `src/handlers/message.ts` + `src/handlers/platform-message.ts` โ at query-assembly time, every workspace-set field overrides the equivalent session/registry default exactly for this one query. Nothing sticky leaks across workspace switches.
|
|
76
|
+
- `src/providers/registry.ts` โ `queryWithFallback()` gains optional `providerOverride`. When supplied AND registered, it becomes primary for that query; fallback chain still applies, and the globally active provider joins the chain as a last-resort backup so availability drops still degrade gracefully.
|
|
77
|
+
- `src/providers/claude-sdk-provider.ts` โ passes `options.temperature` through to the Agent SDK when set.
|
|
78
|
+
- `src/services/voice.ts` โ `textToSpeech(text, voice?)` โ optional second arg; picked up from `workspace.voice` in Telegram handler's voice-reply path. Works for both ElevenLabs (Voice ID) and Edge TTS (Voice Name like `de-DE-ConradNeural`).
|
|
79
|
+
|
|
80
|
+
**Net effect:** Each workspace becomes a self-contained runtime profile. For example:
|
|
81
|
+
- A `prep` workspace with `model: opus ยท effort: high ยท temperature: 0.3 ยท voice: en-US-JennyNeural` for polished long-form work;
|
|
82
|
+
- A `research` workspace with `toolset: research ยท model: haiku ยท effort: low ยท temperature: 0.7` for cheap-and-fast web spelunking;
|
|
83
|
+
- A `sensitive` workspace with `toolset: readonly ยท provider: claude-sdk` so the agent cannot accidentally `Write` or `Bash` inside the cwd.
|
|
84
|
+
|
|
85
|
+
No data migration required โ existing workspace files without the new fields keep working identically.
|
|
86
|
+
|
|
5
87
|
## [4.18.5] โ 2026-04-23
|
|
6
88
|
|
|
7
89
|
### ๐ Fix: auto-reset stale SDK sessionId on empty-stream detection
|
|
@@ -227,7 +227,22 @@ export function registerCommands(bot) {
|
|
|
227
227
|
? path.join(os.homedir(), newDir.slice(1))
|
|
228
228
|
: path.resolve(newDir);
|
|
229
229
|
if (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory()) {
|
|
230
|
+
// v4.19.1 โ Claude Agent SDK's `resume` is bound to the cwd. Changing
|
|
231
|
+
// the working dir without clearing the resume anchor would make the
|
|
232
|
+
// next SDK turn look up the session file in the wrong project folder
|
|
233
|
+
// โ silent empty stream. Null out sessionId + history-anchor so the
|
|
234
|
+
// next turn starts a fresh SDK session in the new cwd.
|
|
235
|
+
const cwdChanged = session.workingDir !== resolved;
|
|
230
236
|
session.workingDir = resolved;
|
|
237
|
+
if (cwdChanged) {
|
|
238
|
+
session.sessionId = null;
|
|
239
|
+
// v4.19.2 โ Anchor at current last turn so no catch-up bridge is
|
|
240
|
+
// generated for the next turn. /dir semantically means "switch
|
|
241
|
+
// project context" just like /workspace โ starting fresh is the
|
|
242
|
+
// sane default.
|
|
243
|
+
session.lastSdkHistoryIndex = session.history.length - 1;
|
|
244
|
+
}
|
|
245
|
+
markSessionDirty(userId);
|
|
231
246
|
await ctx.reply(`Working directory: ${session.workingDir}`);
|
|
232
247
|
}
|
|
233
248
|
else {
|
package/dist/handlers/message.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { InputFile } from "grammy";
|
|
2
2
|
import fs from "fs";
|
|
3
|
-
import { getSession, addToHistory, trackProviderUsage, buildSessionKey, getTelegramWorkspace } from "../services/session.js";
|
|
3
|
+
import { getSession, addToHistory, trackProviderUsage, buildSessionKey, getTelegramWorkspace, markSessionDirty } from "../services/session.js";
|
|
4
4
|
import { resolveWorkspaceOrDefault, getWorkspace } from "../services/workspaces.js";
|
|
5
5
|
import { TelegramStreamer } from "../services/telegram.js";
|
|
6
6
|
import { getRegistry } from "../engine.js";
|
|
@@ -313,9 +313,32 @@ export async function handleMessage(ctx) {
|
|
|
313
313
|
const workspace = activeWsName
|
|
314
314
|
? (getWorkspace(activeWsName) ?? resolveWorkspaceOrDefault("telegram", String(userId), undefined))
|
|
315
315
|
: resolveWorkspaceOrDefault("telegram", String(userId), undefined);
|
|
316
|
+
// v4.19.1 โ Workspace switch detection. Claude Agent SDK's `resume` is
|
|
317
|
+
// bound to the cwd (session files live under
|
|
318
|
+
// ~/.claude/projects/<cwd-hash>/<session-id>.jsonl). If cwd changes as
|
|
319
|
+
// part of this switch, the stored sessionId points at a file the CLI
|
|
320
|
+
// cannot find in the new project folder โ silent empty stream. Guard
|
|
321
|
+
// with a workspaceName change (not cwd comparison) so /dir-initiated
|
|
322
|
+
// custom cwds are preserved across turns where no workspace actually
|
|
323
|
+
// switched.
|
|
316
324
|
if (session.workspaceName !== workspace.name) {
|
|
325
|
+
const cwdChanged = session.workingDir !== workspace.cwd;
|
|
317
326
|
session.workspaceName = workspace.name;
|
|
318
327
|
session.workingDir = workspace.cwd;
|
|
328
|
+
if (cwdChanged) {
|
|
329
|
+
console.log(`[session] workspace switch changed cwd (โ ${workspace.cwd}) โ ` +
|
|
330
|
+
`invalidating SDK resume anchor and skipping bridge`);
|
|
331
|
+
session.sessionId = null;
|
|
332
|
+
// v4.19.2 โ Anchor at the last turn BEFORE the new user message so
|
|
333
|
+
// buildBridgeMessage() produces no catch-up preamble. A workspace
|
|
334
|
+
// switch means "new persona, new task" โ the previous conversation
|
|
335
|
+
// (often from a different workspace) should NOT be reframed as
|
|
336
|
+
// "Fallback model turns" and fed back into Claude. That framing
|
|
337
|
+
// was producing format-kaskaden where Claude imitated Telegram
|
|
338
|
+
// "(Keine Antwort)" fallback artifacts from history.
|
|
339
|
+
session.lastSdkHistoryIndex = session.history.length - 1;
|
|
340
|
+
markSessionDirty(userId);
|
|
341
|
+
}
|
|
319
342
|
}
|
|
320
343
|
const chatIdStr = String(ctx.chat.id);
|
|
321
344
|
const skillContext = buildSkillContext(text);
|
|
@@ -381,14 +404,23 @@ export async function handleMessage(ctx) {
|
|
|
381
404
|
}
|
|
382
405
|
}
|
|
383
406
|
}
|
|
407
|
+
// v4.19.0 โ Per-workspace runtime overrides. Each is only applied when
|
|
408
|
+
// the workspace explicitly set it; otherwise the session/provider default
|
|
409
|
+
// wins. Toolset is mapped to a concrete allowedTools list via
|
|
410
|
+
// toolsetToAllowedTools(); providers that ignore allowedTools (Ollama etc.)
|
|
411
|
+
// just drop it.
|
|
412
|
+
const { toolsetToAllowedTools } = await import("../services/workspaces.js");
|
|
413
|
+
const wsAllowed = toolsetToAllowedTools(workspace.toolset);
|
|
384
414
|
const queryOpts = {
|
|
385
415
|
prompt: bridgedPrompt,
|
|
386
416
|
systemPrompt,
|
|
387
417
|
workingDir: session.workingDir,
|
|
388
|
-
effort: session.effort,
|
|
418
|
+
effort: workspace.effort ?? session.effort,
|
|
389
419
|
// v4.15 โ Per-workspace model override (optional YAML `model:` field).
|
|
390
|
-
//
|
|
420
|
+
// v4.19 โ ditto for temperature and toolset-derived allowedTools.
|
|
391
421
|
...(workspace.model ? { model: workspace.model } : {}),
|
|
422
|
+
...(workspace.temperature !== undefined ? { temperature: workspace.temperature } : {}),
|
|
423
|
+
...(wsAllowed ? { allowedTools: wsAllowed } : {}),
|
|
392
424
|
abortSignal: session.abortController.signal,
|
|
393
425
|
// User's UI locale โ registry uses it to localize failure messages.
|
|
394
426
|
locale: session.language,
|
|
@@ -420,7 +452,13 @@ export async function handleMessage(ctx) {
|
|
|
420
452
|
// readable description (which only appears in the tool_use input,
|
|
421
453
|
// not in the tool_result text). See Fix #17 Stage 2.
|
|
422
454
|
let lastAgentToolUseInput;
|
|
423
|
-
|
|
455
|
+
// v4.19.1 โ Track whether the provider requested a session reset during
|
|
456
|
+
// this stream. If it did, the trailing `done` chunk's sessionId MUST be
|
|
457
|
+
// ignored โ otherwise it restores the exact sessionId we just cleared
|
|
458
|
+
// (the empty-stream capturedSessionId) and the next turn loops again.
|
|
459
|
+
// This is the second half of the empty-stream-loop fix.
|
|
460
|
+
let sessionResetInStream = false;
|
|
461
|
+
for await (const chunk of registry.queryWithFallback(queryOpts, workspace.provider)) {
|
|
424
462
|
// v4.12.1 โ Update pending-sync-task state FIRST so the timer's
|
|
425
463
|
// next reset picks up the new state. This ordering is load-bearing:
|
|
426
464
|
// reversing it means the timer rearms with stale state. A sync
|
|
@@ -455,6 +493,8 @@ export async function handleMessage(ctx) {
|
|
|
455
493
|
console.warn(`[session] provider requested reset for ${sessionKey} โ clearing sessionId + SDK anchor`);
|
|
456
494
|
session.sessionId = null;
|
|
457
495
|
session.lastSdkHistoryIndex = -1;
|
|
496
|
+
sessionResetInStream = true;
|
|
497
|
+
markSessionDirty(userId);
|
|
458
498
|
}
|
|
459
499
|
// Emit the new delta for observers โ accumulated text minus what
|
|
460
500
|
// we already broadcast.
|
|
@@ -535,7 +575,16 @@ export async function handleMessage(ctx) {
|
|
|
535
575
|
lastAgentToolUseInput = undefined;
|
|
536
576
|
break;
|
|
537
577
|
case "done":
|
|
538
|
-
|
|
578
|
+
// v4.19.1 โ Respect the in-stream session reset. If the provider
|
|
579
|
+
// already signalled `sessionResetRequested` on the preceding text
|
|
580
|
+
// chunk (empty-stream detection), do NOT let the trailing done
|
|
581
|
+
// chunk restore the sessionId we just nulled โ that was the
|
|
582
|
+
// silent bug behind the empty-stream loop across workspace
|
|
583
|
+
// switches. The `done` chunk's sessionId on an empty stream is
|
|
584
|
+
// either the stale resume token we tried to use or a brand-new
|
|
585
|
+
// session file the CLI created in the wrong project folder;
|
|
586
|
+
// neither is safe to resume from.
|
|
587
|
+
if (chunk.sessionId && !sessionResetInStream)
|
|
539
588
|
session.sessionId = chunk.sessionId;
|
|
540
589
|
if (chunk.costUsd)
|
|
541
590
|
session.totalCost += chunk.costUsd;
|
|
@@ -608,7 +657,7 @@ export async function handleMessage(ctx) {
|
|
|
608
657
|
if (session.voiceReply && finalText.trim()) {
|
|
609
658
|
try {
|
|
610
659
|
await ctx.api.sendChatAction(ctx.chat.id, "upload_voice");
|
|
611
|
-
const audioPath = await textToSpeech(finalText);
|
|
660
|
+
const audioPath = await textToSpeech(finalText, workspace.voice);
|
|
612
661
|
await ctx.replyWithVoice(new InputFile(fs.readFileSync(audioPath), "response.mp3"));
|
|
613
662
|
fs.unlink(audioPath, () => { });
|
|
614
663
|
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* This is the platform-agnostic equivalent of message.ts (which is Telegram-specific).
|
|
8
8
|
*/
|
|
9
9
|
import fs from "fs";
|
|
10
|
-
import { getSession, addToHistory, trackProviderUsage, buildSessionKey } from "../services/session.js";
|
|
10
|
+
import { getSession, addToHistory, trackProviderUsage, buildSessionKey, markSessionDirty } from "../services/session.js";
|
|
11
11
|
import { resolveWorkspaceOrDefault } from "../services/workspaces.js";
|
|
12
12
|
import { getRegistry } from "../engine.js";
|
|
13
13
|
import { buildSmartSystemPrompt } from "../services/personality.js";
|
|
@@ -114,11 +114,25 @@ export async function handlePlatformMessage(msg, adapter) {
|
|
|
114
114
|
}
|
|
115
115
|
}
|
|
116
116
|
const workspace = resolveWorkspaceOrDefault(msg.platform, msg.chatId, channelName);
|
|
117
|
-
//
|
|
118
|
-
//
|
|
117
|
+
// v4.19.1 โ Workspace switch detection. If cwd changes as part of the
|
|
118
|
+
// switch, null out session.sessionId so the next SDK turn does not
|
|
119
|
+
// resume a session file that lives in the previous project folder
|
|
120
|
+
// (Claude Agent SDK stores sessions under ~/.claude/projects/<cwd-hash>/).
|
|
121
|
+
// Guard with workspaceName so /dir-initiated custom cwds survive turns
|
|
122
|
+
// where no workspace actually switched.
|
|
119
123
|
if (session.workspaceName !== workspace.name) {
|
|
124
|
+
const cwdChanged = session.workingDir !== workspace.cwd;
|
|
120
125
|
session.workspaceName = workspace.name;
|
|
121
126
|
session.workingDir = workspace.cwd;
|
|
127
|
+
if (cwdChanged) {
|
|
128
|
+
console.log(`[session] workspace switch changed cwd (โ ${workspace.cwd}) โ ` +
|
|
129
|
+
`invalidating SDK resume anchor and skipping bridge`);
|
|
130
|
+
session.sessionId = null;
|
|
131
|
+
// v4.19.2 โ Anchor at current last turn so no catch-up bridge is
|
|
132
|
+
// generated for the next turn (see message.ts for full rationale).
|
|
133
|
+
session.lastSdkHistoryIndex = session.history.length - 1;
|
|
134
|
+
markSessionDirty(sessionKey);
|
|
135
|
+
}
|
|
122
136
|
}
|
|
123
137
|
// Skip if already processing (queue up to 3)
|
|
124
138
|
if (session.isProcessing) {
|
|
@@ -164,14 +178,19 @@ export async function handlePlatformMessage(msg, adapter) {
|
|
|
164
178
|
// in the system prompt for this query.
|
|
165
179
|
const isFirstSDKTurn = isSDK && session.sessionId === null;
|
|
166
180
|
const systemPrompt = (await buildSmartSystemPrompt(isSDK, session.language, fullText, msg.chatId, isFirstSDKTurn, workspace.systemPromptOverride)) + skillContext;
|
|
181
|
+
// v4.19.0 โ Per-workspace runtime overrides (model/effort/temperature/toolset).
|
|
182
|
+
const { toolsetToAllowedTools } = await import("../services/workspaces.js");
|
|
183
|
+
const wsAllowed = toolsetToAllowedTools(workspace.toolset);
|
|
167
184
|
const queryOpts = {
|
|
168
185
|
prompt: fullText,
|
|
169
186
|
systemPrompt,
|
|
170
187
|
workingDir: session.workingDir,
|
|
171
|
-
effort: session.effort,
|
|
188
|
+
effort: workspace.effort ?? session.effort,
|
|
172
189
|
// v4.15 โ Per-workspace model override (optional YAML `model:` field).
|
|
173
|
-
//
|
|
190
|
+
// v4.19 โ ditto for temperature and toolset-derived allowedTools.
|
|
174
191
|
...(workspace.model ? { model: workspace.model } : {}),
|
|
192
|
+
...(workspace.temperature !== undefined ? { temperature: workspace.temperature } : {}),
|
|
193
|
+
...(wsAllowed ? { allowedTools: wsAllowed } : {}),
|
|
175
194
|
sessionId: isSDK ? session.sessionId : null,
|
|
176
195
|
history: !isSDK ? session.history : undefined,
|
|
177
196
|
// v4.14 โ Expose alvin_dispatch_agent MCP tool on non-Telegram
|
|
@@ -190,7 +209,12 @@ export async function handlePlatformMessage(msg, adapter) {
|
|
|
190
209
|
if (!isSDK) {
|
|
191
210
|
addToHistory(sessionKey, { role: "user", content: fullText });
|
|
192
211
|
}
|
|
193
|
-
|
|
212
|
+
// v4.19.1 โ Track whether the provider requested a session reset during
|
|
213
|
+
// this stream. If it did, the trailing `done` chunk's sessionId MUST be
|
|
214
|
+
// ignored โ otherwise it restores the exact sessionId we just cleared
|
|
215
|
+
// and the next turn loops again. Mirror of message.ts.
|
|
216
|
+
let sessionResetInStream = false;
|
|
217
|
+
for await (const chunk of registry.queryWithFallback(queryOpts, workspace.provider)) {
|
|
194
218
|
switch (chunk.type) {
|
|
195
219
|
case "text":
|
|
196
220
|
finalText = chunk.text || "";
|
|
@@ -200,10 +224,15 @@ export async function handlePlatformMessage(msg, adapter) {
|
|
|
200
224
|
console.warn(`[session] provider requested reset for ${sessionKey} โ clearing sessionId + SDK anchor`);
|
|
201
225
|
session.sessionId = null;
|
|
202
226
|
session.lastSdkHistoryIndex = -1;
|
|
227
|
+
sessionResetInStream = true;
|
|
228
|
+
markSessionDirty(sessionKey);
|
|
203
229
|
}
|
|
204
230
|
break;
|
|
205
231
|
case "done":
|
|
206
|
-
|
|
232
|
+
// v4.19.1 โ Respect in-stream reset: don't let done.sessionId undo
|
|
233
|
+
// the clear from the empty-stream text chunk. See message.ts for
|
|
234
|
+
// full rationale.
|
|
235
|
+
if (chunk.sessionId && !sessionResetInStream)
|
|
207
236
|
session.sessionId = chunk.sessionId;
|
|
208
237
|
if (chunk.costUsd)
|
|
209
238
|
session.totalCost += chunk.costUsd;
|
|
@@ -192,6 +192,9 @@ export class ClaudeSDKProvider {
|
|
|
192
192
|
maxTurns: 50,
|
|
193
193
|
betas: ["context-1m-2025-08-07"],
|
|
194
194
|
...(modelOverride ? { model: modelOverride } : {}),
|
|
195
|
+
// v4.19.0 โ per-workspace temperature override. Passed through to
|
|
196
|
+
// the Agent SDK; providers that don't use it just drop it.
|
|
197
|
+
...(typeof options.temperature === "number" ? { temperature: options.temperature } : {}),
|
|
195
198
|
// Prefer Haiku as fallback on rate-limit/overload โ cheap and
|
|
196
199
|
// fast, keeps the bot responsive when the primary tier is
|
|
197
200
|
// throttled. Omitted when the primary IS Haiku (SDK requires
|
|
@@ -377,6 +380,37 @@ export class ClaudeSDKProvider {
|
|
|
377
380
|
// and knows to resend โ without tripping the failover.
|
|
378
381
|
if (accumulatedText === "" && outputTok === 0) {
|
|
379
382
|
this.invalidateAvailabilityCache();
|
|
383
|
+
// v4.19.2 โ Diagnostic logging: when the Agent SDK returns an
|
|
384
|
+
// empty stream, we need enough detail to tell apart the possible
|
|
385
|
+
// causes (auth, quota, context overflow, model rejection, MCP
|
|
386
|
+
// init failure). The message handler-level reset was fine for
|
|
387
|
+
// stale-session recovery but gave us no signal on WHY it went
|
|
388
|
+
// empty in the first place.
|
|
389
|
+
try {
|
|
390
|
+
const diag = {
|
|
391
|
+
subtype: resultMsg.subtype,
|
|
392
|
+
is_error: resultMsg.is_error,
|
|
393
|
+
num_turns: resultMsg.num_turns,
|
|
394
|
+
duration_ms: resultMsg.duration_ms,
|
|
395
|
+
duration_api_ms: resultMsg.duration_api_ms,
|
|
396
|
+
total_cost_usd: resultMsg.total_cost_usd,
|
|
397
|
+
session_id: resultMsg.session_id,
|
|
398
|
+
passed_session_id: options.sessionId ?? null,
|
|
399
|
+
usage,
|
|
400
|
+
modelOverride,
|
|
401
|
+
cwd: options.workingDir,
|
|
402
|
+
effort: options.effort,
|
|
403
|
+
systemPromptLen: systemPrompt.length,
|
|
404
|
+
promptLen: prompt.length,
|
|
405
|
+
historyLen: options.history?.length ?? 0,
|
|
406
|
+
allowedToolsCount: (options.allowedTools ?? defaultAllowed).length,
|
|
407
|
+
hasMcp: Object.keys(mcpServers).length > 0,
|
|
408
|
+
};
|
|
409
|
+
console.warn(`[empty-stream] SDK returned 0 output tokens โ diagnostic dump:`, JSON.stringify(diag));
|
|
410
|
+
}
|
|
411
|
+
catch (diagErr) {
|
|
412
|
+
console.warn(`[empty-stream] SDK returned 0 output tokens (diagnostic serialisation failed: ${diagErr})`);
|
|
413
|
+
}
|
|
380
414
|
const hint = "โ ๏ธ Claude antwortete mit leerem Stream. " +
|
|
381
415
|
"Meist Folge einer stale SDK-Session nach /extra-usage, /login oder Token-Refresh. " +
|
|
382
416
|
"Ich starte die Session automatisch neu โ bitte schick die Nachricht einfach nochmal.";
|
|
@@ -109,8 +109,18 @@ export class ProviderRegistry {
|
|
|
109
109
|
* and asking the user to retry. The failover is only silent when
|
|
110
110
|
* the failing provider hadn't committed any visible text yet.
|
|
111
111
|
*/
|
|
112
|
-
async *queryWithFallback(options) {
|
|
113
|
-
|
|
112
|
+
async *queryWithFallback(options, providerOverride) {
|
|
113
|
+
// v4.19.0 โ Per-workspace provider override. If supplied AND registered,
|
|
114
|
+
// it becomes the primary for this query; fallbacks still apply so the
|
|
115
|
+
// bot can degrade gracefully if the override provider is unavailable.
|
|
116
|
+
const primary = providerOverride && this.providers.has(providerOverride)
|
|
117
|
+
? providerOverride
|
|
118
|
+
: this.activeKey;
|
|
119
|
+
const chain = [primary, ...this.fallbackKeys.filter(k => k !== primary)];
|
|
120
|
+
// Also include activeKey as a last-resort fallback if override was used
|
|
121
|
+
if (providerOverride && !chain.includes(this.activeKey)) {
|
|
122
|
+
chain.push(this.activeKey);
|
|
123
|
+
}
|
|
114
124
|
const errors = [];
|
|
115
125
|
for (const key of chain) {
|
|
116
126
|
const provider = this.providers.get(key);
|
package/dist/services/voice.js
CHANGED
|
@@ -55,7 +55,16 @@ export async function transcribeAudio(audioPath) {
|
|
|
55
55
|
});
|
|
56
56
|
}
|
|
57
57
|
// โโ Text-to-Speech (Edge TTS via node-edge-tts) โโโโโโโโ
|
|
58
|
-
|
|
58
|
+
/**
|
|
59
|
+
* Convert text to speech and return path to the MP3 file.
|
|
60
|
+
*
|
|
61
|
+
* @param text The text to synthesize.
|
|
62
|
+
* @param voice Optional voice ID / name override (v4.19.0 โ per-workspace).
|
|
63
|
+
* For ElevenLabs this is the Voice ID, for Edge TTS the Voice Name
|
|
64
|
+
* (e.g. "de-DE-ConradNeural", "en-US-JennyNeural"). When undefined,
|
|
65
|
+
* the config default is used.
|
|
66
|
+
*/
|
|
67
|
+
export async function textToSpeech(text, voice) {
|
|
59
68
|
// Strip markdown formatting for cleaner TTS
|
|
60
69
|
let cleanText = text
|
|
61
70
|
.replace(/```[\s\S]*?```/g, " Code block skipped. ")
|
|
@@ -77,7 +86,9 @@ export async function textToSpeech(text) {
|
|
|
77
86
|
if (config.ttsProvider === "elevenlabs" && config.elevenlabs.apiKey) {
|
|
78
87
|
try {
|
|
79
88
|
const { elevenLabsTTS } = await import("./elevenlabs.js");
|
|
80
|
-
|
|
89
|
+
// v4.19.0 โ per-workspace voice override. When unset, elevenLabsTTS falls
|
|
90
|
+
// back to config.elevenlabs.voiceId.
|
|
91
|
+
return await elevenLabsTTS(cleanText, voice);
|
|
81
92
|
}
|
|
82
93
|
catch (err) {
|
|
83
94
|
console.warn("ElevenLabs TTS failed, falling back to Edge TTS:", err instanceof Error ? err.message : err);
|
|
@@ -86,8 +97,9 @@ export async function textToSpeech(text) {
|
|
|
86
97
|
// Edge TTS (default / fallback)
|
|
87
98
|
const outputPath = path.join(TEMP_DIR, `tts_${Date.now()}.mp3`);
|
|
88
99
|
const tts = new EdgeTTS({
|
|
89
|
-
|
|
90
|
-
|
|
100
|
+
// v4.19.0 โ allow workspace override; default German male.
|
|
101
|
+
voice: voice || "de-DE-ConradNeural",
|
|
102
|
+
lang: voice && voice.match(/^[a-z]{2}-[A-Z]{2}/) ? voice.slice(0, 5) : "de-DE",
|
|
91
103
|
outputFormat: "audio-24khz-48kbitrate-mono-mp3",
|
|
92
104
|
});
|
|
93
105
|
await tts.ttsPromise(cleanText, outputPath);
|
|
@@ -32,6 +32,16 @@ import os from "os";
|
|
|
32
32
|
import path from "path";
|
|
33
33
|
import { WORKSPACES_DIR } from "../paths.js";
|
|
34
34
|
import { config } from "../config.js";
|
|
35
|
+
/** Map a toolset preset to the concrete allowedTools list. */
|
|
36
|
+
export function toolsetToAllowedTools(toolset) {
|
|
37
|
+
if (!toolset || toolset === "full")
|
|
38
|
+
return undefined; // undefined = use provider default
|
|
39
|
+
if (toolset === "readonly")
|
|
40
|
+
return ["Read", "Glob", "Grep", "WebSearch", "WebFetch"];
|
|
41
|
+
if (toolset === "research")
|
|
42
|
+
return ["Read", "WebSearch", "WebFetch", "Grep"];
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
35
45
|
const registry = new Map();
|
|
36
46
|
/** Expand ~ at the start of a path to the user's home directory. */
|
|
37
47
|
function expandHome(p) {
|
|
@@ -100,6 +110,18 @@ function readWorkspaceFile(filePath, name) {
|
|
|
100
110
|
const color = typeof fm.color === "string" ? fm.color : undefined;
|
|
101
111
|
const emoji = typeof fm.emoji === "string" ? fm.emoji : undefined;
|
|
102
112
|
const model = typeof fm.model === "string" && fm.model.trim() ? fm.model.trim() : undefined;
|
|
113
|
+
// v4.19.0 โ per-workspace runtime overrides
|
|
114
|
+
const effortRaw = typeof fm.effort === "string" ? fm.effort.trim().toLowerCase() : "";
|
|
115
|
+
const effort = (effortRaw === "low" || effortRaw === "medium" || effortRaw === "high")
|
|
116
|
+
? effortRaw : undefined;
|
|
117
|
+
const provider = typeof fm.provider === "string" && fm.provider.trim() ? fm.provider.trim() : undefined;
|
|
118
|
+
const voice = typeof fm.voice === "string" && fm.voice.trim() ? fm.voice.trim() : undefined;
|
|
119
|
+
const temperatureRaw = typeof fm.temperature === "string" ? parseFloat(fm.temperature) : (typeof fm.temperature === "number" ? fm.temperature : NaN);
|
|
120
|
+
const temperature = Number.isFinite(temperatureRaw) && temperatureRaw >= 0 && temperatureRaw <= 2
|
|
121
|
+
? temperatureRaw : undefined;
|
|
122
|
+
const toolsetRaw = typeof fm.toolset === "string" ? fm.toolset.trim().toLowerCase() : "";
|
|
123
|
+
const toolset = (toolsetRaw === "full" || toolsetRaw === "readonly" || toolsetRaw === "research")
|
|
124
|
+
? toolsetRaw : undefined;
|
|
103
125
|
const channels = Array.isArray(fm.channels)
|
|
104
126
|
? fm.channels.filter((c) => typeof c === "string")
|
|
105
127
|
: [];
|
|
@@ -111,6 +133,11 @@ function readWorkspaceFile(filePath, name) {
|
|
|
111
133
|
emoji,
|
|
112
134
|
channels,
|
|
113
135
|
model,
|
|
136
|
+
effort,
|
|
137
|
+
provider,
|
|
138
|
+
voice,
|
|
139
|
+
temperature,
|
|
140
|
+
toolset,
|
|
114
141
|
systemPromptOverride: body.trim(),
|
|
115
142
|
};
|
|
116
143
|
}
|