alvin-bot 4.4.6 → 4.5.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/message.js +40 -0
- package/dist/index.js +5 -0
- package/dist/services/broadcast.js +52 -0
- package/dist/services/session.js +74 -0
- package/dist/tui/index.js +184 -36
- package/dist/web/server.js +108 -5
- package/package.json +2 -2
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.5.0] — 2026-04-09
|
|
6
|
+
|
|
7
|
+
### 🐛 TUI Bug Fixes (critical — the old TUI was effectively unusable)
|
|
8
|
+
|
|
9
|
+
**Double-character echo fixed** — Every keystroke in `alvin-bot tui` appeared twice (typing `hello` showed as `hheelllloo`). Root cause: `src/tui/index.ts` called `process.stdin.setRawMode(false)` alongside `readline.createInterface({ terminal: true })`. readline with `terminal: true` already controls the tty mode for its own line editor; forcing cooked mode on top of that makes both the terminal AND readline echo every keystroke. Removed the explicit `setRawMode(false)` call and let readline manage the tty state itself.
|
|
10
|
+
|
|
11
|
+
**Cursor chaos fixed** — The old `redrawHeader()` function wrote `\x1b7` / `\x1b8` (save/restore cursor) escape sequences that raced with readline's internal cursor tracking, producing garbled output mid-stream. The header redraw is now a no-op during active streaming and uses readline's own `cursorTo`/`clearLine` helpers otherwise — cooperating with readline instead of fighting it.
|
|
12
|
+
|
|
13
|
+
**Prompt state machine consolidated** — `showPrompt()` was called at ~7 different places, each re-rendering the prompt at potentially racy moments. It is now the single source of truth and no-ops during streaming. Every helper (`printUser`, `printAssistantStart`, `printInfo`, `printError`, `printSuccess`, `printTool`) calls `clearCurrentLine()` first, so the input line is always cleanly wiped before output is written above it.
|
|
14
|
+
|
|
15
|
+
**Terminal resize handling** — Added `process.stdout.on("resize", …)` so the header redraws correctly when the user resizes the window (when safe).
|
|
16
|
+
|
|
17
|
+
### ✨ New Feature: Parallel Observation + Session Routing
|
|
18
|
+
|
|
19
|
+
**The big one.** Before 4.5.0, the TUI/Web-UI shared the exact same session as the Telegram bot (both keyed to `config.allowedUsers[0]`). That meant `/new` in the TUI wiped the Telegram history, and the TUI had no visibility into live Telegram activity. This release cleanly separates the two and adds live mirroring in both directions.
|
|
20
|
+
|
|
21
|
+
#### New in-process broadcast bus — `src/services/broadcast.ts`
|
|
22
|
+
|
|
23
|
+
A tiny typed `EventEmitter` with four event types:
|
|
24
|
+
- `user_msg` — a user sent a message on a platform (Telegram, WhatsApp, etc.)
|
|
25
|
+
- `response_start` — the bot started generating a response
|
|
26
|
+
- `response_delta` — a streaming text chunk
|
|
27
|
+
- `response_done` — the response is complete
|
|
28
|
+
|
|
29
|
+
Fire-and-forget, zero backpressure, no history retention. The Telegram handler (`src/handlers/message.ts`) emits these events around its normal processing. The web server subscribes once at module load and fan-outs to every connected WebSocket client as `mirror:*` messages. Platform-agnostic signature so WhatsApp/Signal can plug in later without architectural changes.
|
|
30
|
+
|
|
31
|
+
#### TUI session isolation
|
|
32
|
+
|
|
33
|
+
The TUI now owns its own session key, completely separate from the Telegram user's session:
|
|
34
|
+
|
|
35
|
+
- **Default**: `alvin-bot tui` → fresh ephemeral session `tui:ephemeral:<timestamp>`. Every TUI start is a clean slate.
|
|
36
|
+
- **Persistent**: `alvin-bot tui --resume` → resumes `tui:local`, a long-lived session that survives TUI restarts.
|
|
37
|
+
|
|
38
|
+
Your Telegram conversation and your TUI conversation no longer overwrite each other's history. `/new` in the TUI only resets the TUI session.
|
|
39
|
+
|
|
40
|
+
#### `/target tui|telegram` — remote-control the Telegram session from TUI
|
|
41
|
+
|
|
42
|
+
New TUI command to switch where your typed messages go:
|
|
43
|
+
|
|
44
|
+
- **`/target tui`** (default) — Your messages go into the TUI's isolated session. Responses are rendered in the TUI only.
|
|
45
|
+
- **`/target telegram`** — Your messages enter the Telegram session (shared memory with whoever messages your bot on Telegram). The bot responds **both** in the TUI (via the open WebSocket) **and** in the actual Telegram chat (via the existing delivery queue). The active target is shown in the header as `→ Telegram` or `TUI session`.
|
|
46
|
+
|
|
47
|
+
Note: Telegram bot API does not allow bots to forge user messages, so your original prompt stays in the TUI — only the bot's response lands in Telegram. This is the closest possible equivalent to "remote typing into Telegram".
|
|
48
|
+
|
|
49
|
+
#### `/observe on|off` — mirror Telegram activity into the TUI
|
|
50
|
+
|
|
51
|
+
When observer mode is on (default), every Telegram user message and streaming bot response is mirrored into the TUI with distinct dim + `📱 Tel` styling. You can watch a live conversation happen from the TUI while running your own independent session in parallel. Toggle off with `/observe off` if the mirror noise gets in the way.
|
|
52
|
+
|
|
53
|
+
### 🧠 Architecture / Design Note
|
|
54
|
+
|
|
55
|
+
This feature deliberately does **not** go through the Claude Agent SDK or touch the `pathToClaudeCodeExecutable` flow. The broadcast bus is a pure observation layer in alvin-bot's own process, and session routing is just a different `sessionKey` lookup in the existing `getSession()` map. The bot's 1st-party auth behavior (CLI-backed session routing) is preserved exactly as before.
|
|
56
|
+
|
|
57
|
+
### 📦 Compatibility
|
|
58
|
+
|
|
59
|
+
This is a minor release (new feature), not a patch. No breaking changes to existing commands, existing behavior, or existing API endpoints. Old clients that don't send a `target` field continue to work exactly as before (falling back to the primary Telegram user's session).
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
npm update -g alvin-bot
|
|
63
|
+
alvin-bot tui # fresh TUI session, observer on by default
|
|
64
|
+
alvin-bot tui --resume # resume persistent tui:local session
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Once inside TUI:
|
|
68
|
+
- `/target telegram` — route your messages into the Telegram session (responses land in both TUI and Telegram chat)
|
|
69
|
+
- `/target tui` — switch back to isolated TUI session (default)
|
|
70
|
+
- `/observe off` — stop mirroring Telegram activity
|
|
71
|
+
- `/observe on` — resume mirroring
|
|
72
|
+
|
|
73
|
+
## [4.4.7] — 2026-04-09
|
|
74
|
+
|
|
75
|
+
### 🔐 Security / Dependencies
|
|
76
|
+
|
|
77
|
+
**6 of 9 npm audit vulnerabilities fixed (non-breaking)** — Ran `npm audit fix` to patch the transitive `@xmldom/xmldom` XML injection, `basic-ftp` CRLF command injection, and `brace-expansion` DoS vulnerabilities. Also upgraded the direct dependency `@anthropic-ai/claude-agent-sdk` from `0.2.92` to `0.2.97` (latest, non-breaking patch release with no changes to the `query()` API surface Alvin-Bot uses).
|
|
78
|
+
|
|
79
|
+
Remaining unaddressed (by design, require breaking upgrades or overrides):
|
|
80
|
+
- `@anthropic-ai/sdk` Memory Tool sandbox escape — **not exploitable** in Alvin-Bot because the `Memory` tool is not listed in `allowedTools` (we only use `Read`, `Write`, `Edit`, `Bash`, `Glob`, `Grep`, `WebSearch`, `WebFetch`, `Task`).
|
|
81
|
+
- `electron` (17 advisories) — waiting for a planned breaking upgrade to `electron@41.x`.
|
|
82
|
+
|
|
83
|
+
### ✨ Stability Improvements
|
|
84
|
+
|
|
85
|
+
**Session memory hygiene (`src/services/session.ts`)** — The in-memory `sessions` Map grew unbounded: every user that ever messaged the bot kept a full session object (including conversation history, cost breakdown, abort controller) forever. On a single-user bot like Ali's this is a non-issue; on any multi-user deployment it's a steady leak.
|
|
86
|
+
|
|
87
|
+
New behavior:
|
|
88
|
+
- **Conservative 7-day TTL**: a session is only eligible for cleanup after 7 full days of complete inactivity. Configurable via `ALVIN_SESSION_TTL_DAYS` env var.
|
|
89
|
+
- **Never touches active sessions**: the cleanup loop explicitly skips any session with `isProcessing === true`.
|
|
90
|
+
- **`lastActivity` touched on every `getSession()` call**: any interaction at all keeps the session alive indefinitely.
|
|
91
|
+
- **Orphaned `abortController` cleanup** before removal (defensive).
|
|
92
|
+
- Runs hourly; logs a message when it actually purges something.
|
|
93
|
+
|
|
94
|
+
This is memory hygiene only — it cannot reduce Alvin-Bot's capabilities, permissions, or responsiveness. Active users see zero behavioral change.
|
|
95
|
+
|
|
96
|
+
**MAX_BUDGET_USD tracking (`src/services/session.ts:trackProviderUsage`)** — The `MAX_BUDGET_USD` config was declared but never read anywhere. Now it's tracked as a **soft warning** (never a block):
|
|
97
|
+
- When a session's cumulative cost crosses 80% of the configured budget, a `⚠️ Session budget 80% consumed` message is logged.
|
|
98
|
+
- When it crosses 100%, a `💸 Session budget exceeded … bot continues (no hard limit enforced)` message is logged.
|
|
99
|
+
- **The bot never blocks** — the warnings exist purely as operator signals. `/new` resets the warning flags so subsequent sessions get fresh thresholds.
|
|
100
|
+
- `session.totalCost` is now correctly incremented (previously declared in the interface but never written to).
|
|
101
|
+
|
|
102
|
+
### 📦 Compatibility
|
|
103
|
+
|
|
104
|
+
No breaking changes. User-facing behavior is identical — same commands, same permissions, same response patterns. The only visible change is new log messages for cleanup events and budget thresholds.
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
npm update -g alvin-bot
|
|
108
|
+
```
|
|
109
|
+
|
|
5
110
|
## [4.4.6] — 2026-04-09
|
|
6
111
|
|
|
7
112
|
### 🐛 Bug Fixes
|
package/dist/handlers/message.js
CHANGED
|
@@ -12,6 +12,7 @@ import { trackAndAdapt } from "../services/language-detect.js";
|
|
|
12
12
|
import { shouldCompact, compactSession } from "../services/compaction.js";
|
|
13
13
|
import { emit } from "../services/hooks.js";
|
|
14
14
|
import { trackUsage } from "../services/usage-tracker.js";
|
|
15
|
+
import { emitUserMessage as broadcastUserMessage, emitResponseStart as broadcastResponseStart, emitResponseDelta as broadcastResponseDelta, emitResponseDone as broadcastResponseDone, } from "../services/broadcast.js";
|
|
15
16
|
/** React to a message with an emoji. Silently fails if reactions aren't supported. */
|
|
16
17
|
async function react(ctx, emoji) {
|
|
17
18
|
try {
|
|
@@ -85,6 +86,22 @@ export async function handleMessage(ctx) {
|
|
|
85
86
|
await ctx.api.sendChatAction(ctx.chat.id, "typing");
|
|
86
87
|
session.messageCount++;
|
|
87
88
|
emit("message:received", { userId, text, platform: "telegram" });
|
|
89
|
+
// v4.5.0: broadcast the user message so TUI/WebUI observers can mirror it.
|
|
90
|
+
// The broadcast bus is fire-and-forget — never affects the Telegram flow.
|
|
91
|
+
broadcastUserMessage({
|
|
92
|
+
platform: "telegram",
|
|
93
|
+
userId,
|
|
94
|
+
userName: ctx.from?.first_name || ctx.from?.username,
|
|
95
|
+
chatId: ctx.chat.id,
|
|
96
|
+
text,
|
|
97
|
+
ts: Date.now(),
|
|
98
|
+
});
|
|
99
|
+
broadcastResponseStart({
|
|
100
|
+
platform: "telegram",
|
|
101
|
+
userId,
|
|
102
|
+
chatId: ctx.chat.id,
|
|
103
|
+
ts: Date.now(),
|
|
104
|
+
});
|
|
88
105
|
// Determine provider type early for compaction check
|
|
89
106
|
const registry = getRegistry();
|
|
90
107
|
const activeProvider = registry.getActive();
|
|
@@ -130,11 +147,25 @@ export async function handleMessage(ctx) {
|
|
|
130
147
|
addToHistory(userId, { role: "user", content: text });
|
|
131
148
|
}
|
|
132
149
|
// Stream response from provider (with fallback)
|
|
150
|
+
let lastBroadcastLen = 0;
|
|
133
151
|
for await (const chunk of registry.queryWithFallback(queryOpts)) {
|
|
134
152
|
switch (chunk.type) {
|
|
135
153
|
case "text":
|
|
136
154
|
finalText = chunk.text || "";
|
|
137
155
|
await streamer.update(finalText);
|
|
156
|
+
// Emit the new delta for observers — accumulated text minus what
|
|
157
|
+
// we already broadcast.
|
|
158
|
+
if (finalText.length > lastBroadcastLen) {
|
|
159
|
+
const delta = finalText.slice(lastBroadcastLen);
|
|
160
|
+
broadcastResponseDelta({
|
|
161
|
+
platform: "telegram",
|
|
162
|
+
userId,
|
|
163
|
+
chatId: ctx.chat.id,
|
|
164
|
+
delta,
|
|
165
|
+
ts: Date.now(),
|
|
166
|
+
});
|
|
167
|
+
lastBroadcastLen = finalText.length;
|
|
168
|
+
}
|
|
138
169
|
break;
|
|
139
170
|
case "tool_use":
|
|
140
171
|
// Could show tool activity indicator
|
|
@@ -161,6 +192,15 @@ export async function handleMessage(ctx) {
|
|
|
161
192
|
}
|
|
162
193
|
await streamer.finalize(finalText);
|
|
163
194
|
emit("message:sent", { userId, text: finalText, platform: "telegram" });
|
|
195
|
+
// v4.5.0: tell observers the response is complete.
|
|
196
|
+
broadcastResponseDone({
|
|
197
|
+
platform: "telegram",
|
|
198
|
+
userId,
|
|
199
|
+
chatId: ctx.chat.id,
|
|
200
|
+
finalText,
|
|
201
|
+
cost: session.costByProvider[registry.getActiveKey()],
|
|
202
|
+
ts: Date.now(),
|
|
203
|
+
});
|
|
164
204
|
// Clear thinking reaction (replace with nothing — message was answered)
|
|
165
205
|
await react(ctx, "👍");
|
|
166
206
|
// Add assistant response to history (for non-SDK providers)
|
package/dist/index.js
CHANGED
|
@@ -60,6 +60,7 @@ import { loadPlugins, registerPluginCommands, unloadPlugins } from "./services/p
|
|
|
60
60
|
import { initMCP, disconnectMCP, hasMCPConfig } from "./services/mcp.js";
|
|
61
61
|
import { startWebServer } from "./web/server.js";
|
|
62
62
|
import { startScheduler, stopScheduler, setNotifyCallback } from "./services/cron.js";
|
|
63
|
+
import { startSessionCleanup, stopSessionCleanup } from "./services/session.js";
|
|
63
64
|
import { processQueue, cleanupQueue, setSenders, enqueue } from "./services/delivery-queue.js";
|
|
64
65
|
import { discoverTools } from "./services/tool-discovery.js";
|
|
65
66
|
import { startHeartbeat } from "./services/heartbeat.js";
|
|
@@ -214,6 +215,7 @@ const shutdown = async () => {
|
|
|
214
215
|
console.log("Graceful shutdown initiated...");
|
|
215
216
|
cancelAllSubAgents();
|
|
216
217
|
stopScheduler();
|
|
218
|
+
stopSessionCleanup();
|
|
217
219
|
if (queueInterval)
|
|
218
220
|
clearInterval(queueInterval);
|
|
219
221
|
if (queueCleanupInterval)
|
|
@@ -348,6 +350,9 @@ setNotifyCallback(async (target, text) => {
|
|
|
348
350
|
enqueue(target.platform, String(target.chatId), text);
|
|
349
351
|
});
|
|
350
352
|
startScheduler();
|
|
353
|
+
// Session memory hygiene: purge sessions idle > 7 days (configurable via
|
|
354
|
+
// ALVIN_SESSION_TTL_DAYS). Never touches active sessions — see session.ts.
|
|
355
|
+
startSessionCleanup();
|
|
351
356
|
// Wire delivery queue senders
|
|
352
357
|
setSenders({
|
|
353
358
|
telegram: async (chatId, content) => {
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-process Broadcast Bus — Telegram Activity Mirror (v4.5.0+)
|
|
3
|
+
*
|
|
4
|
+
* A tiny typed EventEmitter that lets the Telegram handler announce every
|
|
5
|
+
* user message, every streaming response delta, and every "response done"
|
|
6
|
+
* event. The web server subscribes to the same bus and forwards each event
|
|
7
|
+
* to all connected WebSocket clients as `mirror:*` messages.
|
|
8
|
+
*
|
|
9
|
+
* The TUI (and Web UI) can then show the full Telegram conversation in
|
|
10
|
+
* real time, side-by-side with its own isolated chat session.
|
|
11
|
+
*
|
|
12
|
+
* Design constraints:
|
|
13
|
+
* - Zero backpressure: events are fire-and-forget, listeners must be fast
|
|
14
|
+
* - No memory retention: no history is buffered here, just live pub/sub
|
|
15
|
+
* - Platform-agnostic signature so we can later mirror WhatsApp/Signal too
|
|
16
|
+
* - Does not touch the Claude Agent SDK or any provider internals — this
|
|
17
|
+
* is a pure observation layer
|
|
18
|
+
*/
|
|
19
|
+
import { EventEmitter } from "events";
|
|
20
|
+
class TypedBus extends EventEmitter {
|
|
21
|
+
emit(event, ...args) {
|
|
22
|
+
return super.emit(event, ...args);
|
|
23
|
+
}
|
|
24
|
+
on(event, listener) {
|
|
25
|
+
return super.on(event, listener);
|
|
26
|
+
}
|
|
27
|
+
off(event, listener) {
|
|
28
|
+
return super.off(event, listener);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Singleton bus. Import and call methods directly — no need for a factory.
|
|
33
|
+
*
|
|
34
|
+
* EventEmitter default maxListeners is 10; we bump it because a single
|
|
35
|
+
* web server connection may subscribe many listeners (one per connected
|
|
36
|
+
* WS client on a busy day).
|
|
37
|
+
*/
|
|
38
|
+
export const broadcast = new TypedBus();
|
|
39
|
+
broadcast.setMaxListeners(100);
|
|
40
|
+
// ── Convenience Emitters ───────────────────────────────────────────────────
|
|
41
|
+
export function emitUserMessage(payload) {
|
|
42
|
+
broadcast.emit("user_msg", payload);
|
|
43
|
+
}
|
|
44
|
+
export function emitResponseStart(payload) {
|
|
45
|
+
broadcast.emit("response_start", payload);
|
|
46
|
+
}
|
|
47
|
+
export function emitResponseDelta(payload) {
|
|
48
|
+
broadcast.emit("response_delta", payload);
|
|
49
|
+
}
|
|
50
|
+
export function emitResponseDone(payload) {
|
|
51
|
+
broadcast.emit("response_done", payload);
|
|
52
|
+
}
|
package/dist/services/session.js
CHANGED
|
@@ -39,6 +39,11 @@ export function getSession(key) {
|
|
|
39
39
|
};
|
|
40
40
|
sessions.set(k, session);
|
|
41
41
|
}
|
|
42
|
+
else {
|
|
43
|
+
// Touch lastActivity on every access so the cleanup interval
|
|
44
|
+
// never kills a session that's still being interacted with.
|
|
45
|
+
session.lastActivity = Date.now();
|
|
46
|
+
}
|
|
42
47
|
return session;
|
|
43
48
|
}
|
|
44
49
|
export function resetSession(key) {
|
|
@@ -53,16 +58,85 @@ export function resetSession(key) {
|
|
|
53
58
|
session.totalOutputTokens = 0;
|
|
54
59
|
session.history = [];
|
|
55
60
|
session.startedAt = Date.now();
|
|
61
|
+
// Reset budget warning flags so the user gets fresh warnings in the new session.
|
|
62
|
+
session._budgetWarned80 = false;
|
|
63
|
+
session._budgetWarned100 = false;
|
|
56
64
|
}
|
|
57
65
|
/** Track cost, query count, and tokens for a provider. */
|
|
58
66
|
export function trackProviderUsage(key, providerKey, cost, inputTokens, outputTokens) {
|
|
59
67
|
const session = getSession(key);
|
|
60
68
|
session.costByProvider[providerKey] = (session.costByProvider[providerKey] || 0) + cost;
|
|
61
69
|
session.queriesByProvider[providerKey] = (session.queriesByProvider[providerKey] || 0) + 1;
|
|
70
|
+
session.totalCost += cost;
|
|
62
71
|
if (inputTokens)
|
|
63
72
|
session.totalInputTokens += inputTokens;
|
|
64
73
|
if (outputTokens)
|
|
65
74
|
session.totalOutputTokens += outputTokens;
|
|
75
|
+
// Soft budget warnings — these NEVER block the bot. They exist purely
|
|
76
|
+
// as log signals so the operator (Ali) can notice unusually expensive
|
|
77
|
+
// sessions. Each threshold fires at most once per session (reset on /new).
|
|
78
|
+
const budget = config.maxBudgetUsd;
|
|
79
|
+
if (budget > 0) {
|
|
80
|
+
const pct = (session.totalCost / budget) * 100;
|
|
81
|
+
if (pct >= 100 && !session._budgetWarned100) {
|
|
82
|
+
console.warn(`💸 Session budget exceeded: $${session.totalCost.toFixed(4)} / $${budget.toFixed(2)} (${pct.toFixed(0)}%) — bot continues (no hard limit enforced)`);
|
|
83
|
+
session._budgetWarned100 = true;
|
|
84
|
+
}
|
|
85
|
+
else if (pct >= 80 && !session._budgetWarned80) {
|
|
86
|
+
console.warn(`⚠️ Session budget 80% consumed: $${session.totalCost.toFixed(4)} / $${budget.toFixed(2)}`);
|
|
87
|
+
session._budgetWarned80 = true;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
// ── Session Cleanup ────────────────────────────────────────────────────────
|
|
92
|
+
//
|
|
93
|
+
// Memory hygiene for long-running deployments. The sessions Map would
|
|
94
|
+
// otherwise grow unbounded as new users arrive. The cleanup is deliberately
|
|
95
|
+
// *conservative*:
|
|
96
|
+
// • Default TTL: 7 days of complete inactivity (not 24h)
|
|
97
|
+
// • Never touches sessions where isProcessing === true
|
|
98
|
+
// • Touches lastActivity on every getSession() call, so any interaction
|
|
99
|
+
// in the last 7 days keeps the session alive indefinitely
|
|
100
|
+
// • Aborts orphaned abort controllers defensively before removal
|
|
101
|
+
//
|
|
102
|
+
// Override via ALVIN_SESSION_TTL_DAYS env var if you want different behavior.
|
|
103
|
+
const SESSION_TTL_DAYS = Number(process.env.ALVIN_SESSION_TTL_DAYS) || 7;
|
|
104
|
+
const SESSION_INACTIVE_TTL_MS = SESSION_TTL_DAYS * 24 * 60 * 60 * 1000;
|
|
105
|
+
const CLEANUP_INTERVAL_MS = 60 * 60 * 1000; // check hourly
|
|
106
|
+
let cleanupTimer = null;
|
|
107
|
+
/** Start the periodic session cleanup. Safe to call multiple times. */
|
|
108
|
+
export function startSessionCleanup() {
|
|
109
|
+
if (cleanupTimer)
|
|
110
|
+
return;
|
|
111
|
+
cleanupTimer = setInterval(() => {
|
|
112
|
+
const now = Date.now();
|
|
113
|
+
let purged = 0;
|
|
114
|
+
for (const [key, s] of sessions) {
|
|
115
|
+
// NEVER kill a session that's actively processing a query.
|
|
116
|
+
if (s.isProcessing)
|
|
117
|
+
continue;
|
|
118
|
+
if (now - s.lastActivity > SESSION_INACTIVE_TTL_MS) {
|
|
119
|
+
if (s.abortController) {
|
|
120
|
+
try {
|
|
121
|
+
s.abortController.abort();
|
|
122
|
+
}
|
|
123
|
+
catch { /* ignore */ }
|
|
124
|
+
}
|
|
125
|
+
sessions.delete(key);
|
|
126
|
+
purged++;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (purged > 0) {
|
|
130
|
+
console.log(`🧹 Session cleanup: purged ${purged} inactive session(s) (TTL: ${SESSION_TTL_DAYS} days)`);
|
|
131
|
+
}
|
|
132
|
+
}, CLEANUP_INTERVAL_MS);
|
|
133
|
+
}
|
|
134
|
+
/** Stop the cleanup timer (for graceful shutdown). */
|
|
135
|
+
export function stopSessionCleanup() {
|
|
136
|
+
if (cleanupTimer) {
|
|
137
|
+
clearInterval(cleanupTimer);
|
|
138
|
+
cleanupTimer = null;
|
|
139
|
+
}
|
|
66
140
|
}
|
|
67
141
|
/** Add a message to conversation history (for non-SDK providers). */
|
|
68
142
|
export function addToHistory(key, message) {
|
package/dist/tui/index.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
*
|
|
17
17
|
* Usage: alvin-bot tui [--port 3100] [--host localhost] [--lang en|de]
|
|
18
18
|
*/
|
|
19
|
-
import { createInterface } from "readline";
|
|
19
|
+
import { createInterface, cursorTo, clearLine as rlClearLine } from "readline";
|
|
20
20
|
import WebSocket from "ws";
|
|
21
21
|
import http from "http";
|
|
22
22
|
import { initI18n, t } from "../i18n.js";
|
|
@@ -57,11 +57,17 @@ let connected = false;
|
|
|
57
57
|
let currentModel = "loading...";
|
|
58
58
|
let totalCost = 0;
|
|
59
59
|
let isStreaming = false;
|
|
60
|
+
let isMirrorStreaming = false;
|
|
60
61
|
let currentResponse = "";
|
|
61
62
|
let currentToolName = "";
|
|
62
63
|
let toolCount = 0;
|
|
63
64
|
const inputHistory = [];
|
|
64
65
|
let historyIndex = -1;
|
|
66
|
+
let activeTarget = "tui";
|
|
67
|
+
let observerEnabled = true;
|
|
68
|
+
// TUI's own session key — either ephemeral (new every start) or persistent
|
|
69
|
+
// ("tui:local") if --resume is passed. Set once in startTUI().
|
|
70
|
+
let tuiSessionKey = `tui:ephemeral:${Date.now()}`;
|
|
65
71
|
const host = process.argv.includes("--host")
|
|
66
72
|
? process.argv[process.argv.indexOf("--host") + 1] || "localhost"
|
|
67
73
|
: "localhost";
|
|
@@ -76,8 +82,14 @@ const HEADER_LINES = 3;
|
|
|
76
82
|
function getWidth() {
|
|
77
83
|
return process.stdout.columns || 80;
|
|
78
84
|
}
|
|
79
|
-
|
|
80
|
-
|
|
85
|
+
/**
|
|
86
|
+
* Clear the current readline input line so we can write content "above" the
|
|
87
|
+
* prompt cleanly. Uses readline's own cursor API instead of raw escape
|
|
88
|
+
* sequences — this cooperates with readline's internal cursor tracking.
|
|
89
|
+
*/
|
|
90
|
+
function clearCurrentLine() {
|
|
91
|
+
cursorTo(process.stdout, 0);
|
|
92
|
+
rlClearLine(process.stdout, 0);
|
|
81
93
|
}
|
|
82
94
|
function drawHeader() {
|
|
83
95
|
const w = getWidth();
|
|
@@ -85,61 +97,78 @@ function drawHeader() {
|
|
|
85
97
|
const status = connected ? t("tui.connected") : t("tui.disconnected");
|
|
86
98
|
const modelStr = `${C.brightMagenta}${currentModel}${C.reset}`;
|
|
87
99
|
const costStr = totalCost > 0 ? ` ${C.gray}· $${totalCost.toFixed(4)}${C.reset}` : "";
|
|
100
|
+
const targetStr = ` ${C.gray}│${C.reset} ${C.brightYellow}${activeTarget === "telegram" ? "→ Telegram" : "TUI session"}${C.reset}`;
|
|
88
101
|
const title = `${C.bold}${C.brightCyan}${t("tui.title")}${C.reset}`;
|
|
89
|
-
const right = `${statusDot} ${status} ${C.gray}│${C.reset} ${modelStr}${costStr}`;
|
|
102
|
+
const right = `${statusDot} ${status} ${C.gray}│${C.reset} ${modelStr}${costStr}${targetStr}`;
|
|
90
103
|
console.log(`${C.gray}${"─".repeat(w)}${C.reset}`);
|
|
91
104
|
console.log(` ${title}${"".padEnd(10)}${right}`);
|
|
92
105
|
console.log(`${C.gray}${"─".repeat(w)}${C.reset}`);
|
|
93
106
|
}
|
|
107
|
+
/**
|
|
108
|
+
* Redraw the header in place. Only safe to call when NOT streaming —
|
|
109
|
+
* the previous implementation used aggressive cursor save/restore escape
|
|
110
|
+
* sequences that collided with readline's internal cursor state and
|
|
111
|
+
* produced garbled output. Now this is a no-op during streaming and
|
|
112
|
+
* a clean redraw otherwise.
|
|
113
|
+
*/
|
|
94
114
|
function redrawHeader() {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
//
|
|
115
|
+
if (isStreaming)
|
|
116
|
+
return; // Don't touch the cursor mid-stream
|
|
117
|
+
clearCurrentLine();
|
|
118
|
+
process.stdout.write("\x1b[s"); // Save cursor
|
|
119
|
+
process.stdout.write("\x1b[H"); // Move to top-left
|
|
99
120
|
for (let i = 0; i < HEADER_LINES; i++) {
|
|
100
|
-
process.stdout
|
|
121
|
+
cursorTo(process.stdout, 0);
|
|
122
|
+
rlClearLine(process.stdout, 0);
|
|
101
123
|
if (i < HEADER_LINES - 1)
|
|
102
|
-
process.stdout.write("\x1b[1B");
|
|
124
|
+
process.stdout.write("\x1b[1B");
|
|
103
125
|
}
|
|
104
|
-
process.stdout.write("\x1b[H");
|
|
126
|
+
process.stdout.write("\x1b[H");
|
|
105
127
|
drawHeader();
|
|
106
|
-
process.stdout.write("\
|
|
128
|
+
process.stdout.write("\x1b[u"); // Restore cursor
|
|
129
|
+
if (rl && !isStreaming)
|
|
130
|
+
rl.prompt(true);
|
|
107
131
|
}
|
|
108
132
|
function drawHelp() {
|
|
109
133
|
console.log(`
|
|
110
134
|
${C.bold}${t("help.title")}${C.reset}
|
|
111
|
-
${C.cyan}/model${C.reset}
|
|
112
|
-
${C.cyan}/status${C.reset}
|
|
113
|
-
${C.cyan}/clear${C.reset}
|
|
114
|
-
${C.cyan}/cron${C.reset}
|
|
115
|
-
${C.cyan}/doctor${C.reset}
|
|
116
|
-
${C.cyan}/backup${C.reset}
|
|
117
|
-
${C.cyan}/restart${C.reset}
|
|
118
|
-
${C.cyan}/
|
|
119
|
-
${C.cyan}/
|
|
135
|
+
${C.cyan}/model${C.reset} ${t("help.model")}
|
|
136
|
+
${C.cyan}/status${C.reset} ${t("help.status")}
|
|
137
|
+
${C.cyan}/clear${C.reset} ${t("help.clear")}
|
|
138
|
+
${C.cyan}/cron${C.reset} ${t("help.cron")}
|
|
139
|
+
${C.cyan}/doctor${C.reset} ${t("help.doctor")}
|
|
140
|
+
${C.cyan}/backup${C.reset} ${t("help.backup")}
|
|
141
|
+
${C.cyan}/restart${C.reset} ${t("help.restart")}
|
|
142
|
+
${C.cyan}/target tui${C.reset}|${C.cyan}telegram${C.reset} Switch where your messages go
|
|
143
|
+
${C.cyan}/observe on${C.reset}|${C.cyan}off${C.reset} Mirror Telegram activity (default: on)
|
|
144
|
+
${C.cyan}/help${C.reset} ${t("help.help")}
|
|
145
|
+
${C.cyan}/quit${C.reset} ${t("help.quit")}
|
|
120
146
|
|
|
121
147
|
${C.dim}${t("help.footer")}${C.reset}
|
|
122
148
|
`);
|
|
123
149
|
}
|
|
124
150
|
function printUser(text) {
|
|
151
|
+
clearCurrentLine();
|
|
125
152
|
console.log(`\n${C.bold}${C.brightGreen}${t("tui.you")}:${C.reset} ${text}`);
|
|
126
153
|
}
|
|
127
154
|
function printAssistantStart() {
|
|
128
|
-
|
|
155
|
+
clearCurrentLine();
|
|
156
|
+
const targetTag = activeTarget === "telegram" ? ` ${C.dim}[→ Tel]${C.reset}` : "";
|
|
157
|
+
process.stdout.write(`\n${C.bold}${C.brightBlue}Alvin Bot${targetTag}:${C.reset} `);
|
|
129
158
|
}
|
|
130
159
|
function printAssistantDelta(text) {
|
|
131
160
|
process.stdout.write(text);
|
|
132
161
|
}
|
|
133
162
|
function printAssistantEnd(cost) {
|
|
134
163
|
const costStr = cost && cost > 0 ? ` ${C.dim}($${cost.toFixed(4)})${C.reset}` : "";
|
|
135
|
-
|
|
164
|
+
process.stdout.write(costStr + "\n");
|
|
136
165
|
}
|
|
137
166
|
function printTool(name) {
|
|
138
|
-
|
|
139
|
-
process.stdout.write(
|
|
167
|
+
clearCurrentLine();
|
|
168
|
+
process.stdout.write(` ${C.yellow}⚙ ${name}...${C.reset}`);
|
|
140
169
|
}
|
|
141
170
|
function printToolDone() {
|
|
142
|
-
|
|
171
|
+
clearCurrentLine();
|
|
143
172
|
if (toolCount > 0) {
|
|
144
173
|
const label = toolCount > 1 ? t("tui.toolsUsed") : t("tui.toolUsed");
|
|
145
174
|
console.log(` ${C.dim}${C.yellow}⚙ ${toolCount} ${label}${C.reset}`);
|
|
@@ -147,19 +176,46 @@ function printToolDone() {
|
|
|
147
176
|
toolCount = 0;
|
|
148
177
|
}
|
|
149
178
|
function printError(msg) {
|
|
179
|
+
clearCurrentLine();
|
|
150
180
|
console.log(`\n${C.red}✖ ${msg}${C.reset}`);
|
|
151
181
|
}
|
|
152
182
|
function printInfo(msg) {
|
|
183
|
+
clearCurrentLine();
|
|
153
184
|
console.log(`${C.cyan}ℹ ${msg}${C.reset}`);
|
|
154
185
|
}
|
|
155
186
|
function printSuccess(msg) {
|
|
187
|
+
clearCurrentLine();
|
|
156
188
|
console.log(`${C.green}✔ ${msg}${C.reset}`);
|
|
157
189
|
}
|
|
190
|
+
/**
|
|
191
|
+
* Render the mirror of a Telegram event (user message or bot response).
|
|
192
|
+
* Distinct styling: dim, phone prefix, grayed color.
|
|
193
|
+
*/
|
|
194
|
+
function printMirrorUser(text) {
|
|
195
|
+
clearCurrentLine();
|
|
196
|
+
console.log(`\n${C.dim}${C.gray}📱 Tel User: ${text}${C.reset}`);
|
|
197
|
+
}
|
|
198
|
+
function printMirrorAssistantStart() {
|
|
199
|
+
clearCurrentLine();
|
|
200
|
+
process.stdout.write(`\n${C.dim}${C.gray}📱 Tel Bot: ${C.reset}`);
|
|
201
|
+
}
|
|
202
|
+
function printMirrorAssistantDelta(text) {
|
|
203
|
+
// Dim styling while streaming the mirrored response
|
|
204
|
+
process.stdout.write(`${C.dim}${C.gray}${text}${C.reset}`);
|
|
205
|
+
}
|
|
206
|
+
function printMirrorAssistantEnd() {
|
|
207
|
+
process.stdout.write("\n");
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* The single source of truth for rendering the input prompt. Only ever
|
|
211
|
+
* called at state-transition points (connect, done, error, command result)
|
|
212
|
+
* and no-ops during streaming so the prompt never races with delta writes.
|
|
213
|
+
*/
|
|
158
214
|
function showPrompt() {
|
|
159
|
-
if (!
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
215
|
+
if (isStreaming || !rl)
|
|
216
|
+
return;
|
|
217
|
+
rl.setPrompt(`${C.brightGreen}❯${C.reset} `);
|
|
218
|
+
rl.prompt(true);
|
|
163
219
|
}
|
|
164
220
|
// ── WebSocket Connection ────────────────────────────────
|
|
165
221
|
function connectWebSocket() {
|
|
@@ -223,7 +279,7 @@ function handleMessage(msg) {
|
|
|
223
279
|
isStreaming = false;
|
|
224
280
|
currentResponse = "";
|
|
225
281
|
currentToolName = "";
|
|
226
|
-
redrawHeader(); // Update cost in header
|
|
282
|
+
redrawHeader(); // Update cost in header (only if not streaming — see redrawHeader)
|
|
227
283
|
showPrompt();
|
|
228
284
|
break;
|
|
229
285
|
case "error":
|
|
@@ -235,6 +291,41 @@ function handleMessage(msg) {
|
|
|
235
291
|
printInfo(t("tui.sessionReset"));
|
|
236
292
|
showPrompt();
|
|
237
293
|
break;
|
|
294
|
+
// ── v4.5.0: Telegram activity mirror events ────────────────────────
|
|
295
|
+
// These arrive whenever someone interacts with the bot via Telegram,
|
|
296
|
+
// regardless of what the TUI is currently doing. We render them
|
|
297
|
+
// distinctly (dim + 📱 prefix) so they don't confuse themselves with
|
|
298
|
+
// the user's own session.
|
|
299
|
+
case "mirror:user_msg":
|
|
300
|
+
if (!observerEnabled)
|
|
301
|
+
break;
|
|
302
|
+
printMirrorUser(msg.text || "");
|
|
303
|
+
break;
|
|
304
|
+
case "mirror:response_start":
|
|
305
|
+
if (!observerEnabled)
|
|
306
|
+
break;
|
|
307
|
+
isMirrorStreaming = true;
|
|
308
|
+
printMirrorAssistantStart();
|
|
309
|
+
break;
|
|
310
|
+
case "mirror:response_delta":
|
|
311
|
+
if (!observerEnabled)
|
|
312
|
+
break;
|
|
313
|
+
if (!isMirrorStreaming) {
|
|
314
|
+
isMirrorStreaming = true;
|
|
315
|
+
printMirrorAssistantStart();
|
|
316
|
+
}
|
|
317
|
+
printMirrorAssistantDelta(msg.delta || "");
|
|
318
|
+
break;
|
|
319
|
+
case "mirror:response_done":
|
|
320
|
+
if (!observerEnabled)
|
|
321
|
+
break;
|
|
322
|
+
if (isMirrorStreaming) {
|
|
323
|
+
printMirrorAssistantEnd();
|
|
324
|
+
isMirrorStreaming = false;
|
|
325
|
+
}
|
|
326
|
+
// Don't call showPrompt here — the user's own prompt state is
|
|
327
|
+
// independent of mirror activity.
|
|
328
|
+
break;
|
|
238
329
|
}
|
|
239
330
|
}
|
|
240
331
|
// ── API Calls ───────────────────────────────────────────
|
|
@@ -423,6 +514,40 @@ async function handleCommand(cmd) {
|
|
|
423
514
|
ws.send(JSON.stringify({ type: "reset" }));
|
|
424
515
|
}
|
|
425
516
|
break;
|
|
517
|
+
case "target":
|
|
518
|
+
case "t": {
|
|
519
|
+
const val = (parts[1] || "").toLowerCase();
|
|
520
|
+
if (val === "tui") {
|
|
521
|
+
activeTarget = "tui";
|
|
522
|
+
printSuccess("Target: TUI (your own isolated session)");
|
|
523
|
+
redrawHeader();
|
|
524
|
+
}
|
|
525
|
+
else if (val === "telegram" || val === "tel") {
|
|
526
|
+
activeTarget = "telegram";
|
|
527
|
+
printSuccess("Target: Telegram (your messages now go into the Telegram session — the bot replies in Telegram AND here)");
|
|
528
|
+
redrawHeader();
|
|
529
|
+
}
|
|
530
|
+
else {
|
|
531
|
+
printInfo(`Current target: ${activeTarget}. Use /target tui or /target telegram.`);
|
|
532
|
+
}
|
|
533
|
+
break;
|
|
534
|
+
}
|
|
535
|
+
case "observe":
|
|
536
|
+
case "o": {
|
|
537
|
+
const val = (parts[1] || "").toLowerCase();
|
|
538
|
+
if (val === "on" || val === "1" || val === "true") {
|
|
539
|
+
observerEnabled = true;
|
|
540
|
+
printSuccess("Observer mode: ON — Telegram activity will be mirrored here (dim)");
|
|
541
|
+
}
|
|
542
|
+
else if (val === "off" || val === "0" || val === "false") {
|
|
543
|
+
observerEnabled = false;
|
|
544
|
+
printSuccess("Observer mode: OFF — Telegram activity will NOT be shown here");
|
|
545
|
+
}
|
|
546
|
+
else {
|
|
547
|
+
printInfo(`Observer: ${observerEnabled ? "on" : "off"}. Use /observe on or /observe off.`);
|
|
548
|
+
}
|
|
549
|
+
break;
|
|
550
|
+
}
|
|
426
551
|
case "quit":
|
|
427
552
|
case "q":
|
|
428
553
|
case "exit":
|
|
@@ -442,7 +567,16 @@ function sendChat(text) {
|
|
|
442
567
|
return;
|
|
443
568
|
}
|
|
444
569
|
printUser(text);
|
|
445
|
-
|
|
570
|
+
// v4.5.0: include target + sessionKey so the web server routes the
|
|
571
|
+
// message to the right session. For target=tui, sessionKey is the
|
|
572
|
+
// TUI's own ephemeral (or persistent) key; for target=telegram,
|
|
573
|
+
// the server resolves it to the primary Telegram user's key.
|
|
574
|
+
ws.send(JSON.stringify({
|
|
575
|
+
type: "chat",
|
|
576
|
+
text,
|
|
577
|
+
target: activeTarget,
|
|
578
|
+
sessionKey: activeTarget === "tui" ? tuiSessionKey : undefined,
|
|
579
|
+
}));
|
|
446
580
|
if (inputHistory[0] !== text) {
|
|
447
581
|
inputHistory.unshift(text);
|
|
448
582
|
if (inputHistory.length > 100)
|
|
@@ -464,9 +598,14 @@ async function fetchInitialModel() {
|
|
|
464
598
|
catch { /* will get it on connect */ }
|
|
465
599
|
}
|
|
466
600
|
export async function startTUI() {
|
|
601
|
+
// --resume: use persistent TUI session (survives restarts).
|
|
602
|
+
// Default: ephemeral session, fresh every TUI start.
|
|
603
|
+
const wantResume = process.argv.includes("--resume");
|
|
604
|
+
tuiSessionKey = wantResume ? "tui:local" : `tui:ephemeral:${Date.now()}`;
|
|
467
605
|
console.clear();
|
|
468
606
|
drawHeader();
|
|
469
|
-
console.log(`${C.dim}${t("tui.connecting")} ${baseUrl}...${C.reset}
|
|
607
|
+
console.log(`${C.dim}${t("tui.connecting")} ${baseUrl}...${C.reset}`);
|
|
608
|
+
console.log(`${C.dim}Session: ${wantResume ? "resuming tui:local (persistent)" : "new ephemeral session"}${C.reset}\n`);
|
|
470
609
|
drawHelp();
|
|
471
610
|
rl = createInterface({
|
|
472
611
|
input: process.stdin,
|
|
@@ -495,9 +634,18 @@ export async function startTUI() {
|
|
|
495
634
|
console.log(`\n${C.dim}${t("tui.bye")}${C.reset}\n`);
|
|
496
635
|
process.exit(0);
|
|
497
636
|
});
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
637
|
+
// NOTE: Do NOT call process.stdin.setRawMode(false) here. readline with
|
|
638
|
+
// `terminal: true` already controls the terminal mode, and forcing cooked
|
|
639
|
+
// mode on top of that causes every keystroke to be echoed TWICE (once by
|
|
640
|
+
// the terminal, once by readline's line editor) — producing the classic
|
|
641
|
+
// "hheelllloo" double-echo bug. Let readline manage the tty mode itself.
|
|
642
|
+
// Handle terminal resize — redraw header to fit the new width.
|
|
643
|
+
process.stdout.on("resize", () => {
|
|
644
|
+
if (!isStreaming) {
|
|
645
|
+
redrawHeader();
|
|
646
|
+
showPrompt();
|
|
647
|
+
}
|
|
648
|
+
});
|
|
501
649
|
await fetchInitialModel();
|
|
502
650
|
connectWebSocket();
|
|
503
651
|
}
|
package/dist/web/server.js
CHANGED
|
@@ -28,6 +28,7 @@ import { handleDoctorAPI } from "./doctor-api.js";
|
|
|
28
28
|
import { handleOpenAICompat } from "./openai-compat.js";
|
|
29
29
|
import { addCanvasClient } from "./canvas.js";
|
|
30
30
|
import { BOT_ROOT, ENV_FILE, PUBLIC_DIR, MEMORY_DIR, MEMORY_FILE, SOUL_FILE, DATA_DIR, MCP_CONFIG, SKILLS_DIR } from "../paths.js";
|
|
31
|
+
import { broadcast } from "../services/broadcast.js";
|
|
31
32
|
const WEB_PORT = parseInt(process.env.WEB_PORT || "3100");
|
|
32
33
|
const WEB_PASSWORD = process.env.WEB_PASSWORD || "";
|
|
33
34
|
/** The actual port the Web UI is running on (may differ from WEB_PORT if busy). */
|
|
@@ -1144,6 +1145,57 @@ async function handleAPI(req, res, urlPath, body) {
|
|
|
1144
1145
|
res.end(JSON.stringify({ error: "Not found" }));
|
|
1145
1146
|
}
|
|
1146
1147
|
// ── WebSocket Chat ──────────────────────────────────────
|
|
1148
|
+
// Set of all currently connected chat WebSocket clients (excluding canvas).
|
|
1149
|
+
// Populated on connect, cleaned up on close. Used to forward Telegram
|
|
1150
|
+
// activity to every observer.
|
|
1151
|
+
const chatClients = new Set();
|
|
1152
|
+
/**
|
|
1153
|
+
* Wire the broadcast bus once at module load. The bus is singleton, so
|
|
1154
|
+
* subscribing here means every Telegram message fan-outs to every connected
|
|
1155
|
+
* chat client — without any per-connection re-subscription.
|
|
1156
|
+
*/
|
|
1157
|
+
broadcast.on("user_msg", (payload) => {
|
|
1158
|
+
if (payload.platform !== "telegram")
|
|
1159
|
+
return; // v4.5.0: telegram only for now
|
|
1160
|
+
const json = JSON.stringify({
|
|
1161
|
+
type: "mirror:user_msg",
|
|
1162
|
+
text: payload.text,
|
|
1163
|
+
platform: payload.platform,
|
|
1164
|
+
userName: payload.userName,
|
|
1165
|
+
ts: payload.ts,
|
|
1166
|
+
});
|
|
1167
|
+
for (const client of chatClients) {
|
|
1168
|
+
if (client.readyState === WebSocket.OPEN)
|
|
1169
|
+
client.send(json);
|
|
1170
|
+
}
|
|
1171
|
+
});
|
|
1172
|
+
broadcast.on("response_start", (payload) => {
|
|
1173
|
+
if (payload.platform !== "telegram")
|
|
1174
|
+
return;
|
|
1175
|
+
const json = JSON.stringify({ type: "mirror:response_start", platform: payload.platform, ts: payload.ts });
|
|
1176
|
+
for (const client of chatClients) {
|
|
1177
|
+
if (client.readyState === WebSocket.OPEN)
|
|
1178
|
+
client.send(json);
|
|
1179
|
+
}
|
|
1180
|
+
});
|
|
1181
|
+
broadcast.on("response_delta", (payload) => {
|
|
1182
|
+
if (payload.platform !== "telegram")
|
|
1183
|
+
return;
|
|
1184
|
+
const json = JSON.stringify({ type: "mirror:response_delta", delta: payload.delta, platform: payload.platform, ts: payload.ts });
|
|
1185
|
+
for (const client of chatClients) {
|
|
1186
|
+
if (client.readyState === WebSocket.OPEN)
|
|
1187
|
+
client.send(json);
|
|
1188
|
+
}
|
|
1189
|
+
});
|
|
1190
|
+
broadcast.on("response_done", (payload) => {
|
|
1191
|
+
if (payload.platform !== "telegram")
|
|
1192
|
+
return;
|
|
1193
|
+
const json = JSON.stringify({ type: "mirror:response_done", cost: payload.cost, platform: payload.platform, ts: payload.ts });
|
|
1194
|
+
for (const client of chatClients) {
|
|
1195
|
+
if (client.readyState === WebSocket.OPEN)
|
|
1196
|
+
client.send(json);
|
|
1197
|
+
}
|
|
1198
|
+
});
|
|
1147
1199
|
function handleWebSocket(wss) {
|
|
1148
1200
|
wss.on("connection", (ws, req) => {
|
|
1149
1201
|
// Auth check
|
|
@@ -1158,12 +1210,33 @@ function handleWebSocket(wss) {
|
|
|
1158
1210
|
return;
|
|
1159
1211
|
}
|
|
1160
1212
|
console.log("WebUI: client connected");
|
|
1213
|
+
chatClients.add(ws);
|
|
1161
1214
|
ws.on("message", async (data) => {
|
|
1162
1215
|
try {
|
|
1163
1216
|
const msg = JSON.parse(data.toString());
|
|
1164
1217
|
if (msg.type === "chat") {
|
|
1165
1218
|
let { text, effort, file } = msg;
|
|
1166
|
-
|
|
1219
|
+
// v4.5.0: session routing. The client (TUI/WebUI) tells us which
|
|
1220
|
+
// session it wants its message to go into. Supported targets:
|
|
1221
|
+
// - "tui" → use msg.sessionKey (e.g. "tui:local" or
|
|
1222
|
+
// "tui:ephemeral:…"). Isolated from Telegram.
|
|
1223
|
+
// - "telegram" → route into the primary Telegram user's session.
|
|
1224
|
+
// Responses go back to the client AND to the
|
|
1225
|
+
// actual Telegram chat via the broadcast bus.
|
|
1226
|
+
// - undefined → backwards-compatible: default to the primary
|
|
1227
|
+
// allowed user's session (old behavior).
|
|
1228
|
+
const target = msg.target;
|
|
1229
|
+
const telegramUserId = config.allowedUsers[0] || 0;
|
|
1230
|
+
let sessionKey;
|
|
1231
|
+
if (target === "tui" && typeof msg.sessionKey === "string" && msg.sessionKey.startsWith("tui:")) {
|
|
1232
|
+
sessionKey = msg.sessionKey;
|
|
1233
|
+
}
|
|
1234
|
+
else if (target === "telegram") {
|
|
1235
|
+
sessionKey = telegramUserId;
|
|
1236
|
+
}
|
|
1237
|
+
else {
|
|
1238
|
+
sessionKey = telegramUserId; // backwards compat
|
|
1239
|
+
}
|
|
1167
1240
|
// Handle file upload — save to temp and reference in prompt
|
|
1168
1241
|
if (file?.dataUrl && file?.name) {
|
|
1169
1242
|
try {
|
|
@@ -1184,16 +1257,17 @@ function handleWebSocket(wss) {
|
|
|
1184
1257
|
const registry = getRegistry();
|
|
1185
1258
|
const activeProvider = registry.getActive();
|
|
1186
1259
|
const isSDK = activeProvider.config.type === "claude-sdk";
|
|
1187
|
-
const session = getSession(
|
|
1260
|
+
const session = getSession(sessionKey);
|
|
1188
1261
|
const queryOpts = {
|
|
1189
1262
|
prompt: text,
|
|
1190
|
-
systemPrompt: buildSystemPrompt(isSDK, session.language, "web-dashboard"),
|
|
1263
|
+
systemPrompt: buildSystemPrompt(isSDK, session.language, target === "telegram" ? "telegram" : "web-dashboard"),
|
|
1191
1264
|
workingDir: session.workingDir,
|
|
1192
1265
|
effort: effort || session.effort,
|
|
1193
1266
|
sessionId: isSDK ? session.sessionId : null,
|
|
1194
1267
|
history: !isSDK ? session.history : undefined,
|
|
1195
1268
|
};
|
|
1196
1269
|
let gotDone = false;
|
|
1270
|
+
let finalText = ""; // v4.5.0: capture the final response for target=telegram relay
|
|
1197
1271
|
try {
|
|
1198
1272
|
// Stream response
|
|
1199
1273
|
for await (const chunk of registry.queryWithFallback(queryOpts)) {
|
|
@@ -1201,6 +1275,8 @@ function handleWebSocket(wss) {
|
|
|
1201
1275
|
break;
|
|
1202
1276
|
switch (chunk.type) {
|
|
1203
1277
|
case "text":
|
|
1278
|
+
if (chunk.text)
|
|
1279
|
+
finalText = chunk.text;
|
|
1204
1280
|
ws.send(JSON.stringify({ type: "text", text: chunk.text, delta: chunk.delta }));
|
|
1205
1281
|
break;
|
|
1206
1282
|
case "tool_use":
|
|
@@ -1208,6 +1284,8 @@ function handleWebSocket(wss) {
|
|
|
1208
1284
|
break;
|
|
1209
1285
|
case "done":
|
|
1210
1286
|
gotDone = true;
|
|
1287
|
+
if (chunk.text)
|
|
1288
|
+
finalText = chunk.text;
|
|
1211
1289
|
if (chunk.sessionId)
|
|
1212
1290
|
session.sessionId = chunk.sessionId;
|
|
1213
1291
|
if (chunk.costUsd)
|
|
@@ -1235,6 +1313,21 @@ function handleWebSocket(wss) {
|
|
|
1235
1313
|
if (!gotDone && ws.readyState === WebSocket.OPEN) {
|
|
1236
1314
|
ws.send(JSON.stringify({ type: "done", cost: 0 }));
|
|
1237
1315
|
}
|
|
1316
|
+
// v4.5.0: if the user typed in the TUI with target=telegram, we
|
|
1317
|
+
// must also post the bot's final response to the actual Telegram
|
|
1318
|
+
// chat so the continuity is preserved from the Telegram side.
|
|
1319
|
+
// (Telegram bots cannot forge user messages, so only the
|
|
1320
|
+
// response lands in the chat — the user prompt itself stays
|
|
1321
|
+
// in the TUI.)
|
|
1322
|
+
if (target === "telegram" && finalText.trim()) {
|
|
1323
|
+
try {
|
|
1324
|
+
const dq = await import("../services/delivery-queue.js");
|
|
1325
|
+
dq.enqueue("telegram", String(telegramUserId), finalText);
|
|
1326
|
+
}
|
|
1327
|
+
catch (err) {
|
|
1328
|
+
console.error("WebUI → Telegram relay failed:", err);
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1238
1331
|
}
|
|
1239
1332
|
catch (streamErr) {
|
|
1240
1333
|
const errMsg = streamErr instanceof Error ? streamErr.message : String(streamErr);
|
|
@@ -1248,8 +1341,17 @@ function handleWebSocket(wss) {
|
|
|
1248
1341
|
}
|
|
1249
1342
|
}
|
|
1250
1343
|
if (msg.type === "reset") {
|
|
1251
|
-
|
|
1252
|
-
|
|
1344
|
+
// v4.5.0: reset the target session, not a hardcoded one.
|
|
1345
|
+
const target = msg.target;
|
|
1346
|
+
const telegramUserId = config.allowedUsers[0] || 0;
|
|
1347
|
+
let resetKey;
|
|
1348
|
+
if (target === "tui" && typeof msg.sessionKey === "string" && msg.sessionKey.startsWith("tui:")) {
|
|
1349
|
+
resetKey = msg.sessionKey;
|
|
1350
|
+
}
|
|
1351
|
+
else {
|
|
1352
|
+
resetKey = telegramUserId;
|
|
1353
|
+
}
|
|
1354
|
+
resetSession(resetKey);
|
|
1253
1355
|
ws.send(JSON.stringify({ type: "reset", ok: true }));
|
|
1254
1356
|
}
|
|
1255
1357
|
}
|
|
@@ -1260,6 +1362,7 @@ function handleWebSocket(wss) {
|
|
|
1260
1362
|
});
|
|
1261
1363
|
ws.on("close", () => {
|
|
1262
1364
|
console.log("WebUI: client disconnected");
|
|
1365
|
+
chatClients.delete(ws);
|
|
1263
1366
|
});
|
|
1264
1367
|
});
|
|
1265
1368
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "alvin-bot",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.5.0",
|
|
4
4
|
"description": "Alvin Bot — Your personal AI agent on Telegram, WhatsApp, Discord, Signal, and Web.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -159,7 +159,7 @@
|
|
|
159
159
|
"url": "git+https://github.com/alvbln/Alvin-Bot.git"
|
|
160
160
|
},
|
|
161
161
|
"dependencies": {
|
|
162
|
-
"@anthropic-ai/claude-agent-sdk": "^0.2.
|
|
162
|
+
"@anthropic-ai/claude-agent-sdk": "^0.2.97",
|
|
163
163
|
"@hapi/boom": "^10.0.1",
|
|
164
164
|
"@slack/bolt": "^4.6.0",
|
|
165
165
|
"@types/node": "^22.0.0",
|