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.
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Session Persistence Service (v4.11.0)
3
+ *
4
+ * The sessions Map in src/services/session.ts is in-memory only. When the bot
5
+ * restarts (launchctl, watchdog brake, npm install, crash), every user's
6
+ * Claude SDK session_id, conversation history, language preference, and
7
+ * tracking counters are wiped. Claude SDK then starts a fresh conversation
8
+ * on the next user message, behaving like a goldfish.
9
+ *
10
+ * This service:
11
+ * 1. Flushes a sanitized snapshot of getAllSessions() to disk (atomic write).
12
+ * 2. Loads that snapshot at bot startup and rehydrates the Map.
13
+ * 3. Coalesces rapid mutations via a debounced timer.
14
+ *
15
+ * Persisted fields are intentionally a SUBSET of UserSession — runtime-only
16
+ * fields like abortController, isProcessing, and messageQueue are excluded.
17
+ *
18
+ * History is capped at MAX_PERSISTED_HISTORY (50 entries) per session so the
19
+ * state file stays small even after months of conversation.
20
+ */
21
+ import fs from "fs";
22
+ import { dirname } from "path";
23
+ import { SESSIONS_STATE_FILE } from "../paths.js";
24
+ import { getAllSessions, getTelegramWorkspacesMap, } from "./session.js";
25
+ /** History entries to keep in the persisted snapshot (per session). */
26
+ const MAX_PERSISTED_HISTORY = 50;
27
+ /** Debounce window for grouped mutations. */
28
+ const DEBOUNCE_MS = 1500;
29
+ let debounceTimer = null;
30
+ /** Strip runtime-only fields and clip history. */
31
+ function snapshot(session) {
32
+ return {
33
+ sessionId: session.sessionId,
34
+ workingDir: session.workingDir,
35
+ workspaceName: session.workspaceName,
36
+ language: session.language,
37
+ effort: session.effort,
38
+ voiceReply: session.voiceReply,
39
+ lastActivity: session.lastActivity,
40
+ startedAt: session.startedAt,
41
+ totalCost: session.totalCost,
42
+ messageCount: session.messageCount,
43
+ toolUseCount: session.toolUseCount,
44
+ totalInputTokens: session.totalInputTokens,
45
+ totalOutputTokens: session.totalOutputTokens,
46
+ lastSdkHistoryIndex: session.lastSdkHistoryIndex,
47
+ history: session.history.slice(-MAX_PERSISTED_HISTORY),
48
+ };
49
+ }
50
+ /** Skip sessions that have never accumulated meaningful state. */
51
+ function isWorthPersisting(session) {
52
+ return !!(session.sessionId ||
53
+ session.history.length > 0 ||
54
+ session.messageCount > 0 ||
55
+ session.totalCost > 0);
56
+ }
57
+ /**
58
+ * Atomic flush of all worth-persisting sessions to SESSIONS_STATE_FILE.
59
+ * Cancels any pending debounced flush — this is the immediate path.
60
+ */
61
+ export async function flushSessions() {
62
+ if (debounceTimer) {
63
+ clearTimeout(debounceTimer);
64
+ debounceTimer = null;
65
+ }
66
+ try {
67
+ const all = getAllSessions();
68
+ const out = {};
69
+ for (const [key, session] of all) {
70
+ if (isWorthPersisting(session)) {
71
+ out[key] = snapshot(session);
72
+ }
73
+ }
74
+ // Ensure the state directory exists
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
+ };
88
+ // Atomic write: tmp + rename
89
+ const tmpFile = `${SESSIONS_STATE_FILE}.tmp`;
90
+ fs.writeFileSync(tmpFile, JSON.stringify(envelope, null, 2), "utf-8");
91
+ fs.renameSync(tmpFile, SESSIONS_STATE_FILE);
92
+ }
93
+ catch (err) {
94
+ console.warn("⚠️ session-persistence: flush failed —", err instanceof Error ? err.message : String(err));
95
+ }
96
+ }
97
+ /**
98
+ * Schedule a debounced flush. Multiple rapid calls collapse into one.
99
+ * Use this from any session-mutating code path; the immediate flushSessions()
100
+ * is reserved for graceful shutdown.
101
+ */
102
+ export function schedulePersist() {
103
+ if (debounceTimer)
104
+ clearTimeout(debounceTimer);
105
+ debounceTimer = setTimeout(() => {
106
+ debounceTimer = null;
107
+ void flushSessions();
108
+ }, DEBOUNCE_MS);
109
+ }
110
+ /**
111
+ * Load the persisted sessions snapshot from disk and rehydrate the Map.
112
+ * Called once at bot startup. Returns the number of sessions restored.
113
+ */
114
+ export function loadPersistedSessions() {
115
+ let raw;
116
+ try {
117
+ raw = fs.readFileSync(SESSIONS_STATE_FILE, "utf-8");
118
+ }
119
+ catch {
120
+ return 0; // no file = nothing to do
121
+ }
122
+ let raw_parsed;
123
+ try {
124
+ raw_parsed = JSON.parse(raw);
125
+ }
126
+ catch (err) {
127
+ console.warn("⚠️ session-persistence: corrupt sessions.json, starting fresh —", err instanceof Error ? err.message : String(err));
128
+ return 0;
129
+ }
130
+ if (!raw_parsed || typeof raw_parsed !== "object")
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
+ }
153
+ // Use the same getAllSessions Map that session.ts exports
154
+ const all = getAllSessions();
155
+ let count = 0;
156
+ for (const [key, persisted] of Object.entries(parsed)) {
157
+ if (!persisted || typeof persisted !== "object")
158
+ continue;
159
+ // Build a UserSession from the persisted shape, filling defaults for any
160
+ // fields added in newer schema versions.
161
+ const restored = {
162
+ sessionId: persisted.sessionId ?? null,
163
+ workingDir: persisted.workingDir ?? process.cwd(),
164
+ workspaceName: persisted.workspaceName ?? null,
165
+ isProcessing: false,
166
+ abortController: null,
167
+ lastActivity: persisted.lastActivity ?? Date.now(),
168
+ startedAt: persisted.startedAt ?? Date.now(),
169
+ totalCost: persisted.totalCost ?? 0,
170
+ costByProvider: {},
171
+ queriesByProvider: {},
172
+ effort: persisted.effort ?? "medium",
173
+ voiceReply: persisted.voiceReply ?? false,
174
+ messageCount: persisted.messageCount ?? 0,
175
+ toolUseCount: persisted.toolUseCount ?? 0,
176
+ totalInputTokens: persisted.totalInputTokens ?? 0,
177
+ totalOutputTokens: persisted.totalOutputTokens ?? 0,
178
+ lastTurnInputTokens: 0,
179
+ compactionCount: 0,
180
+ checkpointHintsInjected: 0,
181
+ sdkSubTaskCount: 0,
182
+ history: Array.isArray(persisted.history) ? persisted.history : [],
183
+ language: persisted.language ?? "en",
184
+ messageQueue: [],
185
+ lastSdkHistoryIndex: persisted.lastSdkHistoryIndex ?? -1,
186
+ };
187
+ all.set(key, restored);
188
+ count++;
189
+ }
190
+ if (count > 0) {
191
+ console.log(`🧠 session-persistence: restored ${count} session(s) from disk`);
192
+ }
193
+ return count;
194
+ }
@@ -2,6 +2,62 @@ 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
+ }
34
+ // ── Persistence Hook (v4.11.0) ─────────────────────────────────────
35
+ //
36
+ // session-persistence.ts is wired in via attachPersistHook() at bot startup.
37
+ // We use a callback indirection rather than a direct import to avoid a
38
+ // circular dependency (session-persistence imports getAllSessions from here).
39
+ let _persistHook = null;
40
+ /** Wire a callback that gets invoked on every session mutation. */
41
+ export function attachPersistHook(fn) {
42
+ _persistHook = fn;
43
+ }
44
+ /** Internal: invoke the persist hook if attached. Never throws. */
45
+ function persist() {
46
+ if (!_persistHook)
47
+ return;
48
+ try {
49
+ _persistHook();
50
+ }
51
+ catch {
52
+ // never let persistence break session writes
53
+ }
54
+ }
55
+ /** Public marker for handlers that mutate session fields directly (sessionId,
56
+ * language, effort, voiceReply, workingDir) outside of addToHistory/trackProviderUsage.
57
+ * Triggers a debounced persist. Safe to call from any code path. */
58
+ export function markSessionDirty(_key) {
59
+ persist();
60
+ }
5
61
  export function buildSessionKey(platform, channelId, userId) {
6
62
  switch (config.sessionMode) {
7
63
  case "per-channel":
@@ -20,6 +76,7 @@ export function getSession(key) {
20
76
  session = {
21
77
  sessionId: null,
22
78
  workingDir: config.defaultWorkingDir,
79
+ workspaceName: null,
23
80
  isProcessing: false,
24
81
  abortController: null,
25
82
  lastActivity: Date.now(),
@@ -71,6 +128,7 @@ export function resetSession(key) {
71
128
  // Reset budget warning flags so the user gets fresh warnings in the new session.
72
129
  session._budgetWarned80 = false;
73
130
  session._budgetWarned100 = false;
131
+ persist();
74
132
  }
75
133
  /** Track cost, query count, and tokens for a provider. */
76
134
  export function trackProviderUsage(key, providerKey, cost, inputTokens, outputTokens) {
@@ -82,6 +140,7 @@ export function trackProviderUsage(key, providerKey, cost, inputTokens, outputTo
82
140
  session.totalInputTokens += inputTokens;
83
141
  if (outputTokens)
84
142
  session.totalOutputTokens += outputTokens;
143
+ persist();
85
144
  // Soft budget warnings — these NEVER block the bot. They exist purely
86
145
  // as log signals so the operator (Ali) can notice unusually expensive
87
146
  // sessions. Each threshold fires at most once per session (reset on /new).
@@ -164,11 +223,30 @@ export function addToHistory(key, message) {
164
223
  session.lastSdkHistoryIndex = Math.max(-1, session.lastSdkHistoryIndex - dropped);
165
224
  }
166
225
  }
226
+ persist();
167
227
  }
168
228
  /** Get all active sessions (for web UI session browser). */
169
229
  export function getAllSessions() {
170
230
  return sessions;
171
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
+ }
172
250
  /** Kill a user session completely — abort running query, clear history, remove from map. */
173
251
  export function killSession(key) {
174
252
  const k = String(key);
@@ -0,0 +1,247 @@
1
+ /**
2
+ * Workspace Registry (v4.12.0)
3
+ *
4
+ * A workspace represents an isolated "project context" for Alvin. On Slack
5
+ * each channel maps to a workspace (1:1 by explicit channel ID or by name
6
+ * match). On Telegram, the user selects a workspace via /workspace.
7
+ *
8
+ * Config format: markdown files under ~/.alvin-bot/workspaces/<name>.md
9
+ * with YAML frontmatter. The markdown body is the persona/system-prompt
10
+ * override that gets appended to the base Alvin system prompt for queries
11
+ * in that workspace.
12
+ *
13
+ * Example:
14
+ *
15
+ * ---
16
+ * purpose: Alev-B consulting website dev
17
+ * cwd: ~/Projects/alev-b-website
18
+ * emoji: "🏢"
19
+ * color: "#6366f1"
20
+ * channels: ["C01ALEVABC"]
21
+ * ---
22
+ * You are the Alev-B dev assistant. Stack: React + Express + Drizzle + MySQL.
23
+ * Prefer concise, directly actionable answers about deployment...
24
+ *
25
+ * If no workspaces are configured or no match is found, a built-in "default"
26
+ * workspace is used — it has an empty persona, inherits the global default
27
+ * working directory, and is the natural fallback so existing single-session
28
+ * behavior is preserved for users who don't create any workspace configs.
29
+ */
30
+ import fs from "fs";
31
+ import os from "os";
32
+ import path from "path";
33
+ import { WORKSPACES_DIR } from "../paths.js";
34
+ import { config } from "../config.js";
35
+ const registry = new Map();
36
+ /** Expand ~ at the start of a path to the user's home directory. */
37
+ function expandHome(p) {
38
+ if (!p)
39
+ return p;
40
+ if (p === "~")
41
+ return os.homedir();
42
+ if (p.startsWith("~/"))
43
+ return path.resolve(os.homedir(), p.slice(2));
44
+ return p;
45
+ }
46
+ /** Parse a very simple YAML subset: key: value pairs + arrays in JSON form.
47
+ * We deliberately don't pull in a full YAML library — the frontmatter schema
48
+ * is tiny and well-defined. Falls back to an empty object on any parse error. */
49
+ function parseFrontmatter(text) {
50
+ const out = {};
51
+ for (const rawLine of text.split("\n")) {
52
+ const line = rawLine.trim();
53
+ if (!line || line.startsWith("#"))
54
+ continue;
55
+ const colonIdx = line.indexOf(":");
56
+ if (colonIdx <= 0)
57
+ continue;
58
+ const key = line.slice(0, colonIdx).trim();
59
+ let value = line.slice(colonIdx + 1).trim();
60
+ if (!value)
61
+ continue;
62
+ // JSON array
63
+ if (value.startsWith("[")) {
64
+ try {
65
+ out[key] = JSON.parse(value);
66
+ continue;
67
+ }
68
+ catch {
69
+ continue;
70
+ }
71
+ }
72
+ // Quoted string
73
+ if ((value.startsWith('"') && value.endsWith('"')) ||
74
+ (value.startsWith("'") && value.endsWith("'"))) {
75
+ value = value.slice(1, -1);
76
+ }
77
+ out[key] = value;
78
+ }
79
+ return out;
80
+ }
81
+ /** Split a markdown file into frontmatter (YAML) and body. Returns both as
82
+ * strings. If no frontmatter delimiters are found, frontmatter is empty and
83
+ * the whole content is the body. */
84
+ function splitFrontmatter(content) {
85
+ const trimmed = content.replace(/^\uFEFF/, "");
86
+ const match = trimmed.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
87
+ if (!match)
88
+ return { frontmatter: "", body: trimmed };
89
+ return { frontmatter: match[1], body: match[2] };
90
+ }
91
+ /** Read a single workspace file and return the parsed object, or null on failure. */
92
+ function readWorkspaceFile(filePath, name) {
93
+ try {
94
+ const content = fs.readFileSync(filePath, "utf-8");
95
+ const { frontmatter, body } = splitFrontmatter(content);
96
+ const fm = parseFrontmatter(frontmatter);
97
+ const purpose = typeof fm.purpose === "string" ? fm.purpose : "";
98
+ const rawCwd = typeof fm.cwd === "string" ? fm.cwd : config.defaultWorkingDir;
99
+ const cwd = expandHome(rawCwd);
100
+ const color = typeof fm.color === "string" ? fm.color : undefined;
101
+ const emoji = typeof fm.emoji === "string" ? fm.emoji : undefined;
102
+ const channels = Array.isArray(fm.channels)
103
+ ? fm.channels.filter((c) => typeof c === "string")
104
+ : [];
105
+ return {
106
+ name,
107
+ purpose,
108
+ cwd,
109
+ color,
110
+ emoji,
111
+ channels,
112
+ systemPromptOverride: body.trim(),
113
+ };
114
+ }
115
+ catch (err) {
116
+ console.warn(`⚠️ workspaces: failed to load ${filePath} —`, err instanceof Error ? err.message : String(err));
117
+ return null;
118
+ }
119
+ }
120
+ /** Load all workspaces from ~/.alvin-bot/workspaces/*.md. Returns the count loaded. */
121
+ export function loadWorkspaces() {
122
+ registry.clear();
123
+ if (!fs.existsSync(WORKSPACES_DIR))
124
+ return 0;
125
+ let count = 0;
126
+ let entries;
127
+ try {
128
+ entries = fs.readdirSync(WORKSPACES_DIR);
129
+ }
130
+ catch {
131
+ return 0;
132
+ }
133
+ for (const entry of entries) {
134
+ if (!entry.endsWith(".md") || entry.startsWith("."))
135
+ continue;
136
+ const name = entry.replace(/\.md$/, "");
137
+ const filePath = path.resolve(WORKSPACES_DIR, entry);
138
+ const ws = readWorkspaceFile(filePath, name);
139
+ if (ws) {
140
+ registry.set(name, ws);
141
+ count++;
142
+ }
143
+ }
144
+ return count;
145
+ }
146
+ /** Alias for loadWorkspaces — used by the hot-reload file watcher. */
147
+ export function reloadWorkspaces() {
148
+ return loadWorkspaces();
149
+ }
150
+ /** Return all registered workspaces. */
151
+ export function listWorkspaces() {
152
+ return Array.from(registry.values());
153
+ }
154
+ /** Get a workspace by name, or null. */
155
+ export function getWorkspace(name) {
156
+ return registry.get(name) ?? null;
157
+ }
158
+ /** Built-in fallback workspace. Returned when no user workspaces exist or
159
+ * no channel-match can be made. Preserves the pre-v4.12.0 behavior. */
160
+ export function getDefaultWorkspace() {
161
+ return {
162
+ name: "default",
163
+ purpose: "",
164
+ cwd: config.defaultWorkingDir,
165
+ channels: [],
166
+ systemPromptOverride: "",
167
+ };
168
+ }
169
+ /** Try to resolve a platform + channel to a specific workspace.
170
+ *
171
+ * Resolution order:
172
+ * 1. Explicit channel ID match (workspace frontmatter `channels: ["C01ABC"]`)
173
+ * 2. Channel name match (workspace filename equals the normalized channel name)
174
+ * 3. Return null (caller should fall back to the default workspace)
175
+ *
176
+ * Normalization: strips leading `#`, lowercases, trims whitespace.
177
+ */
178
+ export function matchWorkspaceForChannel(_platform, channelId, channelName) {
179
+ // 1. Channel ID match
180
+ for (const ws of registry.values()) {
181
+ if (ws.channels.includes(channelId))
182
+ return ws;
183
+ }
184
+ // 2. Channel name match against workspace filename
185
+ if (channelName) {
186
+ const normalized = channelName.replace(/^#/, "").trim().toLowerCase();
187
+ for (const ws of registry.values()) {
188
+ if (ws.name.toLowerCase() === normalized)
189
+ return ws;
190
+ }
191
+ }
192
+ return null;
193
+ }
194
+ /** Resolve a channel to a workspace, falling back to the default when no
195
+ * match is found. This is the one-call path handlers should use. */
196
+ export function resolveWorkspaceOrDefault(platform, channelId, channelName) {
197
+ const match = matchWorkspaceForChannel(platform, channelId, channelName);
198
+ return match ?? getDefaultWorkspace();
199
+ }
200
+ // ── Hot-reload watcher ──────────────────────────────────────────────────
201
+ let watcher = null;
202
+ let reloadDebounceTimer = null;
203
+ /** Start watching the workspaces directory for file changes. Debounced reload
204
+ * at 500 ms so a burst of edits (e.g. git checkout) coalesces into one reload. */
205
+ export function startWorkspaceWatcher() {
206
+ if (watcher)
207
+ return;
208
+ if (!fs.existsSync(WORKSPACES_DIR))
209
+ return;
210
+ try {
211
+ watcher = fs.watch(WORKSPACES_DIR, () => {
212
+ if (reloadDebounceTimer)
213
+ clearTimeout(reloadDebounceTimer);
214
+ reloadDebounceTimer = setTimeout(() => {
215
+ const count = reloadWorkspaces();
216
+ console.log(`🧭 workspaces: hot-reloaded (${count} registered)`);
217
+ }, 500);
218
+ });
219
+ }
220
+ catch {
221
+ // ignore — hot-reload is a nice-to-have
222
+ }
223
+ }
224
+ /** Stop the hot-reload watcher (for graceful shutdown). */
225
+ export function stopWorkspaceWatcher() {
226
+ if (watcher) {
227
+ try {
228
+ watcher.close();
229
+ }
230
+ catch { /* ignore */ }
231
+ watcher = null;
232
+ }
233
+ if (reloadDebounceTimer) {
234
+ clearTimeout(reloadDebounceTimer);
235
+ reloadDebounceTimer = null;
236
+ }
237
+ }
238
+ /** Initialize: load all workspaces + start hot-reload watcher. Called once at startup. */
239
+ export function initWorkspaces() {
240
+ const count = loadWorkspaces();
241
+ startWorkspaceWatcher();
242
+ if (count > 0) {
243
+ const names = listWorkspaces().map(w => `${w.emoji ?? "🧭"} ${w.name}`).join(", ");
244
+ console.log(`🧭 Workspaces: ${count} loaded — ${names}`);
245
+ }
246
+ return count;
247
+ }
@@ -451,6 +451,31 @@ async function handleAPI(req, res, urlPath, body) {
451
451
  res.end(JSON.stringify({ plugins: getLoadedPlugins() }));
452
452
  return;
453
453
  }
454
+ // v4.12.0 — Workspace overview: registry + per-workspace cost breakdown
455
+ if (urlPath === "/api/workspaces") {
456
+ try {
457
+ const { listWorkspaces, getDefaultWorkspace } = await import("../services/workspaces.js");
458
+ const { getCostByWorkspace } = await import("../services/session.js");
459
+ const costs = getCostByWorkspace();
460
+ const registered = listWorkspaces();
461
+ const all = [getDefaultWorkspace(), ...registered];
462
+ const payload = all.map((ws) => ({
463
+ name: ws.name,
464
+ purpose: ws.purpose,
465
+ emoji: ws.emoji ?? null,
466
+ color: ws.color ?? null,
467
+ cwd: ws.cwd,
468
+ channels: ws.channels,
469
+ stats: costs[ws.name] ?? { totalCost: 0, sessionCount: 0, messageCount: 0, toolUseCount: 0 },
470
+ }));
471
+ res.end(JSON.stringify({ workspaces: payload }));
472
+ }
473
+ catch (err) {
474
+ res.statusCode = 500;
475
+ res.end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }));
476
+ }
477
+ return;
478
+ }
454
479
  // GET /api/users — Enhanced with session data
455
480
  if (urlPath === "/api/users" && req.method === "GET") {
456
481
  const { getAllSessions } = await import("../services/session.js");
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "alvin-bot",
3
- "version": "4.10.0",
4
- "description": "Alvin Bot Your personal AI agent on Telegram, WhatsApp, Discord, Signal, and Web.",
3
+ "version": "4.12.0",
4
+ "description": "Alvin Bot \u2014 Your personal AI agent on Telegram, WhatsApp, Discord, Signal, and Web.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
7
  "bin": {