alvin-bot 4.11.0 β†’ 4.12.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,111 @@
2
2
 
3
3
  All notable changes to Alvin Bot are documented here.
4
4
 
5
+ ## [4.12.0] β€” 2026-04-13
6
+
7
+ ### 🧭 Multi-Session + Slack Interface β€” parallel contexts, per-channel workspaces
8
+
9
+ A colleague's feature request the same day v4.11.0 shipped: *"Multiple Session und Interface ΓΌber Slack β€” wie bei OpenClaw. Du hast mehrere parallele Sessions, die den jeweiligen Kontext voneinander nicht kennen aber in sich einen bestimmten Kontext und Zweck haben. Sie hatten dabei Zugriff auf das gesamte Knowledge (Skills + Memory). Und konnten bei Bedarf eigene agents starten."*
10
+
11
+ The ultra-analysis revealed Alvin was already ~80% built for this: the Slack adapter existed (355 LOC with `@slack/bolt@4.6.0`), the platform abstraction was clean, `buildSessionKey()` already supported `per-channel` mode, `session.workingDir` was already per-session, sub-agents were already async and session-isolated (v4.10.0), and memory/skills were already globally shared. **The single blocker: one line in `platform-message.ts` that bypassed `buildSessionKey` with a naive `hashUserId(userId)`, collapsing every non-Telegram channel from the same user into one session.**
12
+
13
+ This release adds a thin workspace layer on top plus Slack polish. **No breaking changes** β€” if no workspaces are configured, pre-v4.12 behavior is preserved exactly.
14
+
15
+ #### P0 #1 β€” Session-Key Fix (`src/handlers/platform-message.ts`)
16
+
17
+ `handlePlatformMessage` now routes through `buildSessionKey(msg.platform, msg.chatId, msg.userId)` instead of `hashUserId(msg.userId)`. On Slack with `SESSION_MODE=per-channel`, each channel gets its own session. Cross-channel isolation is automatic.
18
+
19
+ `buildSessionKey` signature widened from `userId: number` to `userId: string | number` so Slack user IDs (`U01ABC...`) pass through unchanged.
20
+
21
+ **6 unit tests** covering per-channel / per-channel-peer / per-user modes, cross-channel isolation, cross-platform isolation, and backwards compat with numeric Telegram user IDs.
22
+
23
+ #### P0 #2 β€” Workspace Registry (`src/services/workspaces.ts`, NEW)
24
+
25
+ Loads `~/.alvin-bot/workspaces/*.md` markdown files with YAML frontmatter. Each workspace has: `name`, `purpose`, `cwd`, optional `color`/`emoji`, explicit `channels: []` array for ID-based mapping, and a markdown body that becomes the system prompt override.
26
+
27
+ Hot-reload via `fs.watch()` with 500 ms debounce β€” same pattern as `src/services/skills.ts`. Changes to workspace files are picked up without a bot restart.
28
+
29
+ Public API: `loadWorkspaces`, `reloadWorkspaces`, `listWorkspaces`, `getWorkspace`, `getDefaultWorkspace`, `matchWorkspaceForChannel`, `resolveWorkspaceOrDefault`, `initWorkspaces`, `startWorkspaceWatcher`, `stopWorkspaceWatcher`.
30
+
31
+ **13 unit tests** covering default fallback, single/multi-workspace load, `~` expansion in cwd, channel-ID match, channel-name match, hot-reload, non-`.md` file skipping, malformed frontmatter resilience, missing directory graceful handling.
32
+
33
+ #### P0 #3 β€” Workspace Resolver Integration (`src/handlers/platform-message.ts`, `src/handlers/message.ts`)
34
+
35
+ Both the platform handler (Slack/Discord/WhatsApp) and the Telegram main handler now resolve the incoming message to a workspace before building the system prompt. If the session's `workspaceName` changed vs. the previous turn, `workingDir` is updated and persisted via `session-persistence` (v4.11.0).
36
+
37
+ `buildSystemPrompt` and `buildSmartSystemPrompt` gained a new optional `workspacePersona` parameter that injects a `## Workspace Persona` section into the system prompt. Empty string = no-op (default workspace).
38
+
39
+ `UserSession` gained a new `workspaceName: string | null` field. Persisted across restarts via the new v2 envelope format in `sessions.json` (backwards compatible with v4.11 flat format β€” the loader auto-detects).
40
+
41
+ #### P0 #4 β€” Slack Setup Documentation (`docs/install/slack-setup.md`, `docs/install/slack-manifest.json`)
42
+
43
+ Step-by-step guide: create Slack App from manifest β†’ Socket Mode β†’ App-Level Token β†’ Bot Token β†’ `~/.alvin-bot/.env` β†’ restart β†’ invite bot β†’ create workspace files. Covers troubleshooting for common issues. The `slack-manifest.json` is copy-paste-ready: pre-configured bot user, all required scopes, event subscriptions, Socket Mode enabled. Both files are gitignored (Ali's docs/install/ convention) and ship via GitHub Release assets.
44
+
45
+ #### P1 #1 β€” Slack Progress Ticker (`src/platforms/slack.ts`)
46
+
47
+ `SlackAdapter.sendText()` now returns the message `ts` so callers can hold on to it. New `SlackAdapter.editMessage(chatId, messageId, newText)` wraps `chat.update`. Fail-silent: if Slack API errors, the ticker degrades gracefully and the full message still arrives at query end.
48
+
49
+ `PlatformAdapter` interface: `sendText` return type widened from `void` to `string | void`, optional `editMessage` method added. Existing adapters (Telegram, WhatsApp, Discord, Signal) that don't implement `editMessage` are unaffected.
50
+
51
+ **3 unit tests** with mocked `@slack/bolt` covering `chat.update` call, `sendText` ts return, and graceful failure handling.
52
+
53
+ #### P1 #2 β€” Slack Typing Status + Channel Name Resolution (`src/platforms/slack.ts`)
54
+
55
+ `SlackAdapter.setTyping()` now calls `assistant.threads.setStatus` so Slack shows "Alvin is thinking…" under the message during long queries. Silently no-ops in channels where the assistant scope isn't granted.
56
+
57
+ New `SlackAdapter.getChannelName(channelId)` resolves + caches channel names via `conversations.info`. `platform-message.ts` detects this helper via duck-typing on the adapter and passes the resolved name to `resolveWorkspaceOrDefault` β€” enabling channel-name matching (`#alev-b` β†’ `workspaces/alev-b.md`) without hardcoding the Slack type in the platform handler.
58
+
59
+ #### P1 #3 β€” Telegram `/workspace` + `/workspaces` Commands
60
+
61
+ Feature parity for Telegram. `/workspaces` lists all configured workspaces with emojis, purposes, and the active one marked βœ…. `/workspace <name>` switches the active workspace for the Telegram user; next message uses the new persona and cwd. `/workspace default` resets.
62
+
63
+ New `session.ts` exports: `getTelegramWorkspace(userId)` / `setTelegramWorkspace(userId, name)` + a module-level `telegramWorkspaces` map persisted via a new v2 envelope format in `sessions.json` (backwards compatible with v4.11 flat format).
64
+
65
+ **5 new unit tests** covering getter/setter/null-clear, persistence roundtrip, and v4.11 flat-format backwards compat.
66
+
67
+ #### P1 #4 β€” Per-Workspace Cost Aggregation (`src/services/session.ts`)
68
+
69
+ New `getCostByWorkspace()` helper aggregates `session.totalCost` by `session.workspaceName` across all active sessions in memory. Returns per-workspace totals for cost, session count, message count, and tool use count. Used by the Web UI workspace cards.
70
+
71
+ Sessions with `workspaceName === null` aggregate under `"default"` in the breakdown.
72
+
73
+ #### P1 #5 β€” Web UI Workspace Cards (`src/web/server.ts`, `web/public/index.html`, `web/public/js/app.js`)
74
+
75
+ New `GET /api/workspaces` endpoint returns the workspace registry merged with `getCostByWorkspace()`. Dashboard SPA gains a "🧭 Workspaces" page in the Data section of the sidebar (between Sessions and Files). Cards show emoji, name, purpose, cwd, channel mappings, session count, message count, and cumulative cost β€” color-coded via workspace frontmatter `color` field.
76
+
77
+ Default workspace is always included even when no user configs exist, so the UI always shows at least one card.
78
+
79
+ #### Architecture Decisions
80
+
81
+ - **Workspace is channel-scoped, not thread-scoped.** Slack channel = workspace. Threads within a channel are continuations of the same session.
82
+ - **Memory stays global.** All workspaces share `MEMORY.md`, the Hub memory, and the embeddings index.
83
+ - **Provider stays global.** Per-workspace provider override deferred to v4.13.
84
+ - **`@slack/bolt@^4.6.0`** is a regular dep, already in `package.json` from a previous branch.
85
+ - **Backwards compat is absolute.** If no workspaces exist, `resolveWorkspaceOrDefault` returns the default workspace with empty persona + global cwd. v4.11 flat-format `sessions.json` files still load without migration.
86
+ - **v2 envelope format**: `sessions.json` is now `{ version: 2, sessions: {...}, telegramWorkspaces: {...} }`. Loader auto-detects and handles both legacy flat format and new envelope.
87
+
88
+ #### Testing
89
+
90
+ **330 tests total** (292 baseline from v4.11 + 38 new). All green. TSC clean.
91
+
92
+ - 6 platform-session-key unit tests
93
+ - 14 workspaces unit + integration tests
94
+ - 3 slack-progress-ticker tests (mocked @slack/bolt)
95
+ - 5 telegram-workspace-command tests
96
+ - 10 multi-session end-to-end stress tests
97
+
98
+ **Live verified** via `tmp/live-multi-session.mjs` probe against the real `dist/`: 5 parallel workspaces, 5 simulated Slack channels, full persistence roundtrip with v2 envelope, cost aggregation, hot-reload picking up new workspace files, channel-name fallback, telegramWorkspaces map persistence. **All 7 phases passed.**
99
+
100
+ #### Files changed
101
+
102
+ - **NEW code:** `src/services/workspaces.ts`
103
+ - **NEW tests:** `test/platform-session-key.test.ts`, `test/workspaces.test.ts`, `test/slack-progress-ticker.test.ts`, `test/telegram-workspace-command.test.ts`, `test/multi-session-stress.test.ts`
104
+ - **NEW docs (gitignored, in Release assets):** `docs/install/slack-setup.md`, `docs/install/slack-manifest.json`
105
+ - **Modified:** `src/handlers/platform-message.ts`, `src/handlers/message.ts`, `src/handlers/commands.ts`, `src/platforms/slack.ts`, `src/platforms/types.ts`, `src/services/session.ts`, `src/services/session-persistence.ts`, `src/services/personality.ts`, `src/paths.ts`, `src/index.ts`, `src/web/server.ts`, `web/public/index.html`, `web/public/js/app.js`
106
+ - **Plan:** `docs/superpowers/plans/2026-04-13-multi-session-slack.md`
107
+
108
+ ---
109
+
5
110
  ## [4.11.0] β€” 2026-04-13
6
111
 
7
112
  ### 🧠 Memory Persistence + Smart Loading β€” sessions survive restart, memory is layered
@@ -2,7 +2,8 @@ import { InlineKeyboard, InputFile } from "grammy";
2
2
  import fs from "fs";
3
3
  import path, { resolve } from "path";
4
4
  import os from "os";
5
- import { getSession, resetSession, markSessionDirty } from "../services/session.js";
5
+ import { getSession, resetSession, markSessionDirty, getTelegramWorkspace, setTelegramWorkspace } from "../services/session.js";
6
+ import { listWorkspaces, getWorkspace } from "../services/workspaces.js";
6
7
  import { getRegistry } from "../engine.js";
7
8
  import { reloadSoul } from "../services/personality.js";
8
9
  import { parseDuration, createReminder, listReminders, cancelReminder } from "../services/reminders.js";
@@ -425,6 +426,50 @@ export function registerCommands(bot) {
425
426
  markSessionDirty(userId);
426
427
  await ctx.reply(`βœ… Effort: ${EFFORT_LABELS[session.effort]}`);
427
428
  });
429
+ // v4.12.0 P1 #3 β€” Multi-workspace support on Telegram
430
+ bot.command("workspaces", async (ctx) => {
431
+ const userId = ctx.from.id;
432
+ const active = getTelegramWorkspace(userId) ?? "default";
433
+ const all = listWorkspaces();
434
+ if (all.length === 0) {
435
+ await ctx.reply("🧭 No workspaces configured.\n\n" +
436
+ "Create one by adding a file at `~/.alvin-bot/workspaces/<name>.md` " +
437
+ "with YAML frontmatter. See docs/install/slack-setup.md for the format.", { parse_mode: "Markdown" });
438
+ return;
439
+ }
440
+ const lines = [`🧭 *Workspaces* (active: \`${active}\`)`, ""];
441
+ for (const ws of all) {
442
+ const marker = ws.name === active ? "βœ…" : (ws.emoji ?? "β–ͺ️");
443
+ const purpose = ws.purpose || "(no purpose)";
444
+ lines.push(`${marker} \`${ws.name}\` β€” ${purpose}`);
445
+ }
446
+ lines.push("");
447
+ lines.push("Switch with: `/workspace <name>` Β· Reset: `/workspace default`");
448
+ await ctx.reply(lines.join("\n"), { parse_mode: "Markdown" });
449
+ });
450
+ bot.command("workspace", async (ctx) => {
451
+ const userId = ctx.from.id;
452
+ const arg = ctx.match?.trim();
453
+ if (!arg) {
454
+ const active = getTelegramWorkspace(userId) ?? "default";
455
+ const ws = active === "default" ? null : getWorkspace(active);
456
+ const purpose = ws?.purpose || "global default β€” no persona, global cwd";
457
+ await ctx.reply(`🧭 Active workspace: *${active}*\n_${purpose}_\n\nUse \`/workspaces\` to see all available.`, { parse_mode: "Markdown" });
458
+ return;
459
+ }
460
+ if (arg === "default" || arg === "reset") {
461
+ setTelegramWorkspace(userId, null);
462
+ await ctx.reply("βœ… Switched to the default workspace.");
463
+ return;
464
+ }
465
+ const ws = getWorkspace(arg);
466
+ if (!ws) {
467
+ await ctx.reply(`❌ Workspace \`${arg}\` not found.\nUse \`/workspaces\` to list available ones.`, { parse_mode: "Markdown" });
468
+ return;
469
+ }
470
+ setTelegramWorkspace(userId, arg);
471
+ await ctx.reply(`βœ… Switched to workspace *${ws.emoji ?? "🧭"} ${ws.name}*\n_${ws.purpose || "(no purpose set)"}_\n\nNext message will use this workspace's persona and cwd (\`${ws.cwd}\`).`, { parse_mode: "Markdown" });
472
+ });
428
473
  // Inline keyboard callback for effort switching
429
474
  bot.callbackQuery(/^effort:(.+)$/, async (ctx) => {
430
475
  const level = ctx.match[1];
@@ -1,6 +1,7 @@
1
1
  import { InputFile } from "grammy";
2
2
  import fs from "fs";
3
- import { getSession, addToHistory, trackProviderUsage, buildSessionKey } from "../services/session.js";
3
+ import { getSession, addToHistory, trackProviderUsage, buildSessionKey, getTelegramWorkspace } from "../services/session.js";
4
+ import { resolveWorkspaceOrDefault, getWorkspace } from "../services/workspaces.js";
4
5
  import { TelegramStreamer } from "../services/telegram.js";
5
6
  import { getRegistry } from "../engine.js";
6
7
  import { textToSpeech } from "../services/voice.js";
@@ -226,10 +227,22 @@ export async function handleMessage(ctx) {
226
227
  // and rehydrated sessions where the persisted snapshot lacked a sessionId.
227
228
  // After the first SDK turn, Claude resumes via SDK session_id and already
228
229
  // carries the recalled context β€” no need for another search per turn.
230
+ //
231
+ // v4.12.0 β€” Resolve the user's active Telegram workspace (if any) and
232
+ // forward the persona to buildSmartSystemPrompt. If the workspace
233
+ // changed since last turn, update session's workingDir + workspaceName.
234
+ const activeWsName = getTelegramWorkspace(userId);
235
+ const workspace = activeWsName
236
+ ? (getWorkspace(activeWsName) ?? resolveWorkspaceOrDefault("telegram", String(userId), undefined))
237
+ : resolveWorkspaceOrDefault("telegram", String(userId), undefined);
238
+ if (session.workspaceName !== workspace.name) {
239
+ session.workspaceName = workspace.name;
240
+ session.workingDir = workspace.cwd;
241
+ }
229
242
  const chatIdStr = String(ctx.chat.id);
230
243
  const skillContext = buildSkillContext(text);
231
244
  const isFirstSDKTurn = isSDK && session.sessionId === null;
232
- const systemPrompt = (await buildSmartSystemPrompt(isSDK, session.language, text, chatIdStr, isFirstSDKTurn)) + skillContext;
245
+ const systemPrompt = (await buildSmartSystemPrompt(isSDK, session.language, text, chatIdStr, isFirstSDKTurn, workspace.systemPromptOverride)) + skillContext;
233
246
  // Track the user turn in history regardless of provider type. This keeps
234
247
  // the fallback path (Ollama etc.) aware of what was said on SDK turns.
235
248
  addToHistory(userId, { role: "user", content: text });
@@ -7,7 +7,8 @@
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 } from "../services/session.js";
10
+ import { getSession, addToHistory, trackProviderUsage, buildSessionKey } from "../services/session.js";
11
+ import { resolveWorkspaceOrDefault } from "../services/workspaces.js";
11
12
  import { getRegistry } from "../engine.js";
12
13
  import { buildSmartSystemPrompt } from "../services/personality.js";
13
14
  import { buildSkillContext } from "../services/skills.js";
@@ -87,9 +88,38 @@ export async function handlePlatformMessage(msg, adapter) {
87
88
  const cmdHandled = await handlePlatformCommand(text, msg, adapter);
88
89
  if (cmdHandled)
89
90
  return;
90
- const userId = hashUserId(msg.userId);
91
- const session = getSession(userId);
92
- touchProfile(userId, msg.userName, msg.userHandle, msg.platform, text);
91
+ // v4.12.0 β€” Use buildSessionKey so each channel on Slack/Discord/WhatsApp
92
+ // gets its own session. Before v4.12.0 we hashed just userId, which
93
+ // collapsed every channel from the same user into one session and broke
94
+ // multi-session completely on non-Telegram platforms.
95
+ const sessionKey = buildSessionKey(msg.platform, msg.chatId, msg.userId);
96
+ const session = getSession(sessionKey);
97
+ // touchProfile still uses a stable userId-based numeric hash for the
98
+ // user profile store β€” profiles are about *people*, not sessions.
99
+ const profileKey = hashUserId(msg.userId);
100
+ touchProfile(profileKey, msg.userName, msg.userHandle, msg.platform, text);
101
+ // v4.12.0 β€” Workspace resolution: channel β†’ workspace β†’ persona + cwd.
102
+ // P1 #2 β€” If the platform has a getChannelName helper (Slack does), use
103
+ // it to enable channel-name-based workspace matching (e.g. #alev-b β†’
104
+ // workspaces/alev-b.md). Cached in the adapter, so no extra API call
105
+ // after the first hit per channel.
106
+ let channelName;
107
+ const getChannelName = adapter.getChannelName;
108
+ if (typeof getChannelName === "function") {
109
+ try {
110
+ channelName = await getChannelName.call(adapter, msg.chatId);
111
+ }
112
+ catch {
113
+ channelName = undefined;
114
+ }
115
+ }
116
+ const workspace = resolveWorkspaceOrDefault(msg.platform, msg.chatId, channelName);
117
+ // If the workspace changed since last turn, update the session's cwd +
118
+ // workspaceName. This is debounced via session-persistence (v4.11.0).
119
+ if (session.workspaceName !== workspace.name) {
120
+ session.workspaceName = workspace.name;
121
+ session.workingDir = workspace.cwd;
122
+ }
93
123
  // Skip if already processing (queue up to 3)
94
124
  if (session.isProcessing) {
95
125
  if (session.messageQueue.length < 3) {
@@ -130,8 +160,10 @@ export async function handlePlatformMessage(msg, adapter) {
130
160
  const isSDK = activeProvider.config.type === "claude-sdk";
131
161
  const skillContext = buildSkillContext(fullText);
132
162
  // v4.11.0 P0 #3 β€” SDK gets semantic recall on first turn (when no resume token yet).
163
+ // v4.12.0 P0 #3 β€” Workspace persona is forwarded so per-channel personas land
164
+ // in the system prompt for this query.
133
165
  const isFirstSDKTurn = isSDK && session.sessionId === null;
134
- const systemPrompt = (await buildSmartSystemPrompt(isSDK, session.language, fullText, msg.chatId, isFirstSDKTurn)) + skillContext;
166
+ const systemPrompt = (await buildSmartSystemPrompt(isSDK, session.language, fullText, msg.chatId, isFirstSDKTurn, workspace.systemPromptOverride)) + skillContext;
135
167
  const queryOpts = {
136
168
  prompt: fullText,
137
169
  systemPrompt,
@@ -141,7 +173,7 @@ export async function handlePlatformMessage(msg, adapter) {
141
173
  history: !isSDK ? session.history : undefined,
142
174
  };
143
175
  if (!isSDK) {
144
- addToHistory(userId, { role: "user", content: fullText });
176
+ addToHistory(sessionKey, { role: "user", content: fullText });
145
177
  }
146
178
  for await (const chunk of registry.queryWithFallback(queryOpts)) {
147
179
  switch (chunk.type) {
@@ -153,7 +185,7 @@ export async function handlePlatformMessage(msg, adapter) {
153
185
  session.sessionId = chunk.sessionId;
154
186
  if (chunk.costUsd)
155
187
  session.totalCost += chunk.costUsd;
156
- trackProviderUsage(userId, registry.getActiveKey(), chunk.costUsd || 0, chunk.inputTokens, chunk.outputTokens);
188
+ trackProviderUsage(sessionKey, registry.getActiveKey(), chunk.costUsd || 0, chunk.inputTokens, chunk.outputTokens);
157
189
  session.lastActivity = Date.now();
158
190
  break;
159
191
  case "error":
@@ -174,7 +206,7 @@ export async function handlePlatformMessage(msg, adapter) {
174
206
  await adapter.sendText(msg.chatId, finalText);
175
207
  }
176
208
  if (!isSDK && finalText) {
177
- addToHistory(userId, { role: "assistant", content: finalText });
209
+ addToHistory(sessionKey, { role: "assistant", content: finalText });
178
210
  }
179
211
  }
180
212
  }
@@ -198,12 +230,14 @@ async function handlePlatformCommand(text, msg, adapter) {
198
230
  return false;
199
231
  const parts = text.split(/\s+/);
200
232
  const cmd = parts[0].toLowerCase();
201
- const userId = hashUserId(msg.userId);
202
- const session = getSession(userId);
233
+ // v4.12.0 β€” Same buildSessionKey routing as the main handler so /new and
234
+ // /status etc operate on the per-channel session, not the per-user one.
235
+ const sessionKey = buildSessionKey(msg.platform, msg.chatId, msg.userId);
236
+ const session = getSession(sessionKey);
203
237
  switch (cmd) {
204
238
  case "/new": {
205
239
  const { resetSession } = await import("../services/session.js");
206
- resetSession(userId);
240
+ resetSession(sessionKey);
207
241
  await adapter.sendText(msg.chatId, "πŸ”„ New chat started.");
208
242
  return true;
209
243
  }
package/dist/index.js CHANGED
@@ -101,6 +101,10 @@ if (assetScanResult.assets.length > 0) {
101
101
  discoverTools();
102
102
  // Load skill files
103
103
  loadSkills();
104
+ // v4.12.0 β€” Workspace registry: load per-channel configs and start the
105
+ // hot-reload watcher. Safe no-op if no workspaces are configured.
106
+ import { initWorkspaces, stopWorkspaceWatcher } from "./services/workspaces.js";
107
+ initWorkspaces();
104
108
  // Load user-defined lifecycle hooks from ~/.alvin-bot/hooks/
105
109
  const hookCount = loadHooks();
106
110
  if (hookCount > 0)
@@ -258,6 +262,7 @@ const shutdown = async () => {
258
262
  stopScheduler();
259
263
  stopAsyncAgentWatcher();
260
264
  stopSessionCleanup();
265
+ stopWorkspaceWatcher();
261
266
  // v4.11.0 β€” Final immediate flush of in-memory sessions to disk before exit.
262
267
  // The debounced timer might be pending; flushSessions() cancels it and writes
263
268
  // synchronously so the next boot can rehydrate the latest state.
package/dist/paths.js CHANGED
@@ -50,6 +50,11 @@ export const IDENTITY_FILE = resolve(DATA_DIR, "memory", "identity.md");
50
50
  export const PREFERENCES_FILE = resolve(DATA_DIR, "memory", "preferences.md");
51
51
  /** memory/projects/ β€” L2 layer (v4.11.0): per-project context loaded on topic match. */
52
52
  export const PROJECTS_MEMORY_DIR = resolve(DATA_DIR, "memory", "projects");
53
+ /** workspaces/ β€” Per-workspace configuration (v4.12.0).
54
+ * Each file is a markdown doc with YAML frontmatter defining the workspace's
55
+ * name, purpose, cwd, color, emoji, and an optional system prompt body.
56
+ * See src/services/workspaces.ts for the loader and matcher. */
57
+ export const WORKSPACES_DIR = resolve(DATA_DIR, "workspaces");
53
58
  /** memory/.embeddings.json β€” Vector index */
54
59
  export const EMBEDDINGS_IDX = resolve(DATA_DIR, "memory", ".embeddings.json");
55
60
  /** users/ β€” User profiles and per-user memory */
@@ -36,6 +36,8 @@ export class SlackAdapter {
36
36
  botUserId = "";
37
37
  botToken;
38
38
  appToken;
39
+ /** v4.12.0 β€” channelId β†’ channelName cache, refreshed on miss via conversations.info */
40
+ channelNameCache = new Map();
39
41
  constructor(botToken, appToken) {
40
42
  this.botToken = botToken;
41
43
  this.appToken = appToken;
@@ -221,8 +223,9 @@ export class SlackAdapter {
221
223
  const chunks = text.length > 3800
222
224
  ? text.match(/.{1,3800}/gs) || [text]
223
225
  : [text];
226
+ let lastTs;
224
227
  for (const chunk of chunks) {
225
- await this.app.client.chat.postMessage({
228
+ const result = await this.app.client.chat.postMessage({
226
229
  token: this.botToken,
227
230
  channel: chatId,
228
231
  text: chunk,
@@ -231,7 +234,30 @@ export class SlackAdapter {
231
234
  // Convert markdown bold/italic to Slack mrkdwn
232
235
  mrkdwn: true,
233
236
  });
237
+ if (result?.ts)
238
+ lastTs = result.ts;
234
239
  }
240
+ return lastTs;
241
+ }
242
+ /** Edit a previously-sent message (for progress tickers on long queries).
243
+ * Fail-silent: ticker UX shouldn't crash the query. */
244
+ async editMessage(chatId, messageId, newText) {
245
+ if (!this.app)
246
+ return messageId;
247
+ try {
248
+ const safeText = newText.length > 3800 ? newText.slice(0, 3800) + "..." : newText;
249
+ await this.app.client.chat.update({
250
+ token: this.botToken,
251
+ channel: chatId,
252
+ ts: messageId,
253
+ text: safeText,
254
+ mrkdwn: true,
255
+ });
256
+ }
257
+ catch {
258
+ // Silent failure β€” ticker UX shouldn't crash the query
259
+ }
260
+ return messageId;
235
261
  }
236
262
  async sendPhoto(chatId, photo, caption) {
237
263
  if (!this.app)
@@ -299,8 +325,46 @@ export class SlackAdapter {
299
325
  catch { /* ignore β€” emoji might not exist */ }
300
326
  }
301
327
  async setTyping(chatId) {
302
- // Slack doesn't have a public typing indicator API for bots
303
- // The closest is the "is typing" shown during Web API calls
328
+ if (!this.app)
329
+ return;
330
+ // v4.12.0 β€” Slack's official "is thinking" API for bots is
331
+ // assistant.threads.setStatus which shows "Alvin is thinking…" under
332
+ // the message. Only works in assistant-enabled channels (scope:
333
+ // assistant:write) β€” silently no-ops in channels where the bot
334
+ // isn't enabled as an assistant.
335
+ try {
336
+ await this.app.client.apiCall("assistant.threads.setStatus", {
337
+ channel_id: chatId,
338
+ status: "is thinking…",
339
+ });
340
+ }
341
+ catch {
342
+ // Not every channel supports assistant threads β€” that's fine
343
+ }
344
+ }
345
+ /** v4.12.0 β€” Look up a Slack channel's name by ID, using a small in-memory
346
+ * cache. Used by platform-message.ts for workspace resolution. */
347
+ async getChannelName(channelId) {
348
+ if (!this.app)
349
+ return undefined;
350
+ const cached = this.channelNameCache.get(channelId);
351
+ if (cached)
352
+ return cached;
353
+ try {
354
+ const result = await this.app.client.conversations.info({
355
+ token: this.botToken,
356
+ channel: channelId,
357
+ });
358
+ const name = result?.channel?.name;
359
+ if (name) {
360
+ this.channelNameCache.set(channelId, name);
361
+ return name;
362
+ }
363
+ }
364
+ catch {
365
+ // IM channels return channel_not_found here β€” that's expected
366
+ }
367
+ return undefined;
304
368
  }
305
369
  async stop() {
306
370
  if (this.app) {
@@ -186,7 +186,7 @@ Always ask yourself first: "Can I solve this with my own intelligence?" If yes
186
186
  * @param isSDK Whether the active provider is the Claude SDK (has tool use)
187
187
  * @param language Preferred language ('de' or 'en')
188
188
  */
189
- export function buildSystemPrompt(isSDK, language = "en", chatId, query) {
189
+ export function buildSystemPrompt(isSDK, language = "en", chatId, query, workspacePersona) {
190
190
  // The deep base prompt has only de/en variants (writing four full
191
191
  // personality templates is out of scope). For es/fr we fall back to
192
192
  // the English base β€” the LLM mirrors the user's conversational language
@@ -214,6 +214,12 @@ export function buildSystemPrompt(isSDK, language = "en", chatId, query) {
214
214
  if (standingOrders) {
215
215
  parts.push("## Standing Orders\n\n" + standingOrders);
216
216
  }
217
+ // v4.12.0 β€” Workspace persona: per-channel system prompt override from
218
+ // ~/.alvin-bot/workspaces/<name>.md body. Only injected when a workspace
219
+ // is resolved for the channel; default workspace passes empty string.
220
+ if (workspacePersona && workspacePersona.trim().length > 0) {
221
+ parts.push("## Workspace Persona\n\n" + workspacePersona.trim());
222
+ }
217
223
  if (isSDK) {
218
224
  parts.push(SDK_ADDON);
219
225
  // Stage 1 β€” teach Claude to use run_in_background for long-running
@@ -266,10 +272,11 @@ export function buildSystemPrompt(isSDK, language = "en", chatId, query) {
266
272
  * context in its conversation history. Non-SDK providers
267
273
  * run the search on every turn (cheap, no resume).
268
274
  */
269
- export async function buildSmartSystemPrompt(isSDK, language = "en", userMessage, chatId, isFirstTurn = false) {
275
+ export async function buildSmartSystemPrompt(isSDK, language = "en", userMessage, chatId, isFirstTurn = false, workspacePersona) {
270
276
  // Pass userMessage as query so L2 project memories matching the topic
271
- // get loaded into the base prompt automatically.
272
- const base = buildSystemPrompt(isSDK, language, chatId, userMessage);
277
+ // get loaded into the base prompt automatically. Workspace persona (v4.12.0)
278
+ // is also threaded through so per-channel personas land in the system prompt.
279
+ const base = buildSystemPrompt(isSDK, language, chatId, userMessage, workspacePersona);
273
280
  if (!userMessage)
274
281
  return base;
275
282
  // Decide whether to run the semantic search:
@@ -21,7 +21,7 @@
21
21
  import fs from "fs";
22
22
  import { dirname } from "path";
23
23
  import { SESSIONS_STATE_FILE } from "../paths.js";
24
- import { getAllSessions, } from "./session.js";
24
+ import { getAllSessions, getTelegramWorkspacesMap, } from "./session.js";
25
25
  /** History entries to keep in the persisted snapshot (per session). */
26
26
  const MAX_PERSISTED_HISTORY = 50;
27
27
  /** Debounce window for grouped mutations. */
@@ -32,6 +32,7 @@ function snapshot(session) {
32
32
  return {
33
33
  sessionId: session.sessionId,
34
34
  workingDir: session.workingDir,
35
+ workspaceName: session.workspaceName,
35
36
  language: session.language,
36
37
  effort: session.effort,
37
38
  voiceReply: session.voiceReply,
@@ -72,9 +73,21 @@ export async function flushSessions() {
72
73
  }
73
74
  // Ensure the state directory exists
74
75
  fs.mkdirSync(dirname(SESSIONS_STATE_FILE), { recursive: true });
76
+ // v4.12.0 β€” Persist Telegram active-workspace map alongside sessions.
77
+ // Wrapped in a versioned envelope so we can add more state later without
78
+ // breaking loadPersistedSessions' backwards-compat path for older files.
79
+ const tgWorkspaces = {};
80
+ for (const [userId, ws] of getTelegramWorkspacesMap()) {
81
+ tgWorkspaces[userId] = ws;
82
+ }
83
+ const envelope = {
84
+ version: 2,
85
+ sessions: out,
86
+ telegramWorkspaces: tgWorkspaces,
87
+ };
75
88
  // Atomic write: tmp + rename
76
89
  const tmpFile = `${SESSIONS_STATE_FILE}.tmp`;
77
- fs.writeFileSync(tmpFile, JSON.stringify(out, null, 2), "utf-8");
90
+ fs.writeFileSync(tmpFile, JSON.stringify(envelope, null, 2), "utf-8");
78
91
  fs.renameSync(tmpFile, SESSIONS_STATE_FILE);
79
92
  }
80
93
  catch (err) {
@@ -106,16 +119,37 @@ export function loadPersistedSessions() {
106
119
  catch {
107
120
  return 0; // no file = nothing to do
108
121
  }
109
- let parsed;
122
+ let raw_parsed;
110
123
  try {
111
- parsed = JSON.parse(raw);
124
+ raw_parsed = JSON.parse(raw);
112
125
  }
113
126
  catch (err) {
114
127
  console.warn("⚠️ session-persistence: corrupt sessions.json, starting fresh β€”", err instanceof Error ? err.message : String(err));
115
128
  return 0;
116
129
  }
117
- if (!parsed || typeof parsed !== "object")
130
+ if (!raw_parsed || typeof raw_parsed !== "object")
118
131
  return 0;
132
+ // v4.12.0 β€” Detect envelope format vs legacy v4.11.0 flat format
133
+ let parsed;
134
+ let tgWorkspaces = {};
135
+ if (raw_parsed &&
136
+ typeof raw_parsed === "object" &&
137
+ "version" in raw_parsed &&
138
+ "sessions" in raw_parsed) {
139
+ const env = raw_parsed;
140
+ parsed = env.sessions ?? {};
141
+ tgWorkspaces = env.telegramWorkspaces ?? {};
142
+ }
143
+ else {
144
+ // Legacy flat format (v4.11.0)
145
+ parsed = raw_parsed;
146
+ }
147
+ // Rehydrate Telegram workspace map
148
+ const tgMap = getTelegramWorkspacesMap();
149
+ for (const [userId, name] of Object.entries(tgWorkspaces)) {
150
+ if (typeof name === "string")
151
+ tgMap.set(userId, name);
152
+ }
119
153
  // Use the same getAllSessions Map that session.ts exports
120
154
  const all = getAllSessions();
121
155
  let count = 0;
@@ -127,6 +161,7 @@ export function loadPersistedSessions() {
127
161
  const restored = {
128
162
  sessionId: persisted.sessionId ?? null,
129
163
  workingDir: persisted.workingDir ?? process.cwd(),
164
+ workspaceName: persisted.workspaceName ?? null,
130
165
  isProcessing: false,
131
166
  abortController: null,
132
167
  lastActivity: persisted.lastActivity ?? Date.now(),
@@ -2,6 +2,35 @@ import { config } from "../config.js";
2
2
  /** Max history entries to keep (to avoid token overflow) */
3
3
  const MAX_HISTORY = 100;
4
4
  const sessions = new Map();
5
+ // v4.12.0 P1 #3 β€” Telegram active-workspace map: userId β†’ workspaceName.
6
+ // Separate from the sessions Map because a user's ACTIVE workspace is an
7
+ // index, not a session itself. Persisted via session-persistence snapshots.
8
+ const telegramWorkspaces = new Map();
9
+ /** Get the user's currently active Telegram workspace. null = default. */
10
+ export function getTelegramWorkspace(userId) {
11
+ return telegramWorkspaces.get(String(userId)) ?? null;
12
+ }
13
+ /** Set the user's currently active Telegram workspace. */
14
+ export function setTelegramWorkspace(userId, name) {
15
+ const key = String(userId);
16
+ if (name === null) {
17
+ telegramWorkspaces.delete(key);
18
+ }
19
+ else {
20
+ telegramWorkspaces.set(key, name);
21
+ }
22
+ // Defer persist() until after it's defined below
23
+ if (_persistHook) {
24
+ try {
25
+ _persistHook();
26
+ }
27
+ catch { /* ignore */ }
28
+ }
29
+ }
30
+ /** For session-persistence.ts β€” expose the raw map for snapshotting. */
31
+ export function getTelegramWorkspacesMap() {
32
+ return telegramWorkspaces;
33
+ }
5
34
  // ── Persistence Hook (v4.11.0) ─────────────────────────────────────
6
35
  //
7
36
  // session-persistence.ts is wired in via attachPersistHook() at bot startup.
@@ -47,6 +76,7 @@ export function getSession(key) {
47
76
  session = {
48
77
  sessionId: null,
49
78
  workingDir: config.defaultWorkingDir,
79
+ workspaceName: null,
50
80
  isProcessing: false,
51
81
  abortController: null,
52
82
  lastActivity: Date.now(),
@@ -199,6 +229,24 @@ export function addToHistory(key, message) {
199
229
  export function getAllSessions() {
200
230
  return sessions;
201
231
  }
232
+ /** v4.12.0 β€” Aggregate session.totalCost by workspaceName across all
233
+ * active sessions. Returns an object keyed by workspace name (null β†’
234
+ * "default") with cumulative cost, session count, message count, and
235
+ * tool use count. Used by the Web UI's workspace overview. */
236
+ export function getCostByWorkspace() {
237
+ const out = {};
238
+ for (const s of sessions.values()) {
239
+ const name = s.workspaceName ?? "default";
240
+ if (!out[name]) {
241
+ out[name] = { totalCost: 0, sessionCount: 0, messageCount: 0, toolUseCount: 0 };
242
+ }
243
+ out[name].totalCost += s.totalCost;
244
+ out[name].sessionCount += 1;
245
+ out[name].messageCount += s.messageCount;
246
+ out[name].toolUseCount += s.toolUseCount;
247
+ }
248
+ return out;
249
+ }
202
250
  /** Kill a user session completely β€” abort running query, clear history, remove from map. */
203
251
  export function killSession(key) {
204
252
  const k = String(key);