@hienlh/ppm 0.9.49 โ†’ 0.9.51

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,20 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.9.51] - 2026-04-07
4
+
5
+ ### Added
6
+ - **`ppm bot memory` CLI command**: AI can now save cross-project memories via Bash tool โ€” `ppm bot memory save/list/forget`. Stores to `_global` scope in SQLite, persists across all projects and sessions. GoClaw-inspired pattern (CLI tool, not MCP).
7
+ - **System prompt instructs AI**: AI automatically knows to use `ppm bot memory save` when user asks to remember preferences, change address style, or save facts.
8
+
9
+ ### Changed
10
+ - **/memory, /remember, /forget**: Now always use `_global` project scope (cross-project).
11
+ - **ppmbot-memory tests updated**: Removed stale tests referencing deleted methods (`recall`, `save`, `parseExtractionResponse`, `extractiveMemoryFallback`).
12
+
13
+ ## [0.9.50] - 2026-04-07
14
+
15
+ ### Changed
16
+ - **/sessions redesigned**: Filters by current project, shows pinned sessions first with ๐Ÿ“Œ icon, displays session titles (not project names), supports pagination via `/sessions 2`. Matches PPM web UI behavior.
17
+
3
18
  ## [0.9.49] - 2026-04-07
4
19
 
5
20
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hienlh/ppm",
3
- "version": "0.9.49",
3
+ "version": "0.9.51",
4
4
  "description": "Personal Project Manager โ€” mobile-first web IDE with AI assistance",
5
5
  "author": "hienlh",
6
6
  "license": "MIT",
@@ -0,0 +1,96 @@
1
+ import { Command } from "commander";
2
+
3
+ const C = {
4
+ reset: "\x1b[0m",
5
+ bold: "\x1b[1m",
6
+ green: "\x1b[32m",
7
+ red: "\x1b[31m",
8
+ yellow: "\x1b[33m",
9
+ cyan: "\x1b[36m",
10
+ dim: "\x1b[2m",
11
+ };
12
+
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.
16
+ *
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"
21
+ */
22
+ export function registerBotCommands(program: Command): void {
23
+ const bot = program.command("bot").description("PPMBot utilities");
24
+
25
+ const mem = bot.command("memory").description("Manage cross-project memories");
26
+
27
+ mem
28
+ .command("save <content>")
29
+ .description("Save a cross-project memory")
30
+ .option("-c, --category <cat>", "Category: fact|preference|decision|architecture|issue", "fact")
31
+ .option("-s, --session <id>", "Session ID (optional)")
32
+ .action(async (content: string, opts: { category: string; session?: string }) => {
33
+ try {
34
+ const { PPMBotMemory } = await import("../../services/ppmbot/ppmbot-memory.ts");
35
+ const memory = new PPMBotMemory();
36
+ const validCategories = ["fact", "decision", "preference", "architecture", "issue"];
37
+ const category = validCategories.includes(opts.category) ? opts.category : "fact";
38
+ const id = memory.saveOne("_global", content, category as any, opts.session);
39
+ console.log(`${C.green}โœ“${C.reset} Saved memory #${id} [${category}]: ${content}`);
40
+ } catch (e) {
41
+ console.error(`${C.red}โœ—${C.reset} ${e instanceof Error ? e.message : String(e)}`);
42
+ process.exit(1);
43
+ }
44
+ });
45
+
46
+ mem
47
+ .command("list")
48
+ .description("List active cross-project memories")
49
+ .option("-l, --limit <n>", "Max results", "30")
50
+ .option("--json", "Output as JSON")
51
+ .action(async (opts: { limit: string; json?: boolean }) => {
52
+ try {
53
+ const { PPMBotMemory } = await import("../../services/ppmbot/ppmbot-memory.ts");
54
+ const memory = new PPMBotMemory();
55
+ const results = memory.getSummary("_global", Number(opts.limit) || 30);
56
+
57
+ if (opts.json) {
58
+ console.log(JSON.stringify(results, null, 2));
59
+ return;
60
+ }
61
+
62
+ if (results.length === 0) {
63
+ console.log(`${C.dim}No memories found.${C.reset}`);
64
+ return;
65
+ }
66
+
67
+ for (const r of results) {
68
+ const catTag = `${C.cyan}[${r.category}]${C.reset}`;
69
+ console.log(` #${r.id} ${catTag} ${r.content}`);
70
+ }
71
+ console.log(`\n${C.dim}${results.length} memories${C.reset}`);
72
+ } catch (e) {
73
+ console.error(`${C.red}โœ—${C.reset} ${e instanceof Error ? e.message : String(e)}`);
74
+ process.exit(1);
75
+ }
76
+ });
77
+
78
+ mem
79
+ .command("forget <topic>")
80
+ .description("Delete memories matching a topic (FTS5 search)")
81
+ .action(async (topic: string) => {
82
+ try {
83
+ const { PPMBotMemory } = await import("../../services/ppmbot/ppmbot-memory.ts");
84
+ const memory = new PPMBotMemory();
85
+ const deleted = memory.forget("_global", topic);
86
+ if (deleted > 0) {
87
+ console.log(`${C.green}โœ“${C.reset} Deleted ${deleted} memory(s) matching "${topic}"`);
88
+ } else {
89
+ console.log(`${C.yellow}No memories matched "${topic}"${C.reset}`);
90
+ }
91
+ } catch (e) {
92
+ console.error(`${C.red}โœ—${C.reset} ${e instanceof Error ? e.message : String(e)}`);
93
+ process.exit(1);
94
+ }
95
+ });
96
+ }
package/src/index.ts CHANGED
@@ -139,4 +139,7 @@ registerCloudCommands(program);
139
139
  const { registerExtCommands } = await import("./cli/commands/ext-cmd.ts");
140
140
  registerExtCommands(program);
141
141
 
142
+ const { registerBotCommands } = await import("./cli/commands/bot-cmd.ts");
143
+ registerBotCommands(program);
144
+
142
145
  program.parse();
@@ -717,6 +717,8 @@ export class ClaudeAgentSdkProvider implements AIProvider {
717
717
  let retryCount = 0;
718
718
  let rateLimitRetryCount = 0;
719
719
  let authRetried = false;
720
+ /** True after the first init event maps ppmId โ†’ sdkId. Prevents retry init events from overwriting the mapping. */
721
+ let initMappingDone = false;
720
722
 
721
723
  let hadAnyEvents = false;
722
724
  retryLoop: while (true) {
@@ -763,16 +765,22 @@ export class ClaudeAgentSdkProvider implements AIProvider {
763
765
  if (subtype === "init") {
764
766
  const initMsg = msg as any;
765
767
  if (initMsg.session_id && initMsg.session_id !== sessionId) {
766
- // Only update sdk_id mapping for brand-new sessions (first message).
767
- // For resumed sessions the SDK may create a new session_id, but the
768
- // old JSONL (keyed by the original sdk_id) still holds the full
769
- // conversation history. Overwriting the mapping would orphan it.
770
- const existingSdkId = getSessionMapping(sessionId);
771
- const isFirstMessage = existingSdkId === null || existingSdkId === sessionId;
772
- if (isFirstMessage) {
773
- setSessionMapping(sessionId, initMsg.session_id, meta.projectName, meta.projectPath);
768
+ // Only update sdk_id mapping once per session lifecycle.
769
+ // Retries (auth refresh, rate limit) create new SDK queries that
770
+ // emit fresh init events โ€” overwriting would orphan the original JSONL.
771
+ if (!initMappingDone) {
772
+ const existingSdkId = getSessionMapping(sessionId);
773
+ const isFirstMapping = existingSdkId === null || existingSdkId === sessionId;
774
+ if (isFirstMapping) {
775
+ setSessionMapping(sessionId, initMsg.session_id, meta.projectName, meta.projectPath);
776
+ initMappingDone = true;
777
+ } else {
778
+ // Already mapped to a real SDK id from a previous conversation
779
+ initMappingDone = true;
780
+ console.log(`[sdk] session=${sessionId} preserving existing mapping โ†’ ${existingSdkId}`);
781
+ }
774
782
  } else {
775
- console.log(`[sdk] session=${sessionId} ignoring new sdk_id=${initMsg.session_id} to preserve existing mapping โ†’ ${existingSdkId}`);
783
+ console.log(`[sdk] session=${sessionId} ignoring retry init sdk_id=${initMsg.session_id} (mapping already set)`);
776
784
  }
777
785
  const oldMeta = this.activeSessions.get(sessionId);
778
786
  if (oldMeta) {
@@ -816,7 +824,7 @@ export class ClaudeAgentSdkProvider implements AIProvider {
816
824
  q.close();
817
825
  const { generator: earlyAuthGen, controller: earlyAuthCtrl } = createMessageChannel();
818
826
  const currentSdkId = getSessionMapping(sessionId);
819
- const canResume = currentSdkId && currentSdkId !== sessionId;
827
+ const canResume = !!currentSdkId;
820
828
  if (!canResume) earlyAuthCtrl.push(firstMsg);
821
829
  const retryOpts = { ...queryOptions, sessionId: undefined, resume: canResume ? currentSdkId : undefined, env: retryEnv };
822
830
  const rq = query({
@@ -844,7 +852,7 @@ export class ClaudeAgentSdkProvider implements AIProvider {
844
852
  q.close();
845
853
  const { generator: switchGen, controller: switchCtrl } = createMessageChannel();
846
854
  const currentSdkId = getSessionMapping(sessionId);
847
- const canResume = currentSdkId && currentSdkId !== sessionId;
855
+ const canResume = !!currentSdkId;
848
856
  if (!canResume) switchCtrl.push(firstMsg);
849
857
  const retryOpts = { ...queryOptions, sessionId: undefined, resume: canResume ? currentSdkId : undefined, env: switchEnv };
850
858
  const rq = query({
@@ -995,7 +1003,7 @@ export class ClaudeAgentSdkProvider implements AIProvider {
995
1003
  q.close();
996
1004
  const { generator: authRetryGen, controller: authRetryCtrl } = createMessageChannel();
997
1005
  const currentSdkId = getSessionMapping(sessionId);
998
- const canResume = currentSdkId && currentSdkId !== sessionId;
1006
+ const canResume = !!currentSdkId;
999
1007
  if (!canResume) authRetryCtrl.push(firstMsg);
1000
1008
  const retryOpts = { ...queryOptions, sessionId: undefined, resume: canResume ? currentSdkId : undefined, env: retryEnv };
1001
1009
  const rq = query({
@@ -1050,7 +1058,7 @@ export class ClaudeAgentSdkProvider implements AIProvider {
1050
1058
  const rlRetryEnv = this.buildQueryEnv(meta.projectPath, account);
1051
1059
  const { generator: rlRetryGen, controller: rlRetryCtrl } = createMessageChannel();
1052
1060
  const rlCurrentSdkId = getSessionMapping(sessionId);
1053
- const rlCanResume = rlCurrentSdkId && rlCurrentSdkId !== sessionId;
1061
+ const rlCanResume = !!rlCurrentSdkId;
1054
1062
  if (!rlCanResume) rlRetryCtrl.push(firstMsg);
1055
1063
  const retryOpts = { ...queryOptions, sessionId: undefined, resume: rlCanResume ? rlCurrentSdkId : undefined, env: rlRetryEnv };
1056
1064
  const rq = query({
@@ -1149,7 +1157,7 @@ export class ClaudeAgentSdkProvider implements AIProvider {
1149
1157
  const rlRetryEnv = this.buildQueryEnv(meta.projectPath, account);
1150
1158
  const { generator: rlRetryGen, controller: rlRetryCtrl } = createMessageChannel();
1151
1159
  const rlCurrentSdkId2 = getSessionMapping(sessionId);
1152
- const rlCanResume2 = rlCurrentSdkId2 && rlCurrentSdkId2 !== sessionId;
1160
+ const rlCanResume2 = !!rlCurrentSdkId2;
1153
1161
  if (!rlCanResume2) rlRetryCtrl.push(firstMsg);
1154
1162
  const retryOpts = { ...queryOptions, sessionId: undefined, resume: rlCanResume2 ? rlCurrentSdkId2 : undefined, env: rlRetryEnv };
1155
1163
  const rq = query({
@@ -1180,7 +1188,7 @@ export class ClaudeAgentSdkProvider implements AIProvider {
1180
1188
  const retryEnv = this.buildQueryEnv(meta.projectPath, refreshedAccount);
1181
1189
  const { generator: authRetryGen2, controller: authRetryCtrl2 } = createMessageChannel();
1182
1190
  const authCurrentSdkId2 = getSessionMapping(sessionId);
1183
- const authCanResume2 = authCurrentSdkId2 && authCurrentSdkId2 !== sessionId;
1191
+ const authCanResume2 = !!authCurrentSdkId2;
1184
1192
  if (!authCanResume2) authRetryCtrl2.push(firstMsg);
1185
1193
  const retryOpts = { ...queryOptions, sessionId: undefined, resume: authCanResume2 ? authCurrentSdkId2 : undefined, env: retryEnv };
1186
1194
  const rq = query({
@@ -486,7 +486,23 @@ export function getSessionProjectPath(ppmId: string): string | null {
486
486
  return row?.project_path ?? null;
487
487
  }
488
488
 
489
- export function setSessionMapping(ppmId: string, sdkId: string, projectName?: string, projectPath?: string): void {
489
+ /**
490
+ * Set session mapping. By default, refuses to overwrite an existing real SDK ID
491
+ * (one that differs from both ppmId and the new sdkId) to prevent orphaning JSONL history.
492
+ * Pass force=true to override (e.g. session delete + recreate).
493
+ */
494
+ export function setSessionMapping(ppmId: string, sdkId: string, projectName?: string, projectPath?: string, force = false): void {
495
+ if (!force) {
496
+ const existing = getSessionMapping(ppmId);
497
+ if (existing && existing !== ppmId && existing !== sdkId) {
498
+ console.warn(`[db] Refusing to overwrite session mapping ${ppmId} โ†’ ${existing} with ${sdkId} (use force=true to override)`);
499
+ // Still update project metadata even when refusing sdk_id overwrite
500
+ getDb().query(
501
+ "UPDATE session_map SET project_name = COALESCE(?, session_map.project_name), project_path = COALESCE(?, session_map.project_path) WHERE ppm_id = ?",
502
+ ).run(projectName ?? null, projectPath ?? null, ppmId);
503
+ return;
504
+ }
505
+ }
490
506
  getDb().query(
491
507
  "INSERT INTO session_map (ppm_id, sdk_id, project_name, project_path) VALUES (?, ?, ?, ?) ON CONFLICT(ppm_id) DO UPDATE SET sdk_id = excluded.sdk_id, project_name = COALESCE(excluded.project_name, session_map.project_name), project_path = COALESCE(excluded.project_path, session_map.project_path)",
492
508
  ).run(ppmId, sdkId, projectName ?? null, projectPath ?? null);
@@ -5,6 +5,7 @@ import {
5
5
  getPairingByChatId,
6
6
  createPairingRequest,
7
7
  getSessionTitles,
8
+ getPinnedSessionIds,
8
9
  getApprovedPairedChats,
9
10
  } from "../db.service.ts";
10
11
  import { PPMBotTelegram } from "./ppmbot-telegram.ts";
@@ -159,7 +160,7 @@ class PPMBotService {
159
160
  case "start": await this.cmdStart(chatId); break;
160
161
  case "project": await this.cmdProject(chatId, cmd.args); break;
161
162
  case "new": await this.cmdNew(chatId); break;
162
- case "sessions": await this.cmdSessions(chatId); break;
163
+ case "sessions": await this.cmdSessions(chatId, cmd.args); break;
163
164
  case "resume": await this.cmdResume(chatId, cmd.args); break;
164
165
  case "status": await this.cmdStatus(chatId); break;
165
166
  case "stop": await this.cmdStop(chatId); break;
@@ -254,29 +255,73 @@ class PPMBotService {
254
255
  );
255
256
  }
256
257
 
257
- private async cmdSessions(chatId: string): Promise<void> {
258
- const sessions = this.sessions.listRecentSessions(chatId, 10);
259
- if (sessions.length === 0) {
260
- await this.telegram!.sendMessage(Number(chatId), "No recent sessions.");
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.");
261
274
  return;
262
275
  }
263
276
 
264
- // Fetch titles for all sessions
265
- const titles = getSessionTitles(sessions.map((s) => s.session_id));
277
+ // Enrich with titles and pin status
278
+ const titles = getSessionTitles(filtered.map((s) => s.session_id));
279
+ const pinnedIds = getPinnedSessionIds();
266
280
 
267
- let text = "<b>Recent Sessions</b>\n\n";
268
- sessions.forEach((s, i) => {
269
- const active = s.is_active ? " โฌค" : "";
270
- const title = titles[s.session_id]?.replace(/^\[PPM\]\s*/, "") || "";
271
- const preview = title ? ` โ€” ${escapeHtml(title.slice(0, 40))}` : "";
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>";
272
310
  const date = new Date(s.last_message_at * 1000).toLocaleString(undefined, {
273
311
  month: "short", day: "numeric", hour: "2-digit", minute: "2-digit",
274
312
  });
275
313
  const sid = s.session_id.slice(0, 8);
276
- text += `${i + 1}. <b>${escapeHtml(s.project_name)}</b>${preview}${active}\n`;
314
+ const num = start + i + 1;
315
+
316
+ text += `${pin}${num}. ${title}${activeDot}\n`;
277
317
  text += ` <code>${sid}</code> ยท ${date}\n\n`;
278
318
  });
279
- text += "Resume: /resume &lt;number&gt;";
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
+
280
325
  await this.telegram!.sendMessage(Number(chatId), text);
281
326
  }
282
327
 
@@ -323,14 +368,12 @@ class PPMBotService {
323
368
  }
324
369
 
325
370
  private async cmdMemory(chatId: string): Promise<void> {
326
- const active = this.sessions.getActiveSession(chatId);
327
- const project = active?.projectName ?? "_global";
328
- const memories = this.memory.getSummary(project);
371
+ const memories = this.memory.getSummary("_global");
329
372
  if (memories.length === 0) {
330
- await this.telegram!.sendMessage(Number(chatId), "No memories stored for this project.");
373
+ await this.telegram!.sendMessage(Number(chatId), "No memories stored. Use /remember to add.");
331
374
  return;
332
375
  }
333
- let text = `<b>Memory โ€” ${escapeHtml(project)}</b>\n\n`;
376
+ let text = "<b>Memory</b> (cross-project)\n\n";
334
377
  for (const mem of memories) {
335
378
  text += `โ€ข [${mem.category}] ${escapeHtml(mem.content)}\n`;
336
379
  }
@@ -342,9 +385,7 @@ class PPMBotService {
342
385
  await this.telegram!.sendMessage(Number(chatId), "Usage: /forget &lt;topic&gt;");
343
386
  return;
344
387
  }
345
- const active = this.sessions.getActiveSession(chatId);
346
- const project = active?.projectName ?? "_global";
347
- const count = this.memory.forget(project, args);
388
+ const count = this.memory.forget("_global", args);
348
389
  await this.telegram!.sendMessage(Number(chatId), `Forgot ${count} memor${count === 1 ? "y" : "ies"} โœ“`);
349
390
  }
350
391
 
@@ -354,9 +395,8 @@ class PPMBotService {
354
395
  return;
355
396
  }
356
397
  const active = this.sessions.getActiveSession(chatId);
357
- const project = active?.projectName ?? "_global";
358
- this.memory.saveOne(project, args, "fact", active?.sessionId);
359
- await this.telegram!.sendMessage(Number(chatId), "Remembered โœ“");
398
+ this.memory.saveOne("_global", args, "fact", active?.sessionId);
399
+ await this.telegram!.sendMessage(Number(chatId), "Remembered โœ“ (cross-project)");
360
400
  }
361
401
 
362
402
  private async cmdRestart(chatId: string): Promise<void> {
@@ -427,8 +467,8 @@ class PPMBotService {
427
467
  /start โ€” Greeting + list projects
428
468
  /project &lt;name&gt; โ€” Switch/list projects
429
469
  /new โ€” Fresh session (current project)
430
- /sessions โ€” List recent sessions
431
- /resume &lt;n&gt; โ€” Resume session #n
470
+ /sessions [page] โ€” List sessions (current project)
471
+ /resume &lt;n or id&gt; โ€” Resume session
432
472
  /status โ€” Current project/session info
433
473
  /stop โ€” End current session
434
474
  /memory โ€” Show project memories
@@ -495,6 +535,15 @@ class PPMBotService {
495
535
  systemPrompt += memorySection;
496
536
  }
497
537
 
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.`;
546
+
498
547
  // Send message to AI (prepend system prompt + memory context)
499
548
  const opts: SendMessageOpts = {
500
549
  permissionMode: (config?.permission_mode ?? "bypassPermissions") as PermissionMode,