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 +105 -0
- package/dist/handlers/commands.js +46 -1
- package/dist/handlers/message.js +15 -2
- package/dist/handlers/platform-message.js +45 -11
- package/dist/index.js +5 -0
- package/dist/paths.js +5 -0
- package/dist/platforms/slack.js +67 -3
- package/dist/services/personality.js +11 -4
- package/dist/services/session-persistence.js +40 -5
- package/dist/services/session.js +48 -0
- package/dist/services/workspaces.js +247 -0
- package/dist/web/server.js +25 -0
- package/package.json +1 -1
- package/test/memory-stress-restart.test.ts +2 -1
- package/test/multi-session-stress.test.ts +255 -0
- package/test/platform-session-key.test.ts +69 -0
- package/test/session-persistence.test.ts +8 -5
- package/test/slack-progress-ticker.test.ts +123 -0
- package/test/telegram-workspace-command.test.ts +78 -0
- package/test/workspaces.test.ts +196 -0
- package/web/public/index.html +9 -0
- package/web/public/js/app.js +44 -1
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];
|
package/dist/handlers/message.js
CHANGED
|
@@ -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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
202
|
-
|
|
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(
|
|
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 */
|
package/dist/platforms/slack.js
CHANGED
|
@@ -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
|
-
|
|
303
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
122
|
+
let raw_parsed;
|
|
110
123
|
try {
|
|
111
|
-
|
|
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 (!
|
|
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(),
|
package/dist/services/session.js
CHANGED
|
@@ -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);
|