@hienlh/ppm 0.9.50 → 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 +17 -0
- package/package.json +1 -1
- package/src/cli/commands/bot-cmd.ts +469 -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 +55 -11
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
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
|
+
|
|
10
|
+
## [0.9.51] - 2026-04-07
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **`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).
|
|
14
|
+
- **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.
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
- **/memory, /remember, /forget**: Now always use `_global` project scope (cross-project).
|
|
18
|
+
- **ppmbot-memory tests updated**: Removed stale tests referencing deleted methods (`recall`, `save`, `parseExtractionResponse`, `extractiveMemoryFallback`).
|
|
19
|
+
|
|
3
20
|
## [0.9.50] - 2026-04-07
|
|
4
21
|
|
|
5
22
|
### Changed
|
package/package.json
CHANGED
|
@@ -0,0 +1,469 @@
|
|
|
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
|
+
* 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.
|
|
37
|
+
*
|
|
38
|
+
* All session/project commands auto-detect the paired Telegram chat.
|
|
39
|
+
*/
|
|
40
|
+
export function registerBotCommands(program: Command): void {
|
|
41
|
+
const bot = program.command("bot").description("PPMBot utilities");
|
|
42
|
+
|
|
43
|
+
registerMemoryCommands(bot);
|
|
44
|
+
registerProjectCommands(bot);
|
|
45
|
+
registerSessionCommands(bot);
|
|
46
|
+
registerMiscCommands(bot);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── Memory ──────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
function registerMemoryCommands(bot: Command): void {
|
|
52
|
+
const mem = bot.command("memory").description("Manage cross-project memories");
|
|
53
|
+
|
|
54
|
+
mem
|
|
55
|
+
.command("save <content>")
|
|
56
|
+
.description("Save a cross-project memory")
|
|
57
|
+
.option("-c, --category <cat>", "Category: fact|preference|decision|architecture|issue", "fact")
|
|
58
|
+
.option("-s, --session <id>", "Session ID (optional)")
|
|
59
|
+
.action(async (content: string, opts: { category: string; session?: string }) => {
|
|
60
|
+
try {
|
|
61
|
+
const { PPMBotMemory } = await import("../../services/ppmbot/ppmbot-memory.ts");
|
|
62
|
+
const memory = new PPMBotMemory();
|
|
63
|
+
const validCategories = ["fact", "decision", "preference", "architecture", "issue"];
|
|
64
|
+
const category = validCategories.includes(opts.category) ? opts.category : "fact";
|
|
65
|
+
const id = memory.saveOne("_global", content, category as any, opts.session);
|
|
66
|
+
console.log(`${C.green}✓${C.reset} Saved memory #${id} [${category}]: ${content}`);
|
|
67
|
+
} catch (e) {
|
|
68
|
+
console.error(`${C.red}✗${C.reset} ${e instanceof Error ? e.message : String(e)}`);
|
|
69
|
+
process.exit(1);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
mem
|
|
74
|
+
.command("list")
|
|
75
|
+
.description("List active cross-project memories")
|
|
76
|
+
.option("-l, --limit <n>", "Max results", "30")
|
|
77
|
+
.option("--json", "Output as JSON")
|
|
78
|
+
.action(async (opts: { limit: string; json?: boolean }) => {
|
|
79
|
+
try {
|
|
80
|
+
const { PPMBotMemory } = await import("../../services/ppmbot/ppmbot-memory.ts");
|
|
81
|
+
const memory = new PPMBotMemory();
|
|
82
|
+
const results = memory.getSummary("_global", Number(opts.limit) || 30);
|
|
83
|
+
|
|
84
|
+
if (opts.json) {
|
|
85
|
+
console.log(JSON.stringify(results, null, 2));
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (results.length === 0) {
|
|
90
|
+
console.log(`${C.dim}No memories found.${C.reset}`);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
for (const r of results) {
|
|
95
|
+
const catTag = `${C.cyan}[${r.category}]${C.reset}`;
|
|
96
|
+
console.log(` #${r.id} ${catTag} ${r.content}`);
|
|
97
|
+
}
|
|
98
|
+
console.log(`\n${C.dim}${results.length} memories${C.reset}`);
|
|
99
|
+
} catch (e) {
|
|
100
|
+
console.error(`${C.red}✗${C.reset} ${e instanceof Error ? e.message : String(e)}`);
|
|
101
|
+
process.exit(1);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
mem
|
|
106
|
+
.command("forget <topic>")
|
|
107
|
+
.description("Delete memories matching a topic (FTS5 search)")
|
|
108
|
+
.action(async (topic: string) => {
|
|
109
|
+
try {
|
|
110
|
+
const { PPMBotMemory } = await import("../../services/ppmbot/ppmbot-memory.ts");
|
|
111
|
+
const memory = new PPMBotMemory();
|
|
112
|
+
const deleted = memory.forget("_global", topic);
|
|
113
|
+
if (deleted > 0) {
|
|
114
|
+
console.log(`${C.green}✓${C.reset} Deleted ${deleted} memory(s) matching "${topic}"`);
|
|
115
|
+
} else {
|
|
116
|
+
console.log(`${C.yellow}No memories matched "${topic}"${C.reset}`);
|
|
117
|
+
}
|
|
118
|
+
} catch (e) {
|
|
119
|
+
console.error(`${C.red}✗${C.reset} ${e instanceof Error ? e.message : String(e)}`);
|
|
120
|
+
process.exit(1);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
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
|
+
}
|
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);
|
|
@@ -368,14 +368,12 @@ class PPMBotService {
|
|
|
368
368
|
}
|
|
369
369
|
|
|
370
370
|
private async cmdMemory(chatId: string): Promise<void> {
|
|
371
|
-
const
|
|
372
|
-
const project = active?.projectName ?? "_global";
|
|
373
|
-
const memories = this.memory.getSummary(project);
|
|
371
|
+
const memories = this.memory.getSummary("_global");
|
|
374
372
|
if (memories.length === 0) {
|
|
375
|
-
await this.telegram!.sendMessage(Number(chatId), "No memories stored
|
|
373
|
+
await this.telegram!.sendMessage(Number(chatId), "No memories stored. Use /remember to add.");
|
|
376
374
|
return;
|
|
377
375
|
}
|
|
378
|
-
let text =
|
|
376
|
+
let text = "<b>Memory</b> (cross-project)\n\n";
|
|
379
377
|
for (const mem of memories) {
|
|
380
378
|
text += `• [${mem.category}] ${escapeHtml(mem.content)}\n`;
|
|
381
379
|
}
|
|
@@ -387,9 +385,7 @@ class PPMBotService {
|
|
|
387
385
|
await this.telegram!.sendMessage(Number(chatId), "Usage: /forget <topic>");
|
|
388
386
|
return;
|
|
389
387
|
}
|
|
390
|
-
const
|
|
391
|
-
const project = active?.projectName ?? "_global";
|
|
392
|
-
const count = this.memory.forget(project, args);
|
|
388
|
+
const count = this.memory.forget("_global", args);
|
|
393
389
|
await this.telegram!.sendMessage(Number(chatId), `Forgot ${count} memor${count === 1 ? "y" : "ies"} ✓`);
|
|
394
390
|
}
|
|
395
391
|
|
|
@@ -399,9 +395,8 @@ class PPMBotService {
|
|
|
399
395
|
return;
|
|
400
396
|
}
|
|
401
397
|
const active = this.sessions.getActiveSession(chatId);
|
|
402
|
-
|
|
403
|
-
this.
|
|
404
|
-
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)");
|
|
405
400
|
}
|
|
406
401
|
|
|
407
402
|
private async cmdRestart(chatId: string): Promise<void> {
|
|
@@ -485,6 +480,52 @@ class PPMBotService {
|
|
|
485
480
|
await this.telegram!.sendMessage(Number(chatId), text);
|
|
486
481
|
}
|
|
487
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
|
+
|
|
488
529
|
// ── Chat Message Pipeline ───────────────────────────────────────
|
|
489
530
|
|
|
490
531
|
private async handleMessage(chatId: string, text: string): Promise<void> {
|
|
@@ -540,6 +581,9 @@ class PPMBotService {
|
|
|
540
581
|
systemPrompt += memorySection;
|
|
541
582
|
}
|
|
542
583
|
|
|
584
|
+
// Instruct AI to use CLI tools for session/project/memory management
|
|
585
|
+
systemPrompt += this.buildToolsPrompt(chatId);
|
|
586
|
+
|
|
543
587
|
// Send message to AI (prepend system prompt + memory context)
|
|
544
588
|
const opts: SendMessageOpts = {
|
|
545
589
|
permissionMode: (config?.permission_mode ?? "bypassPermissions") as PermissionMode,
|