@hienlh/ppm 0.9.51 → 0.9.52

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 CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.9.52] - 2026-04-07
4
+
5
+ ### Added
6
+ - **Full `ppm bot` CLI**: All 13 Telegram commands now have CLI equivalents — `project switch/list/current`, `session new/list/resume/stop`, `memory save/list/forget`, `status`, `version`, `restart`, `help`. AI can invoke any command via Bash tool from natural language (e.g. "chuyển sang project ppm" → `ppm bot project switch ppm`).
7
+ - **Auto-detect chat ID**: `resolveChatId()` auto-detects single approved paired Telegram chat. Falls back to `--chat <id>` when multiple chats exist.
8
+ - **System prompt with natural language mapping**: AI receives full CLI reference + Vietnamese/English intent examples, executes commands directly instead of describing actions.
9
+
3
10
  ## [0.9.51] - 2026-04-07
4
11
 
5
12
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hienlh/ppm",
3
- "version": "0.9.51",
3
+ "version": "0.9.52",
4
4
  "description": "Personal Project Manager — mobile-first web IDE with AI assistance",
5
5
  "author": "hienlh",
6
6
  "license": "MIT",
@@ -11,17 +11,44 @@ const C = {
11
11
  };
12
12
 
13
13
  /**
14
- * `ppm bot memory` CLI allows AI (via Bash tool) to save/list/forget
15
- * cross-project memories in the _global scope of clawbot_memories table.
14
+ * Resolve the Telegram chatId for CLI operations.
15
+ * Auto-detects if exactly 1 approved paired chat exists.
16
+ * Otherwise requires --chat flag.
17
+ */
18
+ export async function resolveChatId(chatOpt?: string): Promise<string> {
19
+ if (chatOpt) return chatOpt;
20
+
21
+ const { getApprovedPairedChats } = await import("../../services/db.service.ts");
22
+ const approved = getApprovedPairedChats();
23
+
24
+ if (approved.length === 0) {
25
+ throw new Error("No paired Telegram chats. Pair a device in PPM Settings first.");
26
+ }
27
+ if (approved.length > 1) {
28
+ const ids = approved.map((c) => ` ${c.telegram_chat_id} (${c.display_name || "unknown"})`).join("\n");
29
+ throw new Error(`Multiple paired chats found. Use --chat <id> to specify:\n${ids}`);
30
+ }
31
+ return approved[0]!.telegram_chat_id;
32
+ }
33
+
34
+ /**
35
+ * `ppm bot` CLI — allows AI (via Bash tool) to manage PPMBot sessions,
36
+ * projects, memories, and server operations through natural language.
16
37
  *
17
- * Usage from AI:
18
- * ppm bot memory save "User prefers Vietnamese" --category preference
19
- * ppm bot memory list
20
- * ppm bot memory forget "Vietnamese"
38
+ * All session/project commands auto-detect the paired Telegram chat.
21
39
  */
22
40
  export function registerBotCommands(program: Command): void {
23
41
  const bot = program.command("bot").description("PPMBot utilities");
24
42
 
43
+ registerMemoryCommands(bot);
44
+ registerProjectCommands(bot);
45
+ registerSessionCommands(bot);
46
+ registerMiscCommands(bot);
47
+ }
48
+
49
+ // ── Memory ──────────────────────────────────────────────────────────
50
+
51
+ function registerMemoryCommands(bot: Command): void {
25
52
  const mem = bot.command("memory").description("Manage cross-project memories");
26
53
 
27
54
  mem
@@ -94,3 +121,349 @@ export function registerBotCommands(program: Command): void {
94
121
  }
95
122
  });
96
123
  }
124
+
125
+ // ── Project ─────────────────────────────────────────────────────────
126
+
127
+ function registerProjectCommands(bot: Command): void {
128
+ const proj = bot.command("project").description("Manage bot project context");
129
+
130
+ proj
131
+ .command("list")
132
+ .description("List available projects")
133
+ .option("--json", "Output as JSON")
134
+ .action(async (opts: { json?: boolean }) => {
135
+ try {
136
+ const { PPMBotSessionManager } = await import("../../services/ppmbot/ppmbot-session.ts");
137
+ const sessions = new PPMBotSessionManager();
138
+ const projects = sessions.getProjectNames();
139
+
140
+ if (opts.json) {
141
+ console.log(JSON.stringify(projects));
142
+ return;
143
+ }
144
+
145
+ if (projects.length === 0) {
146
+ console.log(`${C.dim}No projects configured.${C.reset}`);
147
+ return;
148
+ }
149
+
150
+ // Show current project if possible
151
+ let current = "";
152
+ try {
153
+ const chatId = await resolveChatId();
154
+ const active = sessions.getActiveSession(chatId);
155
+ current = active?.projectName ?? "";
156
+ } catch { /* no chat — skip marker */ }
157
+
158
+ for (const name of projects) {
159
+ const marker = name === current ? ` ${C.green}✓${C.reset}` : "";
160
+ console.log(` ${name}${marker}`);
161
+ }
162
+ } catch (e) {
163
+ console.error(`${C.red}✗${C.reset} ${e instanceof Error ? e.message : String(e)}`);
164
+ process.exit(1);
165
+ }
166
+ });
167
+
168
+ proj
169
+ .command("switch <name>")
170
+ .description("Switch to a different project")
171
+ .option("--chat <id>", "Telegram chat ID (auto-detected if single)")
172
+ .action(async (name: string, opts: { chat?: string }) => {
173
+ try {
174
+ const chatId = await resolveChatId(opts.chat);
175
+ const { PPMBotSessionManager } = await import("../../services/ppmbot/ppmbot-session.ts");
176
+ const sessions = new PPMBotSessionManager();
177
+ const session = await sessions.switchProject(chatId, name);
178
+ console.log(`${C.green}✓${C.reset} Switched to ${C.bold}${session.projectName}${C.reset}`);
179
+ } catch (e) {
180
+ console.error(`${C.red}✗${C.reset} ${e instanceof Error ? e.message : String(e)}`);
181
+ process.exit(1);
182
+ }
183
+ });
184
+
185
+ proj
186
+ .command("current")
187
+ .description("Show current project")
188
+ .option("--chat <id>", "Telegram chat ID (auto-detected if single)")
189
+ .action(async (opts: { chat?: string }) => {
190
+ try {
191
+ const chatId = await resolveChatId(opts.chat);
192
+ const { PPMBotSessionManager } = await import("../../services/ppmbot/ppmbot-session.ts");
193
+ const sessions = new PPMBotSessionManager();
194
+ const active = sessions.getActiveSession(chatId);
195
+
196
+ // Fallback: check DB for active session
197
+ if (!active) {
198
+ const { getActivePPMBotSession } = await import("../../services/db.service.ts");
199
+ const { configService } = await import("../../services/config.service.ts");
200
+ const projects = (configService.get("projects") as any[]) ?? [];
201
+ for (const p of projects) {
202
+ const dbSession = getActivePPMBotSession(chatId, p.name);
203
+ if (dbSession) {
204
+ console.log(dbSession.project_name);
205
+ return;
206
+ }
207
+ }
208
+ console.log(`${C.dim}No active project. Use: ppm bot project switch <name>${C.reset}`);
209
+ return;
210
+ }
211
+ console.log(active.projectName);
212
+ } catch (e) {
213
+ console.error(`${C.red}✗${C.reset} ${e instanceof Error ? e.message : String(e)}`);
214
+ process.exit(1);
215
+ }
216
+ });
217
+ }
218
+
219
+ // ── Session ─────────────────────────────────────────────────────────
220
+
221
+ function registerSessionCommands(bot: Command): void {
222
+ const sess = bot.command("session").description("Manage chat sessions");
223
+
224
+ sess
225
+ .command("new")
226
+ .description("Start a fresh session (current project)")
227
+ .option("--chat <id>", "Telegram chat ID (auto-detected if single)")
228
+ .action(async (opts: { chat?: string }) => {
229
+ try {
230
+ const chatId = await resolveChatId(opts.chat);
231
+ const { PPMBotSessionManager } = await import("../../services/ppmbot/ppmbot-session.ts");
232
+ const sessions = new PPMBotSessionManager();
233
+
234
+ // Get current project before closing
235
+ const active = sessions.getActiveSession(chatId);
236
+ const projectName = active?.projectName;
237
+ await sessions.closeSession(chatId);
238
+ const session = await sessions.getOrCreateSession(chatId, projectName ?? undefined);
239
+ console.log(`${C.green}✓${C.reset} New session for ${C.bold}${session.projectName}${C.reset} (${session.sessionId.slice(0, 8)})`);
240
+ } catch (e) {
241
+ console.error(`${C.red}✗${C.reset} ${e instanceof Error ? e.message : String(e)}`);
242
+ process.exit(1);
243
+ }
244
+ });
245
+
246
+ sess
247
+ .command("list")
248
+ .description("List recent sessions")
249
+ .option("--chat <id>", "Telegram chat ID (auto-detected if single)")
250
+ .option("-l, --limit <n>", "Max results", "20")
251
+ .option("--json", "Output as JSON")
252
+ .action(async (opts: { chat?: string; limit: string; json?: boolean }) => {
253
+ try {
254
+ const chatId = await resolveChatId(opts.chat);
255
+ const { getRecentPPMBotSessions, getSessionTitles, getPinnedSessionIds } = await import("../../services/db.service.ts");
256
+ const allSessions = getRecentPPMBotSessions(chatId, Number(opts.limit) || 20);
257
+
258
+ if (allSessions.length === 0) {
259
+ console.log(`${C.dim}No sessions found.${C.reset}`);
260
+ return;
261
+ }
262
+
263
+ const titles = getSessionTitles(allSessions.map((s) => s.session_id));
264
+ const pinnedIds = getPinnedSessionIds();
265
+
266
+ // Sort: pinned first, then by last_message_at desc
267
+ const sorted = [...allSessions].sort((a, b) => {
268
+ const aPin = pinnedIds.has(a.session_id) ? 1 : 0;
269
+ const bPin = pinnedIds.has(b.session_id) ? 1 : 0;
270
+ if (aPin !== bPin) return bPin - aPin;
271
+ return b.last_message_at - a.last_message_at;
272
+ });
273
+
274
+ if (opts.json) {
275
+ const jsonData = sorted.map((s, i) => ({
276
+ index: i + 1,
277
+ sessionId: s.session_id,
278
+ project: s.project_name,
279
+ title: titles[s.session_id]?.replace(/^\[PPM\]\s*/, "") || "",
280
+ pinned: pinnedIds.has(s.session_id),
281
+ active: !!s.is_active,
282
+ lastMessage: new Date(s.last_message_at * 1000).toISOString(),
283
+ }));
284
+ console.log(JSON.stringify(jsonData, null, 2));
285
+ return;
286
+ }
287
+
288
+ for (const [i, s] of sorted.entries()) {
289
+ const pin = pinnedIds.has(s.session_id) ? "📌 " : " ";
290
+ const activeDot = s.is_active ? ` ${C.green}⬤${C.reset}` : "";
291
+ const rawTitle = titles[s.session_id]?.replace(/^\[PPM\]\s*/, "") || "";
292
+ const title = rawTitle ? rawTitle.slice(0, 50) : `${C.dim}untitled${C.reset}`;
293
+ const sid = s.session_id.slice(0, 8);
294
+ const date = new Date(s.last_message_at * 1000).toLocaleString(undefined, {
295
+ month: "short", day: "numeric", hour: "2-digit", minute: "2-digit",
296
+ });
297
+
298
+ console.log(`${pin}${i + 1}. ${title}${activeDot}`);
299
+ console.log(` ${C.dim}${sid} · ${s.project_name} · ${date}${C.reset}`);
300
+ }
301
+ } catch (e) {
302
+ console.error(`${C.red}✗${C.reset} ${e instanceof Error ? e.message : String(e)}`);
303
+ process.exit(1);
304
+ }
305
+ });
306
+
307
+ sess
308
+ .command("resume <target>")
309
+ .description("Resume a session by index number or session ID prefix")
310
+ .option("--chat <id>", "Telegram chat ID (auto-detected if single)")
311
+ .action(async (target: string, opts: { chat?: string }) => {
312
+ try {
313
+ const chatId = await resolveChatId(opts.chat);
314
+ const { PPMBotSessionManager } = await import("../../services/ppmbot/ppmbot-session.ts");
315
+ const sessions = new PPMBotSessionManager();
316
+
317
+ const index = parseInt(target, 10);
318
+ const isIndex = !isNaN(index) && index >= 1 && String(index) === target.trim();
319
+
320
+ const session = isIndex
321
+ ? await sessions.resumeSessionById(chatId, index)
322
+ : await sessions.resumeSessionByIdPrefix(chatId, target.trim());
323
+
324
+ if (!session) {
325
+ console.log(`${C.yellow}Session not found: ${target}${C.reset}`);
326
+ process.exit(1);
327
+ }
328
+ console.log(`${C.green}✓${C.reset} Resumed session ${C.dim}${session.sessionId.slice(0, 8)}${C.reset} (${C.bold}${session.projectName}${C.reset})`);
329
+ } catch (e) {
330
+ console.error(`${C.red}✗${C.reset} ${e instanceof Error ? e.message : String(e)}`);
331
+ process.exit(1);
332
+ }
333
+ });
334
+
335
+ sess
336
+ .command("stop")
337
+ .description("End the current session")
338
+ .option("--chat <id>", "Telegram chat ID (auto-detected if single)")
339
+ .action(async (opts: { chat?: string }) => {
340
+ try {
341
+ const chatId = await resolveChatId(opts.chat);
342
+ const { PPMBotSessionManager } = await import("../../services/ppmbot/ppmbot-session.ts");
343
+ const sessions = new PPMBotSessionManager();
344
+ await sessions.closeSession(chatId);
345
+ console.log(`${C.green}✓${C.reset} Session ended`);
346
+ } catch (e) {
347
+ console.error(`${C.red}✗${C.reset} ${e instanceof Error ? e.message : String(e)}`);
348
+ process.exit(1);
349
+ }
350
+ });
351
+ }
352
+
353
+ // ── Misc: status, version, restart, help ────────────────────────────
354
+
355
+ function registerMiscCommands(bot: Command): void {
356
+ bot
357
+ .command("status")
358
+ .description("Show current project and session info")
359
+ .option("--chat <id>", "Telegram chat ID (auto-detected if single)")
360
+ .option("--json", "Output as JSON")
361
+ .action(async (opts: { chat?: string; json?: boolean }) => {
362
+ try {
363
+ const chatId = await resolveChatId(opts.chat);
364
+ const { PPMBotSessionManager } = await import("../../services/ppmbot/ppmbot-session.ts");
365
+ const sessions = new PPMBotSessionManager();
366
+ const active = sessions.getActiveSession(chatId);
367
+
368
+ // Fallback: check DB for any active session
369
+ let project = active?.projectName ?? "";
370
+ let provider = active?.providerId ?? "";
371
+ let sessionId = active?.sessionId ?? "";
372
+
373
+ if (!active) {
374
+ const { getRecentPPMBotSessions } = await import("../../services/db.service.ts");
375
+ const recent = getRecentPPMBotSessions(chatId, 1);
376
+ if (recent.length > 0 && recent[0]!.is_active) {
377
+ project = recent[0]!.project_name;
378
+ provider = recent[0]!.provider_id;
379
+ sessionId = recent[0]!.session_id;
380
+ }
381
+ }
382
+
383
+ if (opts.json) {
384
+ console.log(JSON.stringify({ chatId, project, provider, sessionId }));
385
+ return;
386
+ }
387
+
388
+ if (!project) {
389
+ console.log(`${C.dim}No active session. Use: ppm bot project switch <name>${C.reset}`);
390
+ return;
391
+ }
392
+
393
+ console.log(`Project: ${C.bold}${project}${C.reset}`);
394
+ console.log(`Provider: ${provider}`);
395
+ console.log(`Session: ${C.dim}${sessionId.slice(0, 12)}…${C.reset}`);
396
+ console.log(`Chat: ${chatId}`);
397
+ } catch (e) {
398
+ console.error(`${C.red}✗${C.reset} ${e instanceof Error ? e.message : String(e)}`);
399
+ process.exit(1);
400
+ }
401
+ });
402
+
403
+ bot
404
+ .command("version")
405
+ .description("Show PPM version")
406
+ .action(async () => {
407
+ try {
408
+ const { VERSION } = await import("../../version.ts");
409
+ console.log(`PPM v${VERSION}`);
410
+ } catch {
411
+ console.log("PPM version unknown");
412
+ }
413
+ });
414
+
415
+ bot
416
+ .command("restart")
417
+ .description("Restart the PPM server")
418
+ .action(async () => {
419
+ try {
420
+ const { join } = await import("node:path");
421
+ const { writeFileSync } = await import("node:fs");
422
+ const { homedir } = await import("node:os");
423
+ const { getApprovedPairedChats } = await import("../../services/db.service.ts");
424
+
425
+ const approvedChats = getApprovedPairedChats();
426
+ const chatIds = approvedChats.map((c) => c.telegram_chat_id);
427
+
428
+ const markerPath = join(homedir(), ".ppm", "restart-notify.json");
429
+ writeFileSync(markerPath, JSON.stringify({ chatIds, ts: Date.now() }));
430
+
431
+ console.log(`${C.green}✓${C.reset} Restart signal sent (exit code 42)`);
432
+ process.exit(42);
433
+ } catch (e) {
434
+ console.error(`${C.red}✗${C.reset} ${e instanceof Error ? e.message : String(e)}`);
435
+ process.exit(1);
436
+ }
437
+ });
438
+
439
+ bot
440
+ .command("help")
441
+ .description("Show all bot CLI commands")
442
+ .action(() => {
443
+ console.log(`${C.bold}PPMBot CLI Commands${C.reset}
444
+
445
+ ${C.cyan}Project:${C.reset}
446
+ ppm bot project list List available projects
447
+ ppm bot project switch <name> Switch to a project
448
+ ppm bot project current Show current project
449
+
450
+ ${C.cyan}Session:${C.reset}
451
+ ppm bot session new Start fresh session
452
+ ppm bot session list List recent sessions
453
+ ppm bot session resume <n|id> Resume a session
454
+ ppm bot session stop End current session
455
+
456
+ ${C.cyan}Memory (cross-project):${C.reset}
457
+ ppm bot memory save "<text>" Save a memory (-c category)
458
+ ppm bot memory list List saved memories
459
+ ppm bot memory forget "<topic>" Delete matching memories
460
+
461
+ ${C.cyan}Server:${C.reset}
462
+ ppm bot status Current project/session info
463
+ ppm bot version Show PPM version
464
+ ppm bot restart Restart PPM server
465
+
466
+ ${C.dim}Session/project commands auto-detect your Telegram chat.
467
+ Use --chat <id> if multiple chats are paired.${C.reset}`);
468
+ });
469
+ }
@@ -480,6 +480,52 @@ class PPMBotService {
480
480
  await this.telegram!.sendMessage(Number(chatId), text);
481
481
  }
482
482
 
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.`;
527
+ }
528
+
483
529
  // ── Chat Message Pipeline ───────────────────────────────────────
484
530
 
485
531
  private async handleMessage(chatId: string, text: string): Promise<void> {
@@ -535,14 +581,8 @@ class PPMBotService {
535
581
  systemPrompt += memorySection;
536
582
  }
537
583
 
538
- // Instruct AI to use CLI for cross-project memory persistence
539
- systemPrompt += `\n\n## Cross-Project Memory Tool
540
- When the user asks you to remember something, change how you address them, or save any preference/fact that should persist across projects and sessions, use the Bash tool to run:
541
- ppm bot memory save "<content>" --category <category>
542
- Categories: preference, fact, decision, architecture, issue
543
- To list saved memories: ppm bot memory list
544
- To forget: ppm bot memory forget "<topic>"
545
- This saves to a global store that persists across all projects and sessions.`;
584
+ // Instruct AI to use CLI tools for session/project/memory management
585
+ systemPrompt += this.buildToolsPrompt(chatId);
546
586
 
547
587
  // Send message to AI (prepend system prompt + memory context)
548
588
  const opts: SendMessageOpts = {