@hienlh/ppm 0.9.53 → 0.9.55

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.
Files changed (53) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/web/assets/{chat-tab-DvNEQYEe.js → chat-tab-SfXtOm9d.js} +1 -1
  3. package/dist/web/assets/{code-editor-CoT017Ah.js → code-editor-DAZvtAlT.js} +1 -1
  4. package/dist/web/assets/database-viewer-C5fco1jm.js +1 -0
  5. package/dist/web/assets/{diff-viewer-D0tuen4I.js → diff-viewer-ShRSPvsf.js} +1 -1
  6. package/dist/web/assets/{extension-webview-Ba5aeo9r.js → extension-webview-CWJRMPfV.js} +1 -1
  7. package/dist/web/assets/{git-graph-BnJrVPxJ.js → git-graph-h0QmXMdZ.js} +1 -1
  8. package/dist/web/assets/{index-DUQgLj0D.js → index-CDlrGSwd.js} +4 -4
  9. package/dist/web/assets/{index-BEfMoc_W.css → index-DVuSY0BZ.css} +1 -1
  10. package/dist/web/assets/keybindings-store-wbHg-S_v.js +1 -0
  11. package/dist/web/assets/{markdown-renderer-BuGSrE3y.js → markdown-renderer-CSEmmMWt.js} +1 -1
  12. package/dist/web/assets/{port-forwarding-tab-DsbrWNUP.js → port-forwarding-tab-Cts6tMFn.js} +1 -1
  13. package/dist/web/assets/{postgres-viewer-Bh6YmZPq.js → postgres-viewer-CiQC1sf9.js} +1 -1
  14. package/dist/web/assets/{settings-tab-BnzFtexC.js → settings-tab-CQx6aHtO.js} +1 -1
  15. package/dist/web/assets/sqlite-viewer-FQfCkjU6.js +1 -0
  16. package/dist/web/assets/{terminal-tab-fnZvscaH.js → terminal-tab-C2SnOqxn.js} +1 -1
  17. package/dist/web/assets/{use-monaco-theme-BdcKAZ69.js → use-monaco-theme-VPgvhMpB.js} +1 -1
  18. package/dist/web/index.html +2 -2
  19. package/dist/web/sw.js +1 -1
  20. package/docs/codebase-summary.md +52 -13
  21. package/docs/project-changelog.md +45 -1
  22. package/docs/project-roadmap.md +1 -1
  23. package/docs/system-architecture.md +121 -9
  24. package/package.json +1 -1
  25. package/src/cli/commands/bot-cmd.ts +144 -240
  26. package/src/server/routes/database.ts +31 -0
  27. package/src/server/routes/settings.ts +13 -0
  28. package/src/server/routes/sqlite.ts +14 -0
  29. package/src/services/database/postgres-adapter.ts +8 -0
  30. package/src/services/database/sqlite-adapter.ts +5 -0
  31. package/src/services/db.service.ts +109 -1
  32. package/src/services/postgres.service.ts +12 -0
  33. package/src/services/ppmbot/ppmbot-delegation.ts +112 -0
  34. package/src/services/ppmbot/ppmbot-service.ts +194 -369
  35. package/src/services/ppmbot/ppmbot-session.ts +85 -108
  36. package/src/services/ppmbot/ppmbot-telegram.ts +5 -16
  37. package/src/services/sqlite.service.ts +10 -0
  38. package/src/types/config.ts +1 -3
  39. package/src/types/database.ts +3 -0
  40. package/src/types/ppmbot.ts +21 -0
  41. package/src/web/components/database/database-viewer.tsx +50 -8
  42. package/src/web/components/database/use-database.ts +13 -1
  43. package/src/web/components/settings/ppmbot-settings-section.tsx +87 -26
  44. package/src/web/components/sqlite/sqlite-data-grid.tsx +55 -8
  45. package/src/web/components/sqlite/sqlite-viewer.tsx +1 -0
  46. package/src/web/components/sqlite/use-sqlite.ts +16 -1
  47. package/dist/web/assets/database-viewer-C3wK7cDk.js +0 -1
  48. package/dist/web/assets/keybindings-store-CkGFjxkX.js +0 -1
  49. package/dist/web/assets/sqlite-viewer-Cu3_hf07.js +0 -1
  50. package/docs/streaming-input-guide.md +0 -267
  51. package/snapshot-state.md +0 -1526
  52. package/test-session-ops.mjs +0 -444
  53. package/test-tokens.mjs +0 -212
@@ -1,20 +1,26 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { homedir } from "node:os";
1
4
  import { configService } from "../config.service.ts";
2
5
  import { chatService } from "../chat.service.ts";
3
6
  import {
4
7
  isPairedChat,
5
8
  getPairingByChatId,
6
9
  createPairingRequest,
7
- getSessionTitles,
8
- getPinnedSessionIds,
9
10
  getApprovedPairedChats,
11
+ getRecentBotTasks,
12
+ getRunningBotTasks,
13
+ updateBotTaskStatus,
14
+ markBotTaskReported,
10
15
  } from "../db.service.ts";
11
16
  import { PPMBotTelegram } from "./ppmbot-telegram.ts";
12
- import { PPMBotSessionManager } from "./ppmbot-session.ts";
17
+ import { PPMBotSessionManager, ensureCoordinatorWorkspace, DEFAULT_COORDINATOR_IDENTITY } from "./ppmbot-session.ts";
13
18
  import { PPMBotMemory } from "./ppmbot-memory.ts";
14
19
  import { streamToTelegram } from "./ppmbot-streamer.ts";
15
20
  import { escapeHtml } from "./ppmbot-formatter.ts";
21
+ import { executeDelegation, getActiveDelegationCount } from "./ppmbot-delegation.ts";
16
22
  import type { TelegramUpdate, PPMBotCommand } from "../../types/ppmbot.ts";
17
- import type { PPMBotConfig, TelegramConfig, PermissionMode } from "../../types/config.ts";
23
+ import type { PPMBotConfig, TelegramConfig, ProjectConfig, PermissionMode } from "../../types/config.ts";
18
24
  import type { SendMessageOpts } from "../../types/chat.ts";
19
25
 
20
26
  const CONTEXT_WINDOW_THRESHOLD = 80;
@@ -25,6 +31,12 @@ class PPMBotService {
25
31
  private memory = new PPMBotMemory();
26
32
  private running = false;
27
33
 
34
+ /** Cached coordinator identity from coordinator.md */
35
+ private coordinatorIdentity = "";
36
+
37
+ /** Task polling interval */
38
+ private taskPoller: ReturnType<typeof setInterval> | null = null;
39
+
28
40
  /** Debounce timers per chatId */
29
41
  private debounceTimers = new Map<string, ReturnType<typeof setTimeout>>();
30
42
  private debouncedTexts = new Map<string, string>();
@@ -35,12 +47,6 @@ class PPMBotService {
35
47
  /** Message queue per chatId for concurrent messages */
36
48
  private messageQueue = new Map<string, string[]>();
37
49
 
38
- /** Sessions that already had their title set */
39
- private titledSessions = new Set<string>();
40
-
41
- /** Chat IDs that just received identity onboarding prompt */
42
- private identityPending = new Set<string>();
43
-
44
50
  // ── Lifecycle ─────────────────────────────────────────────────
45
51
 
46
52
  async start(): Promise<void> {
@@ -57,13 +63,17 @@ class PPMBotService {
57
63
  }
58
64
 
59
65
  try {
66
+ ensureCoordinatorWorkspace();
67
+
60
68
  this.telegram = new PPMBotTelegram(telegramConfig.bot_token);
61
69
  this.running = true;
62
70
 
63
- // Start polling (non-blocking)
64
71
  this.telegram.startPolling((update) => this.handleUpdate(update));
65
72
 
66
- // Check if this is a restart and notify users
73
+ // Task poller for delegation execution
74
+ this.taskPoller = setInterval(() => this.checkPendingTasks(), 5000);
75
+ this.cleanupStaleTasks();
76
+
67
77
  await this.checkRestartNotification();
68
78
 
69
79
  console.log("[ppmbot] Started");
@@ -77,12 +87,16 @@ class PPMBotService {
77
87
  this.telegram?.stop();
78
88
  this.telegram = null;
79
89
 
90
+ if (this.taskPoller) {
91
+ clearInterval(this.taskPoller);
92
+ this.taskPoller = null;
93
+ }
94
+
80
95
  for (const timer of this.debounceTimers.values()) clearTimeout(timer);
81
96
  this.debounceTimers.clear();
82
97
  this.debouncedTexts.clear();
83
98
  this.processing.clear();
84
99
  this.messageQueue.clear();
85
- this.identityPending.clear();
86
100
 
87
101
  console.log("[ppmbot] Stopped");
88
102
  }
@@ -114,7 +128,6 @@ class PPMBotService {
114
128
  if (!isPairedChat(chatId)) {
115
129
  const pairing = getPairingByChatId(chatId);
116
130
  if (!pairing) {
117
- // First-time user — generate pairing code
118
131
  const code = this.generatePairingCode();
119
132
  createPairingRequest(chatId, String(userId), displayName, code);
120
133
  await this.telegram!.sendMessage(
@@ -130,26 +143,22 @@ class PPMBotService {
130
143
  );
131
144
  return;
132
145
  }
133
- if (pairing.status === "revoked") {
134
- return; // Silently ignore
135
- }
146
+ if (pairing.status === "revoked") return;
136
147
  }
137
148
 
138
- // Try parsing as command
139
149
  const command = PPMBotTelegram.parseCommand(message);
140
150
  if (command) {
141
151
  await this.handleCommand(command);
142
152
  return;
143
153
  }
144
154
 
145
- // Regular message
146
155
  const text = message.text ?? message.caption ?? "";
147
156
  if (!text.trim()) return;
148
157
 
149
158
  await this.handleMessage(chatId, text);
150
159
  }
151
160
 
152
- // ── Command Handlers ────────────────────────────────────────────
161
+ // ── Command Handlers (3 commands + hidden restart) ──────────────
153
162
 
154
163
  private async handleCommand(cmd: PPMBotCommand): Promise<void> {
155
164
  const chatId = String(cmd.chatId);
@@ -158,19 +167,10 @@ class PPMBotService {
158
167
  try {
159
168
  switch (cmd.command) {
160
169
  case "start": await this.cmdStart(chatId); break;
161
- case "project": await this.cmdProject(chatId, cmd.args); break;
162
- case "new": await this.cmdNew(chatId); break;
163
- case "sessions": await this.cmdSessions(chatId, cmd.args); break;
164
- case "resume": await this.cmdResume(chatId, cmd.args); break;
165
170
  case "status": await this.cmdStatus(chatId); break;
166
- case "stop": await this.cmdStop(chatId); break;
167
- case "memory": await this.cmdMemory(chatId); break;
168
- case "forget": await this.cmdForget(chatId, cmd.args); break;
169
- case "remember": await this.cmdRemember(chatId, cmd.args); break;
170
171
  case "restart": await this.cmdRestart(chatId); break;
171
- case "version": await this.cmdVersion(chatId); break;
172
172
  case "help": await this.cmdHelp(chatId); break;
173
- default: await tg.sendMessage(Number(chatId), `Unknown command: /${cmd.command}`);
173
+ default: await tg.sendMessage(Number(chatId), `Just chat naturally — I'll handle it! Try /help`);
174
174
  }
175
175
  } catch (err) {
176
176
  await tg.sendMessage(
@@ -182,348 +182,189 @@ class PPMBotService {
182
182
 
183
183
  private async cmdStart(chatId: string): Promise<void> {
184
184
  const projects = this.sessions.getProjectNames();
185
- let text = "<b>🤖 PPMBot</b>\n\n";
186
- text += "Hey! I'm your AI coding assistant, right here in Telegram.\n";
187
- text += "Ask me anything — code questions, debugging, project tasks.\n\n";
185
+ let text = "<b>🤖 PPMBot Coordinator</b>\n\n";
186
+ text += "I'm your AI project coordinator on Telegram.\n";
187
+ text += "Ask me anything — I'll answer directly or delegate to your projects.\n\n";
188
188
  if (projects.length) {
189
189
  text += "<b>Your projects:</b>\n";
190
190
  for (const name of projects) {
191
191
  text += ` • <code>${escapeHtml(name)}</code>\n`;
192
192
  }
193
- text += "\nSwitch: /project &lt;name&gt;";
194
- } else {
195
- text += "No projects configured — I'll use a default workspace.";
196
193
  }
197
- text += "\n\nJust send a message to start chatting, or /help for commands.";
194
+ text += "\nJust chat naturally no commands needed!";
195
+ text += "\nType /help for more info.";
198
196
  await this.telegram!.sendMessage(Number(chatId), text);
199
197
 
200
- // Identity onboarding: if no identity memories exist, ask user
198
+ // Identity onboarding
201
199
  const globalMemories = this.memory.getSummary("_global", 50);
202
200
  const hasIdentity = globalMemories.some((m) =>
203
201
  m.category === "preference" && /identity|name|role/i.test(m.content),
204
202
  );
205
203
  if (!hasIdentity) {
206
- this.identityPending.add(chatId);
207
204
  await this.telegram!.sendMessage(
208
205
  Number(chatId),
209
206
  "📝 <b>Quick intro?</b>\n\n" +
210
- "I don't know much about you yet! Tell me:\n" +
211
- " Your name\n" +
212
- "• What you work on (language, stack, role)\n" +
213
- "• Preferred response language (English, Vietnamese, etc.)\n\n" +
214
- "I'll remember your preferences for future chats.\n" +
215
- "Or skip this and just start chatting!",
207
+ "Tell me your name, what you work on, and preferred language.\n" +
208
+ "I'll remember for future chats. Or just start chatting!",
216
209
  );
217
210
  }
218
211
  }
219
212
 
220
- private async cmdProject(chatId: string, args: string): Promise<void> {
221
- if (!args) {
222
- const active = this.sessions.getActiveSession(chatId);
223
- const current = active?.projectName ?? "";
224
- const projects = this.sessions.getProjectNames();
225
- let text = "<b>Projects</b>\n\n";
226
- if (projects.length === 0) {
227
- text += "No projects configured.\nUsing default: <code>~/.ppm/bot/</code>\n";
228
- } else {
229
- for (const name of projects) {
230
- const marker = name === current ? " ✓" : "";
231
- text += `• <code>${escapeHtml(name)}</code>${marker}\n`;
232
- }
233
- }
234
- text += `\nCurrent: <b>${escapeHtml(current || "bot (default)")}</b>`;
235
- text += "\nSwitch: /project &lt;name&gt;";
236
- await this.telegram!.sendMessage(Number(chatId), text);
237
- return;
238
- }
239
-
240
- const session = await this.sessions.switchProject(chatId, args);
241
- await this.telegram!.sendMessage(
242
- Number(chatId),
243
- `Switched to <b>${escapeHtml(session.projectName)}</b> ✓`,
244
- );
245
- }
246
-
247
- private async cmdNew(chatId: string): Promise<void> {
248
- const active = this.sessions.getActiveSession(chatId);
249
- const projectName = active?.projectName;
250
- await this.sessions.closeSession(chatId);
251
- const session = await this.sessions.getOrCreateSession(chatId, projectName ?? undefined);
252
- await this.telegram!.sendMessage(
253
- Number(chatId),
254
- `New session for <b>${escapeHtml(session.projectName)}</b> ✓`,
255
- );
256
- }
257
-
258
- private async cmdSessions(chatId: string, args: string): Promise<void> {
259
- const PAGE_SIZE = 8;
260
- const page = Math.max(1, parseInt(args, 10) || 1);
261
-
262
- const active = this.sessions.getActiveSession(chatId);
263
- const project = active?.projectName;
264
-
265
- // Fetch all sessions for this chat (enough for pagination)
266
- const allSessions = this.sessions.listRecentSessions(chatId, 50);
267
- // Filter by current project if one is active
268
- const filtered = project
269
- ? allSessions.filter((s) => s.project_name === project)
270
- : allSessions;
271
-
272
- if (filtered.length === 0) {
273
- await this.telegram!.sendMessage(Number(chatId), "No sessions yet. Send a message to start.");
274
- return;
275
- }
276
-
277
- // Enrich with titles and pin status
278
- const titles = getSessionTitles(filtered.map((s) => s.session_id));
279
- const pinnedIds = getPinnedSessionIds();
280
-
281
- // Sort: pinned first, then by last_message_at desc
282
- const sorted = [...filtered].sort((a, b) => {
283
- const aPin = pinnedIds.has(a.session_id) ? 1 : 0;
284
- const bPin = pinnedIds.has(b.session_id) ? 1 : 0;
285
- if (aPin !== bPin) return bPin - aPin;
286
- return b.last_message_at - a.last_message_at;
287
- });
288
-
289
- const totalPages = Math.ceil(sorted.length / PAGE_SIZE);
290
- const start = (page - 1) * PAGE_SIZE;
291
- const pageItems = sorted.slice(start, start + PAGE_SIZE);
292
-
293
- if (pageItems.length === 0) {
294
- await this.telegram!.sendMessage(Number(chatId), `No sessions on page ${page}.`);
295
- return;
296
- }
297
-
298
- const header = project ? escapeHtml(project) : "All Projects";
299
- let text = `<b>Sessions — ${header}</b>`;
300
- if (totalPages > 1) text += ` <i>(${page}/${totalPages})</i>`;
301
- text += "\n\n";
302
-
303
- pageItems.forEach((s, i) => {
304
- const pin = pinnedIds.has(s.session_id) ? "📌 " : "";
305
- const activeDot = s.is_active ? " ⬤" : "";
306
- const rawTitle = titles[s.session_id]?.replace(/^\[PPM\]\s*/, "") || "";
307
- const title = rawTitle
308
- ? escapeHtml(rawTitle.slice(0, 45))
309
- : "<i>untitled</i>";
310
- const date = new Date(s.last_message_at * 1000).toLocaleString(undefined, {
311
- month: "short", day: "numeric", hour: "2-digit", minute: "2-digit",
312
- });
313
- const sid = s.session_id.slice(0, 8);
314
- const num = start + i + 1;
315
-
316
- text += `${pin}${num}. ${title}${activeDot}\n`;
317
- text += ` <code>${sid}</code> · ${date}\n\n`;
318
- });
319
-
320
- text += "Resume: /resume &lt;n&gt; or /resume &lt;id&gt;";
321
- if (totalPages > 1 && page < totalPages) {
322
- text += `\nNext: /sessions ${page + 1}`;
323
- }
324
-
325
- await this.telegram!.sendMessage(Number(chatId), text);
326
- }
327
-
328
- private async cmdResume(chatId: string, args: string): Promise<void> {
329
- if (!args.trim()) {
330
- await this.telegram!.sendMessage(Number(chatId), "Usage: /resume &lt;number or session-id&gt;");
331
- return;
332
- }
333
-
334
- // Support both index (e.g. "2") and session ID prefix (e.g. "fdc4ddaa")
335
- const index = parseInt(args, 10);
336
- const isIndex = !isNaN(index) && index >= 1 && String(index) === args.trim();
337
-
338
- const session = isIndex
339
- ? await this.sessions.resumeSessionById(chatId, index)
340
- : await this.sessions.resumeSessionByIdPrefix(chatId, args.trim());
341
-
342
- if (!session) {
343
- await this.telegram!.sendMessage(Number(chatId), "Session not found.");
344
- return;
345
- }
346
- await this.telegram!.sendMessage(
347
- Number(chatId),
348
- `Resumed session for <b>${escapeHtml(session.projectName)}</b> ✓`,
349
- );
350
- }
351
-
352
213
  private async cmdStatus(chatId: string): Promise<void> {
353
- const active = this.sessions.getActiveSession(chatId);
354
- if (!active) {
355
- await this.telegram!.sendMessage(Number(chatId), "No active session. Send a message to start.");
356
- return;
214
+ const tasks = getRecentBotTasks(chatId, 10);
215
+ const active = tasks.filter((t) => t.status === "running" || t.status === "pending");
216
+ const completed = tasks.filter((t) => t.status === "completed");
217
+ const delegationCount = getActiveDelegationCount();
218
+
219
+ let text = "<b>PPMBot Status</b>\n\n";
220
+ text += `Active delegations: ${delegationCount}\n`;
221
+
222
+ if (active.length) {
223
+ text += "\n<b>Running Tasks:</b>\n";
224
+ for (const t of active) {
225
+ const elapsed = Math.round((Date.now() / 1000 - t.createdAt) / 60);
226
+ text += ` 🔄 <code>${t.id.slice(0, 8)}</code> ${escapeHtml(t.projectName)} — ${escapeHtml(t.prompt.slice(0, 50))} (${elapsed}m)\n`;
227
+ }
357
228
  }
358
- let text = "<b>Status</b>\n\n";
359
- text += `Project: <code>${escapeHtml(active.projectName)}</code>\n`;
360
- text += `Provider: <code>${escapeHtml(active.providerId)}</code>\n`;
361
- text += `Session: <code>${active.sessionId.slice(0, 12)}…</code>\n`;
362
- await this.telegram!.sendMessage(Number(chatId), text);
363
- }
364
-
365
- private async cmdStop(chatId: string): Promise<void> {
366
- await this.sessions.closeSession(chatId);
367
- await this.telegram!.sendMessage(Number(chatId), "Session ended ✓");
368
- }
369
-
370
- private async cmdMemory(chatId: string): Promise<void> {
371
- const memories = this.memory.getSummary("_global");
372
- if (memories.length === 0) {
373
- await this.telegram!.sendMessage(Number(chatId), "No memories stored. Use /remember to add.");
374
- return;
229
+ if (completed.length) {
230
+ text += "\n<b>Recent Completed:</b>\n";
231
+ for (const t of completed.slice(0, 5)) {
232
+ text += ` <code>${t.id.slice(0, 8)}</code> ${escapeHtml(t.projectName)} — ${escapeHtml(t.prompt.slice(0, 50))}\n`;
233
+ }
375
234
  }
376
- let text = "<b>Memory</b> (cross-project)\n\n";
377
- for (const mem of memories) {
378
- text += `• [${mem.category}] ${escapeHtml(mem.content)}\n`;
235
+ if (!active.length && !completed.length) {
236
+ text += "No recent tasks.";
379
237
  }
380
238
  await this.telegram!.sendMessage(Number(chatId), text);
381
239
  }
382
240
 
383
- private async cmdForget(chatId: string, args: string): Promise<void> {
384
- if (!args) {
385
- await this.telegram!.sendMessage(Number(chatId), "Usage: /forget &lt;topic&gt;");
386
- return;
387
- }
388
- const count = this.memory.forget("_global", args);
389
- await this.telegram!.sendMessage(Number(chatId), `Forgot ${count} memor${count === 1 ? "y" : "ies"} ✓`);
390
- }
391
-
392
- private async cmdRemember(chatId: string, args: string): Promise<void> {
393
- if (!args) {
394
- await this.telegram!.sendMessage(Number(chatId), "Usage: /remember &lt;fact&gt;");
395
- return;
396
- }
397
- const active = this.sessions.getActiveSession(chatId);
398
- this.memory.saveOne("_global", args, "fact", active?.sessionId);
399
- await this.telegram!.sendMessage(Number(chatId), "Remembered ✓ (cross-project)");
400
- }
401
-
402
241
  private async cmdRestart(chatId: string): Promise<void> {
403
242
  await this.telegram!.sendMessage(Number(chatId), "🔄 Restarting PPM...");
404
-
405
- // Schedule restart after a short delay so the response is sent
406
243
  setTimeout(async () => {
407
- const { join } = await import("node:path");
408
244
  const { writeFileSync } = await import("node:fs");
409
- const { homedir } = await import("node:os");
410
-
411
245
  const approvedChats = getApprovedPairedChats();
412
246
  const chatIds = approvedChats.map((c) => c.telegram_chat_id);
413
-
414
- // Write restart marker so we can notify after restart
415
247
  const markerPath = join(homedir(), ".ppm", "restart-notify.json");
416
248
  writeFileSync(markerPath, JSON.stringify({ chatIds, ts: Date.now() }));
417
-
418
249
  console.log("[ppmbot] Restart requested via Telegram, exiting with code 42...");
419
250
  process.exit(42);
420
251
  }, 500);
421
252
  }
422
253
 
423
- /** Check for restart notification marker and send notification */
424
- async checkRestartNotification(): Promise<void> {
254
+ private async cmdHelp(chatId: string): Promise<void> {
255
+ const text = `<b>PPMBot Commands</b>
256
+
257
+ /start — Welcome + list projects
258
+ /status — Running tasks + delegations
259
+ /help — This message
260
+
261
+ <b>Everything else:</b> just chat naturally!
262
+ I'll answer directly or delegate to your project's AI.`;
263
+ await this.telegram!.sendMessage(Number(chatId), text);
264
+ }
265
+
266
+ // ── Coordinator Context ─────────────────────────────────────────
267
+
268
+ private readCoordinatorIdentity(): string {
269
+ if (this.coordinatorIdentity) return this.coordinatorIdentity;
270
+ const identityPath = join(homedir(), ".ppm", "bot", "coordinator.md");
425
271
  try {
426
- const { join } = await import("node:path");
427
- const { existsSync, readFileSync, unlinkSync } = await import("node:fs");
428
- const { homedir } = await import("node:os");
272
+ this.coordinatorIdentity = readFileSync(identityPath, "utf-8");
273
+ } catch {
274
+ this.coordinatorIdentity = DEFAULT_COORDINATOR_IDENTITY;
275
+ }
276
+ return this.coordinatorIdentity;
277
+ }
429
278
 
430
- const markerPath = join(homedir(), ".ppm", "restart-notify.json");
431
- if (!existsSync(markerPath)) return;
279
+ private buildCoordinatorContext(chatId: string): string {
280
+ const parts: string[] = [];
432
281
 
433
- const data = JSON.parse(readFileSync(markerPath, "utf-8"));
434
- unlinkSync(markerPath);
282
+ // Identity
283
+ const identity = this.readCoordinatorIdentity();
284
+ if (identity) {
285
+ parts.push("## Identity");
286
+ parts.push(identity);
287
+ }
435
288
 
436
- // Only notify if restart was recent (< 60s)
437
- if (Date.now() - data.ts > 60_000) return;
289
+ // Session info
290
+ parts.push(`\n## Session Info`);
291
+ parts.push(`Chat ID: ${chatId}`);
438
292
 
439
- // Read version from package.json
440
- let version = "";
441
- try {
442
- const pkgPath = join(import.meta.dir, "../../../package.json");
443
- const pkg = await Bun.file(pkgPath).json();
444
- version = pkg.version ? ` v${pkg.version}` : "";
445
- } catch {}
293
+ // Custom system prompt (user overrides)
294
+ const config = this.getConfig();
295
+ if (config?.system_prompt) {
296
+ parts.push(`\n## Custom Instructions`);
297
+ parts.push(config.system_prompt);
298
+ }
446
299
 
447
- for (const cid of data.chatIds) {
448
- await this.telegram?.sendMessage(Number(cid), `✅ PPM${version} restarted successfully.`);
300
+ // Project list
301
+ const projects = configService.get("projects") as ProjectConfig[];
302
+ if (projects?.length) {
303
+ parts.push("\n## Available Projects");
304
+ for (const p of projects) {
305
+ parts.push(`- ${p.name} (${p.path})`);
449
306
  }
450
- } catch {}
451
- }
307
+ }
452
308
 
453
- private async cmdVersion(chatId: string): Promise<void> {
454
- let version = "unknown";
455
- try {
456
- const { join } = await import("node:path");
457
- const pkgPath = join(import.meta.dir, "../../../package.json");
458
- const pkg = await Bun.file(pkgPath).json();
459
- version = pkg.version ?? "unknown";
460
- } catch {}
461
- await this.telegram!.sendMessage(Number(chatId), `<b>PPM</b> v${version}`);
309
+ // Running/recent tasks
310
+ const tasks = getRecentBotTasks(chatId, 10);
311
+ const activeTasks = tasks.filter((t) => t.status === "running" || t.status === "pending");
312
+ if (activeTasks.length) {
313
+ parts.push("\n## Running Tasks");
314
+ for (const t of activeTasks) {
315
+ const elapsed = Math.round((Date.now() / 1000 - t.createdAt) / 60);
316
+ parts.push(`- ${t.id.slice(0, 8)}: ${t.projectName} — "${t.prompt.slice(0, 60)}" (${t.status}, ${elapsed}m ago)`);
317
+ }
318
+ }
319
+
320
+ // Completed tasks not yet reported
321
+ const completed = tasks.filter((t) => t.status === "completed" && !t.reported);
322
+ if (completed.length) {
323
+ parts.push("\n## Completed Tasks (notify user)");
324
+ for (const t of completed) {
325
+ parts.push(`- ${t.id.slice(0, 8)}: ${t.projectName} — "${t.prompt.slice(0, 60)}"`);
326
+ parts.push(` Summary: ${t.resultSummary ?? "(use ppm bot task-result to get details)"}`);
327
+ markBotTaskReported(t.id);
328
+ }
329
+ }
330
+
331
+ // Memory recall
332
+ const memories = this.memory.getSummary("_global");
333
+ const memorySection = this.memory.buildRecallPrompt(memories);
334
+ if (memorySection) parts.push(memorySection);
335
+
336
+ return parts.join("\n");
462
337
  }
463
338
 
464
- private async cmdHelp(chatId: string): Promise<void> {
465
- const text = `<b>PPMBot Commands</b>
339
+ // ── Task Delegation Polling ─────────────────────────────────────
466
340
 
467
- /start Greeting + list projects
468
- /project &lt;name&gt; — Switch/list projects
469
- /new Fresh session (current project)
470
- /sessions [page] List sessions (current project)
471
- /resume &lt;n or id&gt; — Resume session
472
- /status Current project/session info
473
- /stop End current session
474
- /memory — Show project memories
475
- /forget &lt;topic&gt; Remove matching memories
476
- /remember &lt;fact&gt; Save a fact
477
- /restart — Restart PPM server
478
- /version — Show PPM version
479
- /help — This message`;
480
- await this.telegram!.sendMessage(Number(chatId), text);
341
+ private checkPendingTasks(): void {
342
+ try {
343
+ const pending = getRunningBotTasks().filter((t) => t.status === "pending");
344
+ for (const task of pending) {
345
+ const config = this.getConfig();
346
+ const providerId = config?.default_provider || configService.get("ai").default_provider;
347
+ executeDelegation(task.id, this.telegram!, providerId);
348
+ }
349
+ } catch (err) {
350
+ console.error("[ppmbot] checkPendingTasks error:", (err as Error).message);
351
+ }
481
352
  }
482
353
 
483
- // ── System Prompt: CLI Tools ─────────────────────────────────────
484
-
485
- private buildToolsPrompt(chatId: string): string {
486
- return `\n\n## PPMBot CLI Tools (use via Bash tool)
487
- Your chat ID is: ${chatId}
488
-
489
- You can manage the user's session, project, and memories by running CLI commands via the Bash tool.
490
- The chat ID is auto-detected so you do NOT need --chat flag.
491
-
492
- ### Project
493
- ppm bot project list — List available projects
494
- ppm bot project switch <name> Switch to a project
495
- ppm bot project current — Show current project
496
-
497
- ### Session
498
- ppm bot session new — Start fresh session (current project)
499
- ppm bot session list — List recent sessions
500
- ppm bot session resume <n|id> — Resume a session by index or ID prefix
501
- ppm bot session stop — End current session
502
-
503
- ### Memory (cross-project, persists across all projects)
504
- ppm bot memory save "<content>" --category <category>
505
- Categories: preference, fact, decision, architecture, issue
506
- ppm bot memory list — List saved memories
507
- ppm bot memory forget "<topic>" — Delete matching memories
508
-
509
- ### Server
510
- ppm bot status — Current project/session info
511
- ppm bot version — Show PPM version
512
- ppm bot restart — Restart PPM server
513
-
514
- ### Natural Language Understanding
515
- When the user says something like:
516
- - "chuyển sang project X" or "switch to X" → ppm bot project switch X
517
- - "tạo session mới" or "new session" → ppm bot session new
518
- - "liệt kê sessions" or "show sessions" → ppm bot session list
519
- - "quay lại session cũ" or "resume session 2" → ppm bot session resume 2
520
- - "kết thúc session" or "stop" → ppm bot session stop
521
- - "đang ở project nào?" or "current project?" → ppm bot project current
522
- - "nhớ rằng..." or "remember that..." → ppm bot memory save "..." --category preference
523
- - "quên đi..." or "forget about..." → ppm bot memory forget "..."
524
- - "restart server" or "khởi động lại" → ppm bot restart
525
-
526
- Always execute the appropriate CLI command — do NOT just describe what you would do.`;
354
+ private cleanupStaleTasks(): void {
355
+ try {
356
+ const stale = getRunningBotTasks().filter((t) => t.status === "running");
357
+ for (const task of stale) {
358
+ updateBotTaskStatus(task.id, "failed", { error: "Server restarted during execution" });
359
+ this.telegram?.sendMessage(
360
+ Number(task.chatId),
361
+ `⚠️ Task interrupted by server restart: <i>${escapeHtml(task.prompt.slice(0, 80))}</i>`,
362
+ );
363
+ }
364
+ if (stale.length) console.log(`[ppmbot] Cleaned up ${stale.length} stale task(s)`);
365
+ } catch (err) {
366
+ console.error("[ppmbot] cleanupStaleTasks error:", (err as Error).message);
367
+ }
527
368
  }
528
369
 
529
370
  // ── Chat Message Pipeline ───────────────────────────────────────
@@ -551,7 +392,6 @@ Always execute the appropriate CLI command — do NOT just describe what you wou
551
392
  this.debouncedTexts.delete(chatId);
552
393
  if (!text.trim()) return;
553
394
 
554
- // Queue if already processing
555
395
  if (this.processing.has(chatId)) {
556
396
  const queue = this.messageQueue.get(chatId) ?? [];
557
397
  queue.push(text);
@@ -562,48 +402,16 @@ Always execute the appropriate CLI command — do NOT just describe what you wou
562
402
 
563
403
  try {
564
404
  const config = this.getConfig();
405
+ const session = await this.sessions.getCoordinatorSession(chatId);
565
406
 
566
- const session = await this.sessions.getOrCreateSession(chatId);
567
-
568
- // Update title on first message only
569
- if (!this.titledSessions.has(session.sessionId)) {
570
- this.sessions.updateSessionTitle(session.sessionId, text);
571
- this.titledSessions.add(session.sessionId);
572
- }
573
-
574
- // Recall identity & preferences (global + project)
575
- const memories = this.memory.getSummary(session.projectName);
576
-
577
- // Build system prompt with identity/preferences
578
- let systemPrompt = config?.system_prompt ?? "";
579
- const memorySection = this.memory.buildRecallPrompt(memories);
580
- if (memorySection) {
581
- systemPrompt += memorySection;
582
- }
583
-
584
- // Instruct AI to use CLI tools for session/project/memory management
585
- systemPrompt += this.buildToolsPrompt(chatId);
407
+ // Build coordinator context (identity + projects + tasks + memories)
408
+ const context = this.buildCoordinatorContext(chatId);
409
+ const fullMessage = `<coordinator-context>\n${context}\n</coordinator-context>\n\n${text}`;
586
410
 
587
- // Send message to AI (prepend system prompt + memory context)
588
411
  const opts: SendMessageOpts = {
589
412
  permissionMode: (config?.permission_mode ?? "bypassPermissions") as PermissionMode,
590
413
  };
591
414
 
592
- // Save identity BEFORE streaming — must persist even if streaming times out
593
- let messageForAI = text;
594
- if (this.identityPending.has(chatId)) {
595
- this.identityPending.delete(chatId);
596
- this.memory.saveOne("_global", `User identity: ${text}`, "preference", session.sessionId);
597
- console.log("[ppmbot] Saved identity memory from onboarding");
598
- // Tell AI this is an identity intro so it acknowledges warmly
599
- messageForAI = `[User just introduced themselves in response to onboarding prompt. Acknowledge warmly and briefly.]\n\n${text}`;
600
- }
601
-
602
- let fullMessage = messageForAI;
603
- if (systemPrompt) {
604
- fullMessage = `<system-context>\n${systemPrompt}\n</system-context>\n\n${messageForAI}`;
605
- }
606
-
607
415
  const events = chatService.sendMessage(
608
416
  session.providerId,
609
417
  session.sessionId,
@@ -611,7 +419,6 @@ Always execute the appropriate CLI command — do NOT just describe what you wou
611
419
  opts,
612
420
  );
613
421
 
614
- // Stream response to Telegram
615
422
  const result = await streamToTelegram(
616
423
  Number(chatId),
617
424
  events,
@@ -622,12 +429,16 @@ Always execute the appropriate CLI command — do NOT just describe what you wou
622
429
  },
623
430
  );
624
431
 
625
- // Check context window — auto-rotate if near limit
432
+ // Context rotation
626
433
  if (
627
434
  result.contextWindowPct != null &&
628
435
  result.contextWindowPct > CONTEXT_WINDOW_THRESHOLD
629
436
  ) {
630
- await this.rotateSession(chatId, session.projectName);
437
+ await this.sessions.rotateCoordinatorSession(chatId);
438
+ await this.telegram?.sendMessage(
439
+ Number(chatId),
440
+ "<i>Context refreshed.</i>",
441
+ );
631
442
  }
632
443
  } catch (err) {
633
444
  console.error(`[ppmbot] processMessage error for ${chatId}:`, (err as Error).message);
@@ -638,7 +449,6 @@ Always execute the appropriate CLI command — do NOT just describe what you wou
638
449
  } finally {
639
450
  this.processing.delete(chatId);
640
451
 
641
- // Process queued messages
642
452
  const queued = this.messageQueue.get(chatId);
643
453
  if (queued && queued.length > 0) {
644
454
  this.messageQueue.delete(chatId);
@@ -648,21 +458,36 @@ Always execute the appropriate CLI command — do NOT just describe what you wou
648
458
  }
649
459
  }
650
460
 
651
- // ── Session Rotate ──────────────────────────────────────────────
461
+ // ── Restart Notification ────────────────────────────────────────
652
462
 
653
- private async rotateSession(chatId: string, projectName: string): Promise<void> {
654
- await this.sessions.closeSession(chatId);
655
- await this.sessions.getOrCreateSession(chatId, projectName);
656
- await this.telegram?.sendMessage(
657
- Number(chatId),
658
- "<i>Context window near limit — starting fresh session.</i>",
659
- );
463
+ async checkRestartNotification(): Promise<void> {
464
+ try {
465
+ const { existsSync, readFileSync: fsRead, unlinkSync } = await import("node:fs");
466
+ const markerPath = join(homedir(), ".ppm", "restart-notify.json");
467
+ if (!existsSync(markerPath)) return;
468
+
469
+ const data = JSON.parse(fsRead(markerPath, "utf-8"));
470
+ unlinkSync(markerPath);
471
+
472
+ if (Date.now() - data.ts > 60_000) return;
473
+
474
+ let version = "";
475
+ try {
476
+ const pkgPath = join(import.meta.dir, "../../../package.json");
477
+ const pkg = await Bun.file(pkgPath).json();
478
+ version = pkg.version ? ` v${pkg.version}` : "";
479
+ } catch {}
480
+
481
+ for (const cid of data.chatIds) {
482
+ await this.telegram?.sendMessage(Number(cid), `✅ PPM${version} restarted successfully.`);
483
+ }
484
+ } catch {}
660
485
  }
661
486
 
662
487
  // ── Helpers ─────────────────────────────────────────────────────
663
488
 
664
489
  private generatePairingCode(): string {
665
- const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // no I,O,0,1 (ambiguous)
490
+ const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
666
491
  const bytes = crypto.getRandomValues(new Uint8Array(6));
667
492
  return Array.from(bytes, (b) => chars[b % chars.length]).join("");
668
493
  }