@hienlh/ppm 0.9.30 → 0.9.32
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 +18 -5
- package/dist/web/assets/{browser-tab-D0o6oSlt.js → browser-tab-B9nNKjZX.js} +1 -1
- package/dist/web/assets/{chat-tab-Boo_H1k9.js → chat-tab-6XGhEKaC.js} +2 -2
- package/dist/web/assets/{code-editor-DayGetAZ.js → code-editor-DMZMpzt2.js} +1 -1
- package/dist/web/assets/{database-viewer-CaxAp1qK.js → database-viewer-CnP1FFS2.js} +1 -1
- package/dist/web/assets/{diff-viewer-BvEXe_B4.js → diff-viewer-Cvwd0XBO.js} +1 -1
- package/dist/web/assets/{extension-webview-6XProGzB.js → extension-webview-DkhsRepr.js} +1 -1
- package/dist/web/assets/{git-graph-CvgIIt2x.js → git-graph-C3670Nxm.js} +1 -1
- package/dist/web/assets/index-CcFDEPCo.css +2 -0
- package/dist/web/assets/index-DjIQL8ar.js +30 -0
- package/dist/web/assets/keybindings-store-DHh6rwm-.js +1 -0
- package/dist/web/assets/{markdown-renderer-UCGYJpI-.js → markdown-renderer-Co04dDdI.js} +1 -1
- package/dist/web/assets/{postgres-viewer-TV6kyo6B.js → postgres-viewer-D8K1qnnA.js} +1 -1
- package/dist/web/assets/{settings-tab-EziN5Pco.js → settings-tab-64ODAeQZ.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-D7LPvSkU.js → sqlite-viewer-ClX7FICB.js} +1 -1
- package/dist/web/assets/{terminal-tab-C7Hdv1nq.js → terminal-tab-Dw4IKWGM.js} +1 -1
- package/dist/web/assets/{use-monaco-theme-CI4vTUsh.js → use-monaco-theme-DA7EyR70.js} +1 -1
- package/dist/web/index.html +2 -2
- package/dist/web/sw.js +1 -1
- package/docs/codebase-summary.md +33 -3
- package/docs/project-changelog.md +47 -0
- package/docs/project-roadmap.md +14 -7
- package/docs/streaming-input-guide.md +267 -0
- package/docs/system-architecture.md +65 -2
- package/package.json +1 -1
- package/snapshot-state.md +1526 -0
- package/src/server/index.ts +8 -1
- package/src/server/routes/settings.ts +72 -1
- package/src/services/clawbot/clawbot-formatter.ts +88 -0
- package/src/services/clawbot/clawbot-memory.ts +333 -0
- package/src/services/clawbot/clawbot-service.ts +500 -0
- package/src/services/clawbot/clawbot-session.ts +188 -0
- package/src/services/clawbot/clawbot-streamer.ts +245 -0
- package/src/services/clawbot/clawbot-telegram.ts +251 -0
- package/src/services/config.service.ts +1 -1
- package/src/services/db.service.ts +279 -1
- package/src/services/supervisor.ts +10 -0
- package/src/types/clawbot.ts +103 -0
- package/src/types/config.ts +22 -0
- package/src/web/components/chat/chat-history-bar.tsx +8 -3
- package/src/web/components/settings/clawbot-settings-section.tsx +270 -0
- package/src/web/components/settings/settings-tab.tsx +4 -1
- package/test-session-ops.mjs +444 -0
- package/test-tokens.mjs +212 -0
- package/dist/web/assets/index-CJvp0DJT.css +0 -2
- package/dist/web/assets/index-DocPzjV6.js +0 -30
- package/dist/web/assets/keybindings-store-2KURy8S3.js +0 -1
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
import { configService } from "../config.service.ts";
|
|
2
|
+
import { chatService } from "../chat.service.ts";
|
|
3
|
+
import {
|
|
4
|
+
isPairedChat,
|
|
5
|
+
getPairingByChatId,
|
|
6
|
+
createPairingRequest,
|
|
7
|
+
approvePairing,
|
|
8
|
+
} from "../db.service.ts";
|
|
9
|
+
import { ClawBotTelegram } from "./clawbot-telegram.ts";
|
|
10
|
+
import { ClawBotSessionManager } from "./clawbot-session.ts";
|
|
11
|
+
import { ClawBotMemory } from "./clawbot-memory.ts";
|
|
12
|
+
import { streamToTelegram } from "./clawbot-streamer.ts";
|
|
13
|
+
import { escapeHtml } from "./clawbot-formatter.ts";
|
|
14
|
+
import type { TelegramUpdate, ClawBotCommand } from "../../types/clawbot.ts";
|
|
15
|
+
import type { ClawBotConfig, TelegramConfig, PermissionMode } from "../../types/config.ts";
|
|
16
|
+
import type { SendMessageOpts } from "../../types/chat.ts";
|
|
17
|
+
|
|
18
|
+
const CONTEXT_WINDOW_THRESHOLD = 80;
|
|
19
|
+
|
|
20
|
+
class ClawBotService {
|
|
21
|
+
private telegram: ClawBotTelegram | null = null;
|
|
22
|
+
private sessions = new ClawBotSessionManager();
|
|
23
|
+
private memory = new ClawBotMemory();
|
|
24
|
+
private running = false;
|
|
25
|
+
|
|
26
|
+
/** Debounce timers per chatId */
|
|
27
|
+
private debounceTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
28
|
+
private debouncedTexts = new Map<string, string>();
|
|
29
|
+
|
|
30
|
+
/** Processing lock per chatId */
|
|
31
|
+
private processing = new Set<string>();
|
|
32
|
+
|
|
33
|
+
/** Message queue per chatId for concurrent messages */
|
|
34
|
+
private messageQueue = new Map<string, string[]>();
|
|
35
|
+
|
|
36
|
+
/** Sessions that already had their title set */
|
|
37
|
+
private titledSessions = new Set<string>();
|
|
38
|
+
|
|
39
|
+
// ── Lifecycle ─────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
async start(): Promise<void> {
|
|
42
|
+
const clawbotConfig = this.getConfig();
|
|
43
|
+
if (!clawbotConfig?.enabled) {
|
|
44
|
+
console.log("[clawbot] Disabled in config");
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const telegramConfig = configService.get("telegram") as TelegramConfig | undefined;
|
|
49
|
+
if (!telegramConfig?.bot_token) {
|
|
50
|
+
console.log("[clawbot] No bot token configured");
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
this.telegram = new ClawBotTelegram(telegramConfig.bot_token);
|
|
56
|
+
this.running = true;
|
|
57
|
+
|
|
58
|
+
// Run memory decay on startup
|
|
59
|
+
this.memory.runDecay();
|
|
60
|
+
|
|
61
|
+
// Start polling (non-blocking)
|
|
62
|
+
this.telegram.startPolling((update) => this.handleUpdate(update));
|
|
63
|
+
|
|
64
|
+
console.log("[clawbot] Started");
|
|
65
|
+
} catch (err) {
|
|
66
|
+
console.error("[clawbot] Start failed:", (err as Error).message);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
stop(): void {
|
|
71
|
+
this.running = false;
|
|
72
|
+
this.telegram?.stop();
|
|
73
|
+
this.telegram = null;
|
|
74
|
+
|
|
75
|
+
for (const timer of this.debounceTimers.values()) clearTimeout(timer);
|
|
76
|
+
this.debounceTimers.clear();
|
|
77
|
+
this.debouncedTexts.clear();
|
|
78
|
+
this.processing.clear();
|
|
79
|
+
this.messageQueue.clear();
|
|
80
|
+
|
|
81
|
+
console.log("[clawbot] Stopped");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
get isRunning(): boolean {
|
|
85
|
+
return this.running;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Notify user on Telegram that their pairing was approved */
|
|
89
|
+
async notifyPairingApproved(chatId: string): Promise<void> {
|
|
90
|
+
await this.telegram?.sendMessage(
|
|
91
|
+
Number(chatId),
|
|
92
|
+
"✅ Pairing approved! You can now chat with ClawBot.\n\nSend /start to begin.",
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ── Update Routing ──────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
private async handleUpdate(update: TelegramUpdate): Promise<void> {
|
|
99
|
+
if (!this.telegram) return;
|
|
100
|
+
const message = update.message;
|
|
101
|
+
if (!message?.chat?.id) return;
|
|
102
|
+
|
|
103
|
+
const chatId = String(message.chat.id);
|
|
104
|
+
const userId = message.from?.id ?? 0;
|
|
105
|
+
const displayName = message.from?.first_name ?? message.from?.username ?? "Unknown";
|
|
106
|
+
|
|
107
|
+
// Pairing-based access control
|
|
108
|
+
if (!isPairedChat(chatId)) {
|
|
109
|
+
const pairing = getPairingByChatId(chatId);
|
|
110
|
+
if (!pairing) {
|
|
111
|
+
// First-time user — generate pairing code
|
|
112
|
+
const code = this.generatePairingCode();
|
|
113
|
+
createPairingRequest(chatId, String(userId), displayName, code);
|
|
114
|
+
await this.telegram!.sendMessage(
|
|
115
|
+
Number(chatId),
|
|
116
|
+
`🔐 Pairing required.\n\nYour pairing code: <code>${code}</code>\n\nEnter this code in PPM Settings → ClawBot → Pair Device to approve access.`,
|
|
117
|
+
);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (pairing.status === "pending") {
|
|
121
|
+
await this.telegram!.sendMessage(
|
|
122
|
+
Number(chatId),
|
|
123
|
+
`⏳ Pairing pending approval.\n\nCode: <code>${pairing.pairing_code}</code>\nAsk the PPM owner to approve in Settings → ClawBot.`,
|
|
124
|
+
);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (pairing.status === "revoked") {
|
|
128
|
+
return; // Silently ignore
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Try parsing as command
|
|
133
|
+
const command = ClawBotTelegram.parseCommand(message);
|
|
134
|
+
if (command) {
|
|
135
|
+
await this.handleCommand(command);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Regular message
|
|
140
|
+
const text = message.text ?? message.caption ?? "";
|
|
141
|
+
if (!text.trim()) return;
|
|
142
|
+
|
|
143
|
+
await this.handleMessage(chatId, text);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ── Command Handlers ────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
private async handleCommand(cmd: ClawBotCommand): Promise<void> {
|
|
149
|
+
const chatId = String(cmd.chatId);
|
|
150
|
+
const tg = this.telegram!;
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
switch (cmd.command) {
|
|
154
|
+
case "start": await this.cmdStart(chatId); break;
|
|
155
|
+
case "project": await this.cmdProject(chatId, cmd.args); break;
|
|
156
|
+
case "new": await this.cmdNew(chatId); break;
|
|
157
|
+
case "sessions": await this.cmdSessions(chatId); break;
|
|
158
|
+
case "resume": await this.cmdResume(chatId, cmd.args); break;
|
|
159
|
+
case "status": await this.cmdStatus(chatId); break;
|
|
160
|
+
case "stop": await this.cmdStop(chatId); break;
|
|
161
|
+
case "memory": await this.cmdMemory(chatId); break;
|
|
162
|
+
case "forget": await this.cmdForget(chatId, cmd.args); break;
|
|
163
|
+
case "remember": await this.cmdRemember(chatId, cmd.args); break;
|
|
164
|
+
case "help": await this.cmdHelp(chatId); break;
|
|
165
|
+
default: await tg.sendMessage(Number(chatId), `Unknown command: /${cmd.command}`);
|
|
166
|
+
}
|
|
167
|
+
} catch (err) {
|
|
168
|
+
await tg.sendMessage(
|
|
169
|
+
Number(chatId),
|
|
170
|
+
`❌ Command error: ${escapeHtml((err as Error).message)}`,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
private async cmdStart(chatId: string): Promise<void> {
|
|
176
|
+
const projects = this.sessions.getProjectNames();
|
|
177
|
+
let text = "<b>🤖 ClawBot</b>\n\nI'm your PPM assistant on Telegram.\n\n";
|
|
178
|
+
if (projects.length) {
|
|
179
|
+
text += "<b>Available projects:</b>\n";
|
|
180
|
+
for (const name of projects) {
|
|
181
|
+
text += `• <code>${escapeHtml(name)}</code>\n`;
|
|
182
|
+
}
|
|
183
|
+
text += "\nSwitch project: /project <name>";
|
|
184
|
+
} else {
|
|
185
|
+
text += "No projects configured. Add projects in PPM settings.";
|
|
186
|
+
}
|
|
187
|
+
text += "\n\nSend /help for all commands.";
|
|
188
|
+
await this.telegram!.sendMessage(Number(chatId), text);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
private async cmdProject(chatId: string, args: string): Promise<void> {
|
|
192
|
+
if (!args) {
|
|
193
|
+
const active = this.sessions.getActiveSession(chatId);
|
|
194
|
+
const current = active?.projectName ?? "(none)";
|
|
195
|
+
await this.telegram!.sendMessage(
|
|
196
|
+
Number(chatId),
|
|
197
|
+
`Current project: <b>${escapeHtml(current)}</b>\n\nUsage: /project <name>`,
|
|
198
|
+
);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
await this.saveSessionMemory(chatId);
|
|
203
|
+
const session = await this.sessions.switchProject(chatId, args);
|
|
204
|
+
await this.telegram!.sendMessage(
|
|
205
|
+
Number(chatId),
|
|
206
|
+
`Switched to <b>${escapeHtml(session.projectName)}</b> ✓`,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
private async cmdNew(chatId: string): Promise<void> {
|
|
211
|
+
await this.saveSessionMemory(chatId);
|
|
212
|
+
const active = this.sessions.getActiveSession(chatId);
|
|
213
|
+
const projectName = active?.projectName;
|
|
214
|
+
await this.sessions.closeSession(chatId);
|
|
215
|
+
const session = await this.sessions.getOrCreateSession(chatId, projectName ?? undefined);
|
|
216
|
+
await this.telegram!.sendMessage(
|
|
217
|
+
Number(chatId),
|
|
218
|
+
`New session for <b>${escapeHtml(session.projectName)}</b> ✓`,
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
private async cmdSessions(chatId: string): Promise<void> {
|
|
223
|
+
const sessions = this.sessions.listRecentSessions(chatId, 10);
|
|
224
|
+
if (sessions.length === 0) {
|
|
225
|
+
await this.telegram!.sendMessage(Number(chatId), "No recent sessions.");
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
let text = "<b>Recent Sessions</b>\n\n";
|
|
229
|
+
sessions.forEach((s, i) => {
|
|
230
|
+
const active = s.is_active ? " ⬤" : "";
|
|
231
|
+
const date = new Date(s.last_message_at * 1000).toLocaleDateString();
|
|
232
|
+
text += `${i + 1}. <code>${escapeHtml(s.project_name)}</code> — ${date}${active}\n`;
|
|
233
|
+
});
|
|
234
|
+
text += "\nResume: /resume <number>";
|
|
235
|
+
await this.telegram!.sendMessage(Number(chatId), text);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
private async cmdResume(chatId: string, args: string): Promise<void> {
|
|
239
|
+
const index = parseInt(args, 10);
|
|
240
|
+
if (!index || index < 1) {
|
|
241
|
+
await this.telegram!.sendMessage(Number(chatId), "Usage: /resume <number>");
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
await this.saveSessionMemory(chatId);
|
|
245
|
+
const session = await this.sessions.resumeSessionById(chatId, index);
|
|
246
|
+
if (!session) {
|
|
247
|
+
await this.telegram!.sendMessage(Number(chatId), "Session not found.");
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
await this.telegram!.sendMessage(
|
|
251
|
+
Number(chatId),
|
|
252
|
+
`Resumed session for <b>${escapeHtml(session.projectName)}</b> ✓`,
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
private async cmdStatus(chatId: string): Promise<void> {
|
|
257
|
+
const active = this.sessions.getActiveSession(chatId);
|
|
258
|
+
if (!active) {
|
|
259
|
+
await this.telegram!.sendMessage(Number(chatId), "No active session. Send a message to start.");
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
let text = "<b>Status</b>\n\n";
|
|
263
|
+
text += `Project: <code>${escapeHtml(active.projectName)}</code>\n`;
|
|
264
|
+
text += `Provider: <code>${escapeHtml(active.providerId)}</code>\n`;
|
|
265
|
+
text += `Session: <code>${active.sessionId.slice(0, 12)}…</code>\n`;
|
|
266
|
+
await this.telegram!.sendMessage(Number(chatId), text);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
private async cmdStop(chatId: string): Promise<void> {
|
|
270
|
+
await this.saveSessionMemory(chatId);
|
|
271
|
+
await this.sessions.closeSession(chatId);
|
|
272
|
+
await this.telegram!.sendMessage(Number(chatId), "Session ended ✓");
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
private async cmdMemory(chatId: string): Promise<void> {
|
|
276
|
+
const active = this.sessions.getActiveSession(chatId);
|
|
277
|
+
const project = active?.projectName ?? "_global";
|
|
278
|
+
const memories = this.memory.getSummary(project);
|
|
279
|
+
if (memories.length === 0) {
|
|
280
|
+
await this.telegram!.sendMessage(Number(chatId), "No memories stored for this project.");
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
let text = `<b>Memory — ${escapeHtml(project)}</b>\n\n`;
|
|
284
|
+
for (const mem of memories) {
|
|
285
|
+
text += `• [${mem.category}] ${escapeHtml(mem.content)}\n`;
|
|
286
|
+
}
|
|
287
|
+
await this.telegram!.sendMessage(Number(chatId), text);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
private async cmdForget(chatId: string, args: string): Promise<void> {
|
|
291
|
+
if (!args) {
|
|
292
|
+
await this.telegram!.sendMessage(Number(chatId), "Usage: /forget <topic>");
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
const active = this.sessions.getActiveSession(chatId);
|
|
296
|
+
const project = active?.projectName ?? "_global";
|
|
297
|
+
const count = this.memory.forget(project, args);
|
|
298
|
+
await this.telegram!.sendMessage(Number(chatId), `Forgot ${count} memor${count === 1 ? "y" : "ies"} ✓`);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
private async cmdRemember(chatId: string, args: string): Promise<void> {
|
|
302
|
+
if (!args) {
|
|
303
|
+
await this.telegram!.sendMessage(Number(chatId), "Usage: /remember <fact>");
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
const active = this.sessions.getActiveSession(chatId);
|
|
307
|
+
const project = active?.projectName ?? "_global";
|
|
308
|
+
this.memory.saveOne(project, args, "fact", active?.sessionId);
|
|
309
|
+
await this.telegram!.sendMessage(Number(chatId), "Remembered ✓");
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
private async cmdHelp(chatId: string): Promise<void> {
|
|
313
|
+
const text = `<b>ClawBot Commands</b>
|
|
314
|
+
|
|
315
|
+
/start — Greeting + list projects
|
|
316
|
+
/project <name> — Switch project
|
|
317
|
+
/new — Fresh session (current project)
|
|
318
|
+
/sessions — List recent sessions
|
|
319
|
+
/resume <n> — Resume session #n
|
|
320
|
+
/status — Current project/session info
|
|
321
|
+
/stop — End current session
|
|
322
|
+
/memory — Show project memories
|
|
323
|
+
/forget <topic> — Remove matching memories
|
|
324
|
+
/remember <fact> — Save a fact
|
|
325
|
+
/help — This message`;
|
|
326
|
+
await this.telegram!.sendMessage(Number(chatId), text);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// ── Chat Message Pipeline ───────────────────────────────────────
|
|
330
|
+
|
|
331
|
+
private async handleMessage(chatId: string, text: string): Promise<void> {
|
|
332
|
+
const config = this.getConfig();
|
|
333
|
+
const debounceMs = config?.debounce_ms ?? 2000;
|
|
334
|
+
|
|
335
|
+
const existing = this.debouncedTexts.get(chatId) ?? "";
|
|
336
|
+
const merged = existing ? `${existing}\n${text}` : text;
|
|
337
|
+
this.debouncedTexts.set(chatId, merged.length > 10000 ? merged.slice(0, 10000) : merged);
|
|
338
|
+
|
|
339
|
+
const prevTimer = this.debounceTimers.get(chatId);
|
|
340
|
+
if (prevTimer) clearTimeout(prevTimer);
|
|
341
|
+
|
|
342
|
+
this.debounceTimers.set(
|
|
343
|
+
chatId,
|
|
344
|
+
setTimeout(() => this.processMessage(chatId), debounceMs),
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
private async processMessage(chatId: string): Promise<void> {
|
|
349
|
+
this.debounceTimers.delete(chatId);
|
|
350
|
+
const text = this.debouncedTexts.get(chatId) ?? "";
|
|
351
|
+
this.debouncedTexts.delete(chatId);
|
|
352
|
+
if (!text.trim()) return;
|
|
353
|
+
|
|
354
|
+
// Queue if already processing
|
|
355
|
+
if (this.processing.has(chatId)) {
|
|
356
|
+
const queue = this.messageQueue.get(chatId) ?? [];
|
|
357
|
+
queue.push(text);
|
|
358
|
+
this.messageQueue.set(chatId, queue);
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
this.processing.add(chatId);
|
|
362
|
+
|
|
363
|
+
try {
|
|
364
|
+
const config = this.getConfig();
|
|
365
|
+
|
|
366
|
+
const session = await this.sessions.getOrCreateSession(chatId);
|
|
367
|
+
|
|
368
|
+
// Update title on first message only
|
|
369
|
+
if (!this.titledSessions.has(session.sessionId)) {
|
|
370
|
+
this.sessions.updateSessionTitle(session.sessionId, text);
|
|
371
|
+
this.titledSessions.add(session.sessionId);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Recall memories (with cross-project detection)
|
|
375
|
+
const memories = this.memory.recallWithCrossProject(
|
|
376
|
+
session.projectName,
|
|
377
|
+
text,
|
|
378
|
+
text,
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
// Build system prompt with memory
|
|
382
|
+
let systemPrompt = config?.system_prompt ?? "";
|
|
383
|
+
const memorySection = this.memory.buildRecallPrompt(memories);
|
|
384
|
+
if (memorySection) {
|
|
385
|
+
systemPrompt += memorySection;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Send message to AI (prepend system prompt + memory context)
|
|
389
|
+
const opts: SendMessageOpts = {
|
|
390
|
+
permissionMode: (config?.permission_mode ?? "bypassPermissions") as PermissionMode,
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
let fullMessage = text;
|
|
394
|
+
if (systemPrompt) {
|
|
395
|
+
fullMessage = `<system-context>\n${systemPrompt}\n</system-context>\n\n${text}`;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const events = chatService.sendMessage(
|
|
399
|
+
session.providerId,
|
|
400
|
+
session.sessionId,
|
|
401
|
+
fullMessage,
|
|
402
|
+
opts,
|
|
403
|
+
);
|
|
404
|
+
|
|
405
|
+
// Stream response to Telegram
|
|
406
|
+
const result = await streamToTelegram(
|
|
407
|
+
Number(chatId),
|
|
408
|
+
events,
|
|
409
|
+
this.telegram!,
|
|
410
|
+
{
|
|
411
|
+
showToolCalls: config?.show_tool_calls ?? true,
|
|
412
|
+
showThinking: config?.show_thinking ?? false,
|
|
413
|
+
},
|
|
414
|
+
);
|
|
415
|
+
|
|
416
|
+
// Check context window — auto-rotate if near limit
|
|
417
|
+
if (
|
|
418
|
+
result.contextWindowPct != null &&
|
|
419
|
+
result.contextWindowPct > CONTEXT_WINDOW_THRESHOLD
|
|
420
|
+
) {
|
|
421
|
+
await this.rotateSession(chatId, session.projectName);
|
|
422
|
+
}
|
|
423
|
+
} catch (err) {
|
|
424
|
+
console.error(`[clawbot] processMessage error for ${chatId}:`, (err as Error).message);
|
|
425
|
+
await this.telegram?.sendMessage(
|
|
426
|
+
Number(chatId),
|
|
427
|
+
`❌ ${escapeHtml((err as Error).message)}`,
|
|
428
|
+
);
|
|
429
|
+
} finally {
|
|
430
|
+
this.processing.delete(chatId);
|
|
431
|
+
|
|
432
|
+
// Process queued messages
|
|
433
|
+
const queued = this.messageQueue.get(chatId);
|
|
434
|
+
if (queued && queued.length > 0) {
|
|
435
|
+
this.messageQueue.delete(chatId);
|
|
436
|
+
const merged = queued.join("\n\n");
|
|
437
|
+
this.handleMessage(chatId, merged);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// ── Memory Save / Session Rotate ────────────────────────────────
|
|
443
|
+
|
|
444
|
+
private async saveSessionMemory(chatId: string): Promise<void> {
|
|
445
|
+
const session = this.sessions.getActiveSession(chatId);
|
|
446
|
+
if (!session) return;
|
|
447
|
+
|
|
448
|
+
try {
|
|
449
|
+
const extractionPrompt = this.memory.buildExtractionPrompt();
|
|
450
|
+
const events = chatService.sendMessage(
|
|
451
|
+
session.providerId,
|
|
452
|
+
session.sessionId,
|
|
453
|
+
extractionPrompt,
|
|
454
|
+
{ permissionMode: "bypassPermissions" },
|
|
455
|
+
);
|
|
456
|
+
|
|
457
|
+
let responseText = "";
|
|
458
|
+
for await (const event of events) {
|
|
459
|
+
if (event.type === "text") responseText += event.content;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const facts = this.memory.parseExtractionResponse(responseText);
|
|
463
|
+
if (facts.length > 0) {
|
|
464
|
+
const count = this.memory.save(session.projectName, facts, session.sessionId);
|
|
465
|
+
console.log(`[clawbot] Saved ${count} memories for ${session.projectName}`);
|
|
466
|
+
} else {
|
|
467
|
+
// Fallback: regex-based extraction
|
|
468
|
+
// Note: we don't have conversation history text here easily,
|
|
469
|
+
// so regex fallback only triggers when AI extraction fails
|
|
470
|
+
console.log("[clawbot] No memories extracted via AI");
|
|
471
|
+
}
|
|
472
|
+
} catch (err) {
|
|
473
|
+
console.warn("[clawbot] Memory save failed:", (err as Error).message);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
private async rotateSession(chatId: string, projectName: string): Promise<void> {
|
|
478
|
+
await this.saveSessionMemory(chatId);
|
|
479
|
+
await this.sessions.closeSession(chatId);
|
|
480
|
+
await this.sessions.getOrCreateSession(chatId, projectName);
|
|
481
|
+
await this.telegram?.sendMessage(
|
|
482
|
+
Number(chatId),
|
|
483
|
+
"<i>Context window near limit — starting fresh session. Memories saved.</i>",
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// ── Helpers ─────────────────────────────────────────────────────
|
|
488
|
+
|
|
489
|
+
private generatePairingCode(): string {
|
|
490
|
+
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // no I,O,0,1 (ambiguous)
|
|
491
|
+
const bytes = crypto.getRandomValues(new Uint8Array(6));
|
|
492
|
+
return Array.from(bytes, (b) => chars[b % chars.length]).join("");
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
private getConfig(): ClawBotConfig | undefined {
|
|
496
|
+
return configService.get("clawbot") as ClawBotConfig | undefined;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
export const clawbotService = new ClawBotService();
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { chatService } from "../chat.service.ts";
|
|
2
|
+
import { configService } from "../config.service.ts";
|
|
3
|
+
import {
|
|
4
|
+
getActiveClawBotSession,
|
|
5
|
+
createClawBotSession,
|
|
6
|
+
deactivateClawBotSession,
|
|
7
|
+
touchClawBotSession,
|
|
8
|
+
getRecentClawBotSessions,
|
|
9
|
+
setSessionTitle,
|
|
10
|
+
} from "../db.service.ts";
|
|
11
|
+
import type { ClawBotActiveSession, ClawBotSessionRow } from "../../types/clawbot.ts";
|
|
12
|
+
import type { ClawBotConfig, ProjectConfig } from "../../types/config.ts";
|
|
13
|
+
|
|
14
|
+
export class ClawBotSessionManager {
|
|
15
|
+
/** In-memory cache: telegramChatId → active session */
|
|
16
|
+
private activeSessions = new Map<string, ClawBotActiveSession>();
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Get active session for chatId. If none exists, create one for the
|
|
20
|
+
* given project (or default project from config).
|
|
21
|
+
*/
|
|
22
|
+
async getOrCreateSession(
|
|
23
|
+
chatId: string,
|
|
24
|
+
projectName?: string,
|
|
25
|
+
): Promise<ClawBotActiveSession> {
|
|
26
|
+
const cached = this.activeSessions.get(chatId);
|
|
27
|
+
if (cached && (!projectName || cached.projectName === projectName)) {
|
|
28
|
+
touchClawBotSession(cached.sessionId);
|
|
29
|
+
return cached;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const resolvedProject = this.resolveProject(
|
|
33
|
+
projectName || this.getDefaultProject(),
|
|
34
|
+
);
|
|
35
|
+
if (!resolvedProject) {
|
|
36
|
+
throw new Error(`Project not found: "${projectName || "(default)"}"`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const dbSession = getActiveClawBotSession(chatId, resolvedProject.name);
|
|
40
|
+
if (dbSession) {
|
|
41
|
+
return this.resumeFromDb(chatId, dbSession, resolvedProject);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return this.createNewSession(chatId, resolvedProject);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Switch to a different project. Deactivates current session. */
|
|
48
|
+
async switchProject(
|
|
49
|
+
chatId: string,
|
|
50
|
+
projectName: string,
|
|
51
|
+
): Promise<ClawBotActiveSession> {
|
|
52
|
+
await this.closeSession(chatId);
|
|
53
|
+
return this.getOrCreateSession(chatId, projectName);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Close (deactivate) the current session for a chatId */
|
|
57
|
+
async closeSession(chatId: string): Promise<void> {
|
|
58
|
+
const active = this.activeSessions.get(chatId);
|
|
59
|
+
if (active) {
|
|
60
|
+
deactivateClawBotSession(active.sessionId);
|
|
61
|
+
this.activeSessions.delete(chatId);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Get active session from cache (no DB hit) */
|
|
66
|
+
getActiveSession(chatId: string): ClawBotActiveSession | null {
|
|
67
|
+
return this.activeSessions.get(chatId) ?? null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** List recent sessions for a chat (from DB) */
|
|
71
|
+
listRecentSessions(chatId: string, limit = 10): ClawBotSessionRow[] {
|
|
72
|
+
return getRecentClawBotSessions(chatId, limit);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Resume a specific session by 1-indexed position in history */
|
|
76
|
+
async resumeSessionById(
|
|
77
|
+
chatId: string,
|
|
78
|
+
sessionIndex: number,
|
|
79
|
+
): Promise<ClawBotActiveSession | null> {
|
|
80
|
+
const sessions = getRecentClawBotSessions(chatId, 20);
|
|
81
|
+
const target = sessions[sessionIndex - 1];
|
|
82
|
+
if (!target) return null;
|
|
83
|
+
|
|
84
|
+
await this.closeSession(chatId);
|
|
85
|
+
|
|
86
|
+
const project = this.resolveProject(target.project_name);
|
|
87
|
+
if (!project) return null;
|
|
88
|
+
|
|
89
|
+
return this.resumeFromDb(chatId, target, project);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Resolve a project name against configured projects.
|
|
94
|
+
* Case-insensitive, supports prefix matching.
|
|
95
|
+
*/
|
|
96
|
+
resolveProject(input: string): { name: string; path: string } | null {
|
|
97
|
+
const projects = configService.get("projects") as ProjectConfig[];
|
|
98
|
+
if (!projects?.length) return null;
|
|
99
|
+
|
|
100
|
+
const lower = input.toLowerCase();
|
|
101
|
+
|
|
102
|
+
const exact = projects.find((p) => p.name.toLowerCase() === lower);
|
|
103
|
+
if (exact) return { name: exact.name, path: exact.path };
|
|
104
|
+
|
|
105
|
+
const prefix = projects.filter((p) => p.name.toLowerCase().startsWith(lower));
|
|
106
|
+
if (prefix.length === 1) return { name: prefix[0]!.name, path: prefix[0]!.path };
|
|
107
|
+
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Update session title (e.g. after first message) */
|
|
112
|
+
updateSessionTitle(sessionId: string, firstMessage: string): void {
|
|
113
|
+
const preview = firstMessage.slice(0, 60).replace(/\n/g, " ");
|
|
114
|
+
const title = `[Claw] ${preview}`;
|
|
115
|
+
setSessionTitle(sessionId, title);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Get list of available project names (for /start greeting) */
|
|
119
|
+
getProjectNames(): string[] {
|
|
120
|
+
const projects = configService.get("projects") as ProjectConfig[];
|
|
121
|
+
return projects?.map((p) => p.name) ?? [];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ── Private ─────────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
private getDefaultProject(): string {
|
|
127
|
+
const clawbot = configService.get("clawbot") as ClawBotConfig | undefined;
|
|
128
|
+
return clawbot?.default_project || "";
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private getDefaultProvider(): string {
|
|
132
|
+
const clawbot = configService.get("clawbot") as ClawBotConfig | undefined;
|
|
133
|
+
return clawbot?.default_provider || configService.get("ai").default_provider;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private async createNewSession(
|
|
137
|
+
chatId: string,
|
|
138
|
+
project: { name: string; path: string },
|
|
139
|
+
): Promise<ClawBotActiveSession> {
|
|
140
|
+
const providerId = this.getDefaultProvider();
|
|
141
|
+
|
|
142
|
+
const session = await chatService.createSession(providerId, {
|
|
143
|
+
projectName: project.name,
|
|
144
|
+
projectPath: project.path,
|
|
145
|
+
title: `[Claw] New session`,
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
createClawBotSession(chatId, session.id, providerId, project.name, project.path);
|
|
149
|
+
|
|
150
|
+
const active: ClawBotActiveSession = {
|
|
151
|
+
telegramChatId: chatId,
|
|
152
|
+
sessionId: session.id,
|
|
153
|
+
providerId,
|
|
154
|
+
projectName: project.name,
|
|
155
|
+
projectPath: project.path,
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
this.activeSessions.set(chatId, active);
|
|
159
|
+
return active;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private async resumeFromDb(
|
|
163
|
+
chatId: string,
|
|
164
|
+
dbSession: ClawBotSessionRow,
|
|
165
|
+
project: { name: string; path: string },
|
|
166
|
+
): Promise<ClawBotActiveSession> {
|
|
167
|
+
try {
|
|
168
|
+
await chatService.resumeSession(dbSession.provider_id, dbSession.session_id);
|
|
169
|
+
} catch {
|
|
170
|
+
console.warn(`[clawbot] Failed to resume session ${dbSession.session_id}, creating new`);
|
|
171
|
+
deactivateClawBotSession(dbSession.session_id);
|
|
172
|
+
return this.createNewSession(chatId, project);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
touchClawBotSession(dbSession.session_id);
|
|
176
|
+
|
|
177
|
+
const active: ClawBotActiveSession = {
|
|
178
|
+
telegramChatId: chatId,
|
|
179
|
+
sessionId: dbSession.session_id,
|
|
180
|
+
providerId: dbSession.provider_id,
|
|
181
|
+
projectName: project.name,
|
|
182
|
+
projectPath: project.path,
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
this.activeSessions.set(chatId, active);
|
|
186
|
+
return active;
|
|
187
|
+
}
|
|
188
|
+
}
|