alvin-bot 4.18.5 โ†’ 4.19.0

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,48 @@
2
2
 
3
3
  All notable changes to Alvin Bot are documented here.
4
4
 
5
+ ## [4.19.0] โ€” 2026-04-24
6
+
7
+ ### ๐Ÿงญ Feature: per-workspace runtime overrides (effort ยท provider ยท voice ยท temperature ยท toolset)
8
+
9
+ 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>`.
10
+
11
+ **New workspace frontmatter fields** (`~/.alvin-bot/workspaces/<name>.md`):
12
+
13
+ ```yaml
14
+ ---
15
+ purpose: my-project
16
+ cwd: ~/path/to/workdir
17
+ model: opus # already existed
18
+ effort: high # NEW โ€” low | medium | high
19
+ provider: claude-sdk # NEW โ€” registry key; fallback chain still applies
20
+ voice: iP95p4xoKVk53GoZ742B # NEW โ€” ElevenLabs voice ID, or Edge-TTS voice name (e.g. "en-US-JennyNeural")
21
+ temperature: 0.3 # NEW โ€” 0โ€“2 sampling temperature
22
+ toolset: research # NEW โ€” full (default) | readonly | research
23
+ ---
24
+ Persona body continues here...
25
+ ```
26
+
27
+ **Toolset presets** (map to concrete `allowedTools` lists via the new exported `toolsetToAllowedTools` helper):
28
+ - `full` โ€” provider default (`Read, Write, Edit, Bash, Glob, Grep, WebSearch, WebFetch, Task` + MCP)
29
+ - `readonly` โ€” `Read, Glob, Grep, WebSearch, WebFetch` (no Write/Edit/Bash)
30
+ - `research` โ€” `Read, WebSearch, WebFetch, Grep` (pure research mode)
31
+
32
+ **Implementation:**
33
+
34
+ - `src/services/workspaces.ts` โ€” `Workspace` interface + `parseFrontmatter` extended; numeric parsing + enum validation so a malformed value is silently dropped and the session-default wins.
35
+ - `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.
36
+ - `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.
37
+ - `src/providers/claude-sdk-provider.ts` โ€” passes `options.temperature` through to the Agent SDK when set.
38
+ - `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`).
39
+
40
+ **Net effect:** Each workspace becomes a self-contained runtime profile. For example:
41
+ - A `prep` workspace with `model: opus ยท effort: high ยท temperature: 0.3 ยท voice: en-US-JennyNeural` for polished long-form work;
42
+ - A `research` workspace with `toolset: research ยท model: haiku ยท effort: low ยท temperature: 0.7` for cheap-and-fast web spelunking;
43
+ - A `sensitive` workspace with `toolset: readonly ยท provider: claude-sdk` so the agent cannot accidentally `Write` or `Bash` inside the cwd.
44
+
45
+ No data migration required โ€” existing workspace files without the new fields keep working identically.
46
+
5
47
  ## [4.18.5] โ€” 2026-04-23
6
48
 
7
49
  ### ๐Ÿ› Fix: auto-reset stale SDK sessionId on empty-stream detection
@@ -381,14 +381,23 @@ export async function handleMessage(ctx) {
381
381
  }
382
382
  }
383
383
  }
384
+ // v4.19.0 โ€” Per-workspace runtime overrides. Each is only applied when
385
+ // the workspace explicitly set it; otherwise the session/provider default
386
+ // wins. Toolset is mapped to a concrete allowedTools list via
387
+ // toolsetToAllowedTools(); providers that ignore allowedTools (Ollama etc.)
388
+ // just drop it.
389
+ const { toolsetToAllowedTools } = await import("../services/workspaces.js");
390
+ const wsAllowed = toolsetToAllowedTools(workspace.toolset);
384
391
  const queryOpts = {
385
392
  prompt: bridgedPrompt,
386
393
  systemPrompt,
387
394
  workingDir: session.workingDir,
388
- effort: session.effort,
395
+ effort: workspace.effort ?? session.effort,
389
396
  // v4.15 โ€” Per-workspace model override (optional YAML `model:` field).
390
- // When unset, falls through to the globally active provider's model.
397
+ // v4.19 โ€” ditto for temperature and toolset-derived allowedTools.
391
398
  ...(workspace.model ? { model: workspace.model } : {}),
399
+ ...(workspace.temperature !== undefined ? { temperature: workspace.temperature } : {}),
400
+ ...(wsAllowed ? { allowedTools: wsAllowed } : {}),
392
401
  abortSignal: session.abortController.signal,
393
402
  // User's UI locale โ€” registry uses it to localize failure messages.
394
403
  locale: session.language,
@@ -420,7 +429,7 @@ export async function handleMessage(ctx) {
420
429
  // readable description (which only appears in the tool_use input,
421
430
  // not in the tool_result text). See Fix #17 Stage 2.
422
431
  let lastAgentToolUseInput;
423
- for await (const chunk of registry.queryWithFallback(queryOpts)) {
432
+ for await (const chunk of registry.queryWithFallback(queryOpts, workspace.provider)) {
424
433
  // v4.12.1 โ€” Update pending-sync-task state FIRST so the timer's
425
434
  // next reset picks up the new state. This ordering is load-bearing:
426
435
  // reversing it means the timer rearms with stale state. A sync
@@ -608,7 +617,7 @@ export async function handleMessage(ctx) {
608
617
  if (session.voiceReply && finalText.trim()) {
609
618
  try {
610
619
  await ctx.api.sendChatAction(ctx.chat.id, "upload_voice");
611
- const audioPath = await textToSpeech(finalText);
620
+ const audioPath = await textToSpeech(finalText, workspace.voice);
612
621
  await ctx.replyWithVoice(new InputFile(fs.readFileSync(audioPath), "response.mp3"));
613
622
  fs.unlink(audioPath, () => { });
614
623
  }
@@ -164,14 +164,19 @@ export async function handlePlatformMessage(msg, adapter) {
164
164
  // in the system prompt for this query.
165
165
  const isFirstSDKTurn = isSDK && session.sessionId === null;
166
166
  const systemPrompt = (await buildSmartSystemPrompt(isSDK, session.language, fullText, msg.chatId, isFirstSDKTurn, workspace.systemPromptOverride)) + skillContext;
167
+ // v4.19.0 โ€” Per-workspace runtime overrides (model/effort/temperature/toolset).
168
+ const { toolsetToAllowedTools } = await import("../services/workspaces.js");
169
+ const wsAllowed = toolsetToAllowedTools(workspace.toolset);
167
170
  const queryOpts = {
168
171
  prompt: fullText,
169
172
  systemPrompt,
170
173
  workingDir: session.workingDir,
171
- effort: session.effort,
174
+ effort: workspace.effort ?? session.effort,
172
175
  // v4.15 โ€” Per-workspace model override (optional YAML `model:` field).
173
- // When unset, falls through to the globally active provider's model.
176
+ // v4.19 โ€” ditto for temperature and toolset-derived allowedTools.
174
177
  ...(workspace.model ? { model: workspace.model } : {}),
178
+ ...(workspace.temperature !== undefined ? { temperature: workspace.temperature } : {}),
179
+ ...(wsAllowed ? { allowedTools: wsAllowed } : {}),
175
180
  sessionId: isSDK ? session.sessionId : null,
176
181
  history: !isSDK ? session.history : undefined,
177
182
  // v4.14 โ€” Expose alvin_dispatch_agent MCP tool on non-Telegram
@@ -190,7 +195,7 @@ export async function handlePlatformMessage(msg, adapter) {
190
195
  if (!isSDK) {
191
196
  addToHistory(sessionKey, { role: "user", content: fullText });
192
197
  }
193
- for await (const chunk of registry.queryWithFallback(queryOpts)) {
198
+ for await (const chunk of registry.queryWithFallback(queryOpts, workspace.provider)) {
194
199
  switch (chunk.type) {
195
200
  case "text":
196
201
  finalText = chunk.text || "";
@@ -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
@@ -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
- const chain = [this.activeKey, ...this.fallbackKeys.filter(k => k !== this.activeKey)];
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);
@@ -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
- export async function textToSpeech(text) {
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
- return await elevenLabsTTS(cleanText);
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
- voice: "de-DE-ConradNeural",
90
- lang: "de-DE",
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "alvin-bot",
3
- "version": "4.18.5",
3
+ "version": "4.19.0",
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",