alvin-bot 4.10.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,200 @@
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
+
110
+ ## [4.11.0] β€” 2026-04-13
111
+
112
+ ### 🧠 Memory Persistence + Smart Loading β€” sessions survive restart, memory is layered
113
+
114
+ A colleague asked the same day v4.10.0 shipped: *"Memory after session restart is also a bit fiddly. I installed mempalace as a workaround β€” maybe build something like that natively."* He was right. Alvin had a hand-curated `MEMORY.md`, a 128 MB embeddings vector index, and an AI-powered compaction service β€” but **the in-memory `sessions Map` was wiped on every bot restart**. Claude SDK then started a fresh conversation on the next user message, behaving like a goldfish despite all that memory infrastructure on disk.
115
+
116
+ This release fixes that with **five complementary tasks**, all bundled into v4.11.0. Three core fixes (P0) plus two structural improvements (P1) inspired by mempalace's L0–L3 stack and Mem0's auto-extraction pattern.
117
+
118
+ #### P0 #1 β€” Session Persistence (`src/services/session-persistence.ts`, NEW)
119
+
120
+ The core fix. The `sessions Map` in `src/services/session.ts` was in-memory only; every `launchctl kickstart` wiped every user's `sessionId`, history, language, effort, voiceReply, and tracking counters.
121
+
122
+ - **Debounced flush** (1.5 s coalesce window) writes a sanitized snapshot of `getAllSessions()` to `~/.alvin-bot/state/sessions.json` via atomic tmp+rename.
123
+ - **`loadPersistedSessions()`** rehydrates the Map at bot startup; `flushSessions()` flushes synchronously on graceful shutdown (SIGINT/SIGTERM).
124
+ - **`attachPersistHook()` / `markSessionDirty()`** in `session.ts` give handlers a callback to trigger persist after direct mutations (`/lang`, `/effort`, `/voice`). `addToHistory()` and `trackProviderUsage()` trigger it automatically.
125
+ - History is capped at `MAX_PERSISTED_HISTORY = 50` per session so the file stays small.
126
+ - Runtime-only fields (`abortController`, `isProcessing`, `messageQueue`) are stripped before persisting.
127
+ - Schema drift is handled: missing fields fall back to defaults; corrupt JSON loads zero sessions; null root rejected gracefully.
128
+ - **9 unit tests** + **18 stress tests** covering 100-session burst, 1000-mutate debounce coalescing, unicode (RTL/ZWJ/astral plane), atomic write recovery from stale `.tmp`, schema drift, hostile JSON, read-only filesystem, simulated bot restart.
129
+
130
+ #### P0 #2 β€” MEMORY.md Auto-Inject for SDK (`src/services/personality.ts`)
131
+
132
+ Before v4.11.0, only non-SDK providers (Groq, Gemini, NVIDIA) got `buildMemoryContext()` injected into their system prompt. The Claude SDK was *expected* to read memory files via tools, but in practice rarely did unless the user's first message specifically prompted it.
133
+
134
+ - Drops the `!isSDK` guard around `buildMemoryContext()` and asset-index injection.
135
+ - SDK now gets the same compact memory context (MEMORY.md + today + yesterday daily logs) at every turn β€” the same context non-SDK providers had since 4.0.
136
+ - **3 unit tests** verifying SDK includes the memory section, non-SDK regression, and graceful behavior when MEMORY.md is missing.
137
+
138
+ #### P0 #3 β€” Semantic Recall on SDK First Turn (`src/services/personality.ts`, `src/handlers/message.ts`, `src/handlers/platform-message.ts`)
139
+
140
+ `buildSmartSystemPrompt()` now accepts an `isFirstTurn` flag. For SDK providers it runs the embeddings-based `searchMemory()` only on the first turn (`session.sessionId === null` β€” meaning Claude hasn't given us a resume token yet for this session). After the first turn Claude carries the recalled context inside the SDK session via resume, so spamming the embeddings API on every subsequent turn is wasted work. Non-SDK providers still run the search on every turn (no resume mechanism).
141
+
142
+ - `handlers/message.ts` and `handlers/platform-message.ts` updated to compute `isFirstSDKTurn = isSDK && session.sessionId === null` and pass it through.
143
+ - The bare `buildSystemPrompt` calls on the SDK paths are gone β€” `buildSmartSystemPrompt` is the single entry point.
144
+ - **5 mocked-search tests** covering call-count semantics for SDK first/later turns, non-SDK every turn, missing `userMessage` skip, and graceful failure when `searchMemory` throws.
145
+
146
+ #### P1 #4 β€” Layered Memory Loader (`src/services/memory-layers.ts`, NEW)
147
+
148
+ Inspired by mempalace's L0–L3 stack. Replaces the monolithic `MEMORY.md β†’ System Prompt` injection with a structured, token-budgeted layered loader:
149
+
150
+ - **L0** `~/.alvin-bot/memory/identity.md` β€” always loaded, ~200 tokens (core user facts: name, location, family, contact)
151
+ - **L1** `~/.alvin-bot/memory/preferences.md` β€” always loaded (communication style, do's and don'ts)
152
+ - **L1** `~/.alvin-bot/memory/MEMORY.md` β€” backwards-compat: existing curated knowledge (full content if no split files exist; truncated to 1500 chars when split files coexist)
153
+ - **L2** `~/.alvin-bot/memory/projects/*.md` β€” loaded only when the user's incoming query mentions the project topic (substring or first-200-char keyword overlap)
154
+ - **L3** daily logs β€” still handled by `embeddings.ts` vector search (unchanged)
155
+
156
+ The split is **opt-in**: if `identity.md` and `preferences.md` don't exist, the loader falls back to monolithic MEMORY.md exactly like before. No migration required for existing users. Users who want the cleaner layout can split MEMORY.md manually and the loader picks it up automatically. Token budget: L0+L1 capped at 5000 chars (~1300 tokens), L2 capped at 3000 chars total (~750 tokens, max 1500 per matched project file). New `query` parameter on `buildSystemPrompt()` and `buildMemoryContext()` propagates the user message all the way through. **9 unit tests** + 2 layered-context stress tests.
157
+
158
+ #### P1 #5 β€” Auto-Fact-Extraction in Compaction (`src/services/memory-extractor.ts`, NEW)
159
+
160
+ Inspired by Mem0's auto-extraction. When `compactSession()` archives old messages, it now runs an additional extraction pass that pulls structured facts (`user_facts`, `preferences`, `decisions`) out of the archived chunk via the active AI provider and appends them to MEMORY.md.
161
+
162
+ - **`parseExtractedFacts(text)`** β€” tolerates JSON wrapped in markdown code fences, surrounding prose, null/undefined fields, non-string entries.
163
+ - **`appendFactsToMemoryFile(facts)`** β€” exact-string dedup against existing MEMORY.md content, structured under `## Auto-extracted (YYYY-MM-DD)` header with `### User Facts` / `### Preferences` / `### Decisions` sub-sections.
164
+ - **`extractAndStoreFacts(chunk)`** β€” safe wrapper, never throws. Opt-out via `MEMORY_EXTRACTION_DISABLED=1` env var. Uses effort=low for cost minimization. Skips short input (<50 chars). Provider failures are swallowed; compaction always continues.
165
+ - Wired into `compactSession()` after the daily-log flush, before the AI summary generation.
166
+ - Marked **experimental** in v4.11.0. Semantic dedup (vs current exact-string match) deferred to v4.12+.
167
+ - **11 unit tests** covering JSON parsing edge cases, dedup, opt-out, short-input skip, garbage input, non-string filtering, graceful provider-failure handling.
168
+
169
+ #### Architecture decisions
170
+
171
+ - **mempalace as MCP server: rejected.** Considered installing mempalace as a Python MCP service. Rejected because (1) Alvin is all-TypeScript and adding a 2nd Python service to launchd is operational complexity, (2) Alvin already has an embeddings vector index β€” mempalace would be a parallel duplicate, (3) mempalace's MCP tools are only consumed by the SDK; cron jobs, sub-agents, and non-SDK providers wouldn't see them. Conclusion: **adopt the patterns natively** (L0–L3 layering, AAAK-style structured extraction) rather than running a second service.
172
+ - **SQLite migration deferred.** The 128 MB JSON embeddings index is a known performance issue and is already noted in `~/.claude/projects/-Users-alvin-de/memory/project_alvinbot_sqlite_migration.md` for v4.12+. Orthogonal to the "frickelig nach Restart" UX problem this release targets.
173
+ - **Multi-user isolation deferred.** Memories are still global per data dir. Single-user use case, not a privacy concern for Ali's setup.
174
+ - **Decay/aging deferred.** Daily logs grow monotonically. Will be addressed alongside SQLite migration.
175
+
176
+ #### Testing
177
+
178
+ **292 tests total** (237 baseline + 55 new). All green. TSC clean.
179
+
180
+ - 9 session-persistence unit tests
181
+ - 8 SDK memory-injection tests (3 base + 5 smart-prompt mocked-search)
182
+ - 9 memory-layers tests (loader + topic match + token budget)
183
+ - 11 memory-extractor tests (parse + append + extract pipeline)
184
+ - 18 stress tests (100 sessions, schema drift, unicode, atomic recovery, hostile JSON, simulated restart)
185
+
186
+ **Live verification:**
187
+ - `tmp/live-stress-memory.mjs` β€” 50 fake sessions against the built `dist/`, real ~/.alvin-bot/memory/MEMORY.md as the L1 source, simulated restart via Map clear + reload. Result: 215 KB state file, 1 ms flush, 1 ms reload, 50/50 perfect round-trip.
188
+ - `tmp/live-edge-cases.mjs` β€” 7 hostile scenarios: all-null fields, 1000-burst debounce (2 ms), 20 concurrent flushes, extreme unicode (RTL + ZWJ + astral plane), 4-layer memory with project topic match, atomic write recovery from stale .tmp, empty project file skipping. All passed.
189
+
190
+ #### Files changed
191
+
192
+ - **NEW:** `src/services/session-persistence.ts`, `src/services/memory-layers.ts`, `src/services/memory-extractor.ts`
193
+ - **NEW tests:** `test/session-persistence.test.ts`, `test/memory-sdk-injection.test.ts`, `test/memory-layers.test.ts`, `test/memory-extractor.test.ts`, `test/memory-stress-restart.test.ts`
194
+ - **Modified:** `src/services/session.ts` (persist hook), `src/services/personality.ts` (SDK injection + isFirstTurn), `src/services/memory.ts` (use layered loader), `src/services/compaction.ts` (extractor hook), `src/handlers/message.ts` + `src/handlers/platform-message.ts` (smart prompt wiring), `src/handlers/commands.ts` (`markSessionDirty` calls), `src/index.ts` (load + flush wiring), `src/paths.ts` (4 new constants)
195
+ - **Plan:** `docs/superpowers/plans/2026-04-13-memory-persistence.md`
196
+
197
+ ---
198
+
5
199
  ## [4.10.0] β€” 2026-04-13
6
200
 
7
201
  ### πŸš€ Async sub-agents β€” main session no longer blocks during long tasks
@@ -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 } 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";
@@ -399,6 +400,7 @@ export function registerCommands(bot) {
399
400
  const userId = ctx.from.id;
400
401
  const session = getSession(userId);
401
402
  session.voiceReply = !session.voiceReply;
403
+ markSessionDirty(userId);
402
404
  await ctx.reply(session.voiceReply
403
405
  ? "Voice replies enabled. Responses will also be sent as voice messages."
404
406
  : "Voice replies disabled. Text-only responses.");
@@ -421,8 +423,53 @@ export function registerCommands(bot) {
421
423
  return;
422
424
  }
423
425
  session.effort = level;
426
+ markSessionDirty(userId);
424
427
  await ctx.reply(`βœ… Effort: ${EFFORT_LABELS[session.effort]}`);
425
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
+ });
426
473
  // Inline keyboard callback for effort switching
427
474
  bot.callbackQuery(/^effort:(.+)$/, async (ctx) => {
428
475
  const level = ctx.match[1];
@@ -433,6 +480,7 @@ export function registerCommands(bot) {
433
480
  const userId = ctx.from.id;
434
481
  const session = getSession(userId);
435
482
  session.effort = level;
483
+ markSessionDirty(userId);
436
484
  const keyboard = new InlineKeyboard();
437
485
  for (const [key, label] of Object.entries(EFFORT_LABELS)) {
438
486
  const marker = key === session.effort ? "βœ… " : "";
@@ -827,6 +875,7 @@ export function registerCommands(bot) {
827
875
  }
828
876
  else if (arg === "en" || arg === "de" || arg === "es" || arg === "fr") {
829
877
  session.language = arg;
878
+ markSessionDirty(userId);
830
879
  const { setExplicitLanguage } = await import("../services/language-detect.js");
831
880
  setExplicitLanguage(userId, arg);
832
881
  await ctx.reply(t("bot.lang.setFixed", arg, { name: LOCALE_NAMES[arg] }));
@@ -851,6 +900,7 @@ export function registerCommands(bot) {
851
900
  }
852
901
  const newLang = choice;
853
902
  session.language = newLang;
903
+ markSessionDirty(userId);
854
904
  const { setExplicitLanguage } = await import("../services/language-detect.js");
855
905
  setExplicitLanguage(userId, newLang);
856
906
  const currentName = `${LOCALE_FLAGS[newLang]} ${LOCALE_NAMES[newLang]}`;
@@ -1,10 +1,11 @@
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";
7
- import { buildSystemPrompt, buildSmartSystemPrompt } from "../services/personality.js";
8
+ import { buildSmartSystemPrompt } from "../services/personality.js";
8
9
  import { buildSkillContext } from "../services/skills.js";
9
10
  import { isForwardingAllowed } from "../services/access.js";
10
11
  import { touchProfile } from "../services/users.js";
@@ -219,12 +220,29 @@ export async function handleMessage(ctx) {
219
220
  if (adaptedLang !== session.language) {
220
221
  session.language = adaptedLang;
221
222
  }
222
- // Build query options (with semantic memory search for non-SDK + skill injection)
223
+ // Build query options (with semantic memory search for non-SDK + skill injection).
224
+ // v4.11.0 P0 #3: SDK now also gets semantic recall on first-turn. The signal
225
+ // is `session.sessionId === null` β€” meaning Claude SDK hasn't given us a
226
+ // resume token yet for this session. True for: brand-new users, post-/new,
227
+ // and rehydrated sessions where the persisted snapshot lacked a sessionId.
228
+ // After the first SDK turn, Claude resumes via SDK session_id and already
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
+ }
223
242
  const chatIdStr = String(ctx.chat.id);
224
243
  const skillContext = buildSkillContext(text);
225
- const systemPrompt = (isSDK
226
- ? buildSystemPrompt(isSDK, session.language, chatIdStr)
227
- : await buildSmartSystemPrompt(isSDK, session.language, text, chatIdStr)) + skillContext;
244
+ const isFirstSDKTurn = isSDK && session.sessionId === null;
245
+ const systemPrompt = (await buildSmartSystemPrompt(isSDK, session.language, text, chatIdStr, isFirstSDKTurn, workspace.systemPromptOverride)) + skillContext;
228
246
  // Track the user turn in history regardless of provider type. This keeps
229
247
  // the fallback path (Ollama etc.) aware of what was said on SDK turns.
230
248
  addToHistory(userId, { role: "user", content: text });
@@ -7,9 +7,10 @@
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
- import { buildSystemPrompt, buildSmartSystemPrompt } from "../services/personality.js";
13
+ import { buildSmartSystemPrompt } from "../services/personality.js";
13
14
  import { buildSkillContext } from "../services/skills.js";
14
15
  import { touchProfile } from "../services/users.js";
15
16
  import { trackAndAdapt } from "../services/language-detect.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) {
@@ -129,9 +159,11 @@ export async function handlePlatformMessage(msg, adapter) {
129
159
  const activeProvider = registry.getActive();
130
160
  const isSDK = activeProvider.config.type === "claude-sdk";
131
161
  const skillContext = buildSkillContext(fullText);
132
- const systemPrompt = (isSDK
133
- ? buildSystemPrompt(isSDK, session.language, msg.chatId)
134
- : await buildSmartSystemPrompt(isSDK, session.language, fullText, msg.chatId)) + skillContext;
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.
165
+ const isFirstSDKTurn = isSDK && session.sessionId === null;
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
@@ -79,7 +79,8 @@ import { initMCP, disconnectMCP, hasMCPConfig } from "./services/mcp.js";
79
79
  import { startWebServer, stopWebServer } from "./web/server.js";
80
80
  import { startScheduler, stopScheduler, setNotifyCallback } from "./services/cron.js";
81
81
  import { startWatcher as startAsyncAgentWatcher, stopWatcher as stopAsyncAgentWatcher } from "./services/async-agent-watcher.js";
82
- import { startSessionCleanup, stopSessionCleanup } from "./services/session.js";
82
+ import { startSessionCleanup, stopSessionCleanup, attachPersistHook } from "./services/session.js";
83
+ import { loadPersistedSessions, flushSessions, schedulePersist, } from "./services/session-persistence.js";
83
84
  import { processQueue, cleanupQueue, setSenders, enqueue } from "./services/delivery-queue.js";
84
85
  import { discoverTools } from "./services/tool-discovery.js";
85
86
  import { startHeartbeat } from "./services/heartbeat.js";
@@ -100,6 +101,10 @@ if (assetScanResult.assets.length > 0) {
100
101
  discoverTools();
101
102
  // Load skill files
102
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();
103
108
  // Load user-defined lifecycle hooks from ~/.alvin-bot/hooks/
104
109
  const hookCount = loadHooks();
105
110
  if (hookCount > 0)
@@ -257,6 +262,11 @@ const shutdown = async () => {
257
262
  stopScheduler();
258
263
  stopAsyncAgentWatcher();
259
264
  stopSessionCleanup();
265
+ stopWorkspaceWatcher();
266
+ // v4.11.0 β€” Final immediate flush of in-memory sessions to disk before exit.
267
+ // The debounced timer might be pending; flushSessions() cancels it and writes
268
+ // synchronously so the next boot can rehydrate the latest state.
269
+ await flushSessions().catch((err) => console.warn("[shutdown] flushSessions failed:", err));
260
270
  if (queueInterval)
261
271
  clearInterval(queueInterval);
262
272
  if (queueCleanupInterval)
@@ -430,6 +440,13 @@ startAsyncAgentWatcher();
430
440
  // Session memory hygiene: purge sessions idle > 7 days (configurable via
431
441
  // ALVIN_SESSION_TTL_DAYS). Never touches active sessions β€” see session.ts.
432
442
  startSessionCleanup();
443
+ // Session persistence (v4.11.0): wire the debounced persist hook BEFORE we
444
+ // load the snapshot, then rehydrate the in-memory Map from disk so users'
445
+ // Claude SDK session_id, conversation history, language and effort all
446
+ // survive bot restarts. Without this, every launchctl restart turns the
447
+ // bot into a goldfish for every active conversation.
448
+ attachPersistHook(schedulePersist);
449
+ loadPersistedSessions();
433
450
  // Wire delivery queue senders
434
451
  setSenders({
435
452
  telegram: async (chatId, content) => {
package/dist/paths.js CHANGED
@@ -41,8 +41,20 @@ export const TOOLS_EXAMPLE_JSON = resolve(BOT_ROOT, "docs", "tools.example.json"
41
41
  export const ENV_FILE = resolve(DATA_DIR, ".env");
42
42
  /** memory/ β€” Daily logs and embeddings */
43
43
  export const MEMORY_DIR = resolve(DATA_DIR, "memory");
44
- /** memory/MEMORY.md β€” Long-term curated memory */
44
+ /** memory/MEMORY.md β€” Long-term curated memory (legacy monolithic, still loaded) */
45
45
  export const MEMORY_FILE = resolve(DATA_DIR, "memory", "MEMORY.md");
46
+ /** memory/identity.md β€” L0 layer (v4.11.0): core user facts, always loaded.
47
+ * Optional. If missing, MEMORY.md acts as the L0+L1 fallback. */
48
+ export const IDENTITY_FILE = resolve(DATA_DIR, "memory", "identity.md");
49
+ /** memory/preferences.md β€” L1 layer (v4.11.0): communication style + don'ts. */
50
+ export const PREFERENCES_FILE = resolve(DATA_DIR, "memory", "preferences.md");
51
+ /** memory/projects/ β€” L2 layer (v4.11.0): per-project context loaded on topic match. */
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");
46
58
  /** memory/.embeddings.json β€” Vector index */
47
59
  export const EMBEDDINGS_IDX = resolve(DATA_DIR, "memory", ".embeddings.json");
48
60
  /** users/ β€” User profiles and per-user memory */
@@ -66,6 +78,12 @@ export const BACKUP_DIR = resolve(DATA_DIR, "backups");
66
78
  * See src/services/async-agent-watcher.ts for the watcher that polls and
67
79
  * delivers these. Survives bot restarts. */
68
80
  export const ASYNC_AGENTS_STATE_FILE = resolve(DATA_DIR, "state", "async-agents.json");
81
+ /** state/sessions.json β€” Persisted user sessions across bot restarts (v4.11.0).
82
+ * Includes: sessionId (Claude SDK resume token), language, effort, voiceReply,
83
+ * workingDir, lastActivity, lastSdkHistoryIndex, history (capped). Atomic write
84
+ * via tmp+rename. Loaded on startup, debounce-flushed on mutations.
85
+ * See src/services/session-persistence.ts for the loader/flusher. */
86
+ export const SESSIONS_STATE_FILE = resolve(DATA_DIR, "state", "sessions.json");
69
87
  /** soul.md β€” Bot personality */
70
88
  export const SOUL_FILE = resolve(DATA_DIR, "soul.md");
71
89
  /** tools.md β€” Custom tool definitions (Markdown) */