@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 +15 -0
- package/package.json +1 -1
- package/src/cli/commands/bot-cmd.ts +96 -0
- package/src/index.ts +3 -0
- package/src/providers/claude-agent-sdk.ts +23 -15
- package/src/services/db.service.ts +17 -1
- package/src/services/ppmbot/ppmbot-service.ts +76 -27
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
|
@@ -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
|
|
767
|
-
//
|
|
768
|
-
//
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
//
|
|
265
|
-
const titles = getSessionTitles(
|
|
277
|
+
// Enrich with titles and pin status
|
|
278
|
+
const titles = getSessionTitles(filtered.map((s) => s.session_id));
|
|
279
|
+
const pinnedIds = getPinnedSessionIds();
|
|
266
280
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
const
|
|
270
|
-
const
|
|
271
|
-
|
|
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
|
-
|
|
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
|
-
|
|
319
|
+
|
|
320
|
+
text += "Resume: /resume <n> or /resume <id>";
|
|
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
|
|
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
|
|
373
|
+
await this.telegram!.sendMessage(Number(chatId), "No memories stored. Use /remember to add.");
|
|
331
374
|
return;
|
|
332
375
|
}
|
|
333
|
-
let text =
|
|
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 <topic>");
|
|
343
386
|
return;
|
|
344
387
|
}
|
|
345
|
-
const
|
|
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
|
-
|
|
358
|
-
this.
|
|
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 <name> โ Switch/list projects
|
|
429
469
|
/new โ Fresh session (current project)
|
|
430
|
-
/sessions โ List
|
|
431
|
-
/resume <n> โ Resume session
|
|
470
|
+
/sessions [page] โ List sessions (current project)
|
|
471
|
+
/resume <n or id> โ 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,
|