@hienlh/ppm 0.9.47 → 0.9.49

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,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.9.49] - 2026-04-07
4
+
5
+ ### Fixed
6
+ - **/resume accepts session ID prefix**: `/resume fdc4ddaa` now works alongside `/resume 2` (index). Matches session by ID prefix from `/sessions` list.
7
+ - **/restart actually restarts**: Server now exits with code 42 (restart signal) instead of 0 (clean exit). Supervisor recognizes code 42 and respawns immediately without backoff.
8
+ - **Restart notification delivered**: Supervisor respawns after `/restart`, new server sends "PPM v0.9.49 restarted successfully." to all paired chats.
9
+ - **/project lists all projects**: `getProjectNames()` now merges config projects + unique project names from session history. Previously returned empty when no projects in config.
10
+
11
+ ## [0.9.48] - 2026-04-06
12
+
13
+ ### Changed
14
+ - **Memory → Identity layer**: Stripped custom memory extraction, decay, periodic AI extraction prompts. PPMBot now stores only identity/preferences + explicit `/remember` facts. Contextual memory delegated to provider's native system (Claude Code MEMORY.md). `ppmbot-memory.ts` 333→111 LOC.
15
+ - **Removed "don't write memory" directive**: AI provider manages its own contextual memory naturally.
16
+
3
17
  ## [0.9.47] - 2026-04-06
4
18
 
5
19
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hienlh/ppm",
3
- "version": "0.9.47",
3
+ "version": "0.9.49",
4
4
  "description": "Personal Project Manager — mobile-first web IDE with AI assistance",
5
5
  "author": "hienlh",
6
6
  "license": "MIT",
@@ -1022,6 +1022,13 @@ export function getRecentPPMBotSessions(
1022
1022
  ).all(telegramChatId, limit) as PPMBotSessionRow[];
1023
1023
  }
1024
1024
 
1025
+ export function getDistinctPPMBotProjectNames(): string[] {
1026
+ const rows = getDb().query(
1027
+ "SELECT DISTINCT project_name FROM clawbot_sessions ORDER BY project_name",
1028
+ ).all() as { project_name: string }[];
1029
+ return rows.map((r) => r.project_name);
1030
+ }
1031
+
1025
1032
  // ---------------------------------------------------------------------------
1026
1033
  // PPMBot memory helpers
1027
1034
  // ---------------------------------------------------------------------------
@@ -1097,19 +1104,6 @@ export function deletePPMBotMemoriesByTopic(
1097
1104
  return matches.length;
1098
1105
  }
1099
1106
 
1100
- export function decayPPMBotMemories(): void {
1101
- getDb().query(
1102
- `UPDATE clawbot_memories
1103
- SET importance = importance * 0.95,
1104
- updated_at = unixepoch()
1105
- WHERE superseded_by IS NULL
1106
- AND category NOT IN ('preference', 'architecture')
1107
- AND updated_at < unixepoch() - 604800`,
1108
- ).run();
1109
- getDb().query(
1110
- `DELETE FROM clawbot_memories WHERE importance < 0.1 AND superseded_by IS NULL`,
1111
- ).run();
1112
- }
1113
1107
 
1114
1108
  // ---------------------------------------------------------------------------
1115
1109
  // PPMBot pairing helpers
@@ -4,51 +4,22 @@ import {
4
4
  getPPMBotMemories,
5
5
  supersedePPMBotMemory,
6
6
  deletePPMBotMemoriesByTopic,
7
- decayPPMBotMemories,
8
- getDb,
9
7
  } from "../db.service.ts";
10
- import { configService } from "../config.service.ts";
11
8
  import type {
12
9
  PPMBotMemoryCategory,
13
10
  MemoryRecallResult,
14
11
  } from "../../types/ppmbot.ts";
15
- import type { ProjectConfig } from "../../types/config.ts";
16
-
17
- /** Max memories per project before pruning */
18
- const MAX_MEMORIES_PER_PROJECT = 500;
19
-
20
- /** Fact extracted from AI response */
21
- interface ExtractedFact {
22
- content: string;
23
- category: PPMBotMemoryCategory;
24
- importance?: number;
25
- }
26
12
 
13
+ /**
14
+ * Lightweight memory layer for PPMBot.
15
+ *
16
+ * Stores identity, preferences, and explicit user facts (/remember).
17
+ * Contextual memory (decisions, architecture, etc.) is left to the
18
+ * AI provider's native memory system (e.g. Claude Code MEMORY.md).
19
+ */
27
20
  export class PPMBotMemory {
28
- /**
29
- * Recall relevant memories for a project.
30
- * If query provided, use FTS5 search. Otherwise return top by importance.
31
- */
32
- recall(project: string, query?: string, limit = 20): MemoryRecallResult[] {
33
- if (query) {
34
- const sanitized = this.sanitizeFtsQuery(query);
35
- if (sanitized) {
36
- try {
37
- const results = searchPPMBotMemories(project, sanitized, limit);
38
- return results.map((r) => ({
39
- id: r.id,
40
- content: r.content,
41
- category: r.category,
42
- importance: r.importance,
43
- project: r.project,
44
- rank: r.rank,
45
- }));
46
- } catch {
47
- // FTS query syntax error — fallback to importance-based
48
- }
49
- }
50
- }
51
-
21
+ /** Get all active memories for a project (+ _global) */
22
+ getSummary(project: string, limit = 30): MemoryRecallResult[] {
52
23
  const rows = getPPMBotMemories(project, limit);
53
24
  return rows.map((r) => ({
54
25
  id: r.id,
@@ -59,61 +30,7 @@ export class PPMBotMemory {
59
30
  }));
60
31
  }
61
32
 
62
- /**
63
- * Enhanced recall: include memories from mentioned projects.
64
- * Detects project names in message and fetches their memories too.
65
- */
66
- recallWithCrossProject(
67
- currentProject: string,
68
- query: string | undefined,
69
- message: string,
70
- limit = 20,
71
- ): MemoryRecallResult[] {
72
- const mainMemories = this.recall(currentProject, query, limit);
73
- const mentioned = this.detectMentionedProjects(message, currentProject);
74
-
75
- if (mentioned.length === 0) return mainMemories;
76
-
77
- const crossMemories: MemoryRecallResult[] = [];
78
- for (const proj of mentioned.slice(0, 3)) {
79
- const projMems = this.recall(proj, query, 5);
80
- crossMemories.push(...projMems.map((m) => ({ ...m, project: proj })));
81
- }
82
-
83
- return [...mainMemories, ...crossMemories].slice(0, limit);
84
- }
85
-
86
- /**
87
- * Save multiple extracted facts. Checks for duplicates via FTS
88
- * and supersedes old facts when new ones are similar.
89
- */
90
- save(project: string, facts: ExtractedFact[], sessionId?: string): number {
91
- let inserted = 0;
92
- for (const fact of facts) {
93
- if (!fact.content?.trim()) continue;
94
-
95
- const existingId = this.findSimilar(project, fact.content);
96
-
97
- const newId = insertPPMBotMemory(
98
- project,
99
- fact.content.trim(),
100
- fact.category || "fact",
101
- fact.importance ?? 1.0,
102
- sessionId,
103
- );
104
-
105
- if (existingId) {
106
- supersedePPMBotMemory(existingId, newId);
107
- }
108
-
109
- inserted++;
110
- }
111
-
112
- this.pruneExcess(project);
113
- return inserted;
114
- }
115
-
116
- /** Save a single fact immediately (from /remember command) */
33
+ /** Save a single fact (identity, /remember) */
117
34
  saveOne(
118
35
  project: string,
119
36
  content: string,
@@ -128,26 +45,14 @@ export class PPMBotMemory {
128
45
  return newId;
129
46
  }
130
47
 
131
- /** Delete memories matching a topic (from /forget command) */
48
+ /** Delete memories matching a topic (/forget) */
132
49
  forget(project: string, topic: string): number {
133
50
  const sanitized = this.sanitizeFtsQuery(topic);
134
51
  if (!sanitized) return 0;
135
52
  return deletePPMBotMemoriesByTopic(project, sanitized);
136
53
  }
137
54
 
138
- /** Get summary of all active memories for a project */
139
- getSummary(project: string, limit = 30): MemoryRecallResult[] {
140
- const rows = getPPMBotMemories(project, limit);
141
- return rows.map((r) => ({
142
- id: r.id,
143
- content: r.content,
144
- category: r.category,
145
- importance: r.importance,
146
- project: r.project,
147
- }));
148
- }
149
-
150
- /** Build system prompt section with recalled memories */
55
+ /** Build system prompt section with identity/preferences */
151
56
  buildRecallPrompt(memories: MemoryRecallResult[]): string {
152
57
  if (memories.length === 0) return "";
153
58
 
@@ -158,9 +63,7 @@ export class PPMBotMemory {
158
63
  grouped.get(cat)!.push(mem.content);
159
64
  }
160
65
 
161
- let prompt = "\n\n## Cross-Session Memory\n";
162
- prompt += "The following facts are recalled from previous sessions:\n\n";
163
-
66
+ let prompt = "\n\n## User Identity & Preferences\n";
164
67
  for (const [category, facts] of grouped) {
165
68
  prompt += `### ${category.charAt(0).toUpperCase() + category.slice(1)}s\n`;
166
69
  for (const fact of facts) {
@@ -168,126 +71,11 @@ export class PPMBotMemory {
168
71
  }
169
72
  prompt += "\n";
170
73
  }
171
-
172
- prompt += "Use these as context. Correct any that seem outdated.\n";
173
74
  return prompt;
174
75
  }
175
76
 
176
- /** Build the extraction prompt sent at session end */
177
- buildExtractionPrompt(): string {
178
- return `Summarize the key facts, decisions, and preferences from this conversation as a JSON array. Each entry:
179
- {"content": "the fact", "category": "fact|decision|preference|architecture|issue", "importance": 0.5-2.0}
180
-
181
- Rules:
182
- - Only include facts worth remembering across sessions
183
- - Skip ephemeral details (file line numbers, temp debug info)
184
- - Prefer concise, self-contained statements
185
- - Max 10 entries
186
- - Return ONLY the JSON array, no markdown fencing
187
-
188
- If nothing worth remembering, return []`;
189
- }
190
-
191
- /**
192
- * Parse the AI's extraction response into structured facts.
193
- * Handles: raw JSON array, markdown-fenced JSON, or graceful failure.
194
- */
195
- parseExtractionResponse(text: string): ExtractedFact[] {
196
- let cleaned = text.trim();
197
- if (cleaned.startsWith("```")) {
198
- cleaned = cleaned.replace(/^```\w*\n?/, "").replace(/\n?```$/, "");
199
- }
200
-
201
- try {
202
- const parsed = JSON.parse(cleaned);
203
- if (!Array.isArray(parsed)) return [];
204
-
205
- return parsed
206
- .filter(
207
- (item: unknown): item is Record<string, unknown> =>
208
- typeof item === "object" && item !== null && "content" in item,
209
- )
210
- .map((item) => ({
211
- content: String(item.content ?? ""),
212
- category: this.validateCategory(String(item.category ?? "fact")),
213
- importance: Math.max(0, Math.min(2, Number(item.importance ?? 1))),
214
- }))
215
- .filter((f) => f.content.length > 0);
216
- } catch {
217
- console.warn("[ppmbot-memory] Failed to parse extraction response");
218
- return [];
219
- }
220
- }
221
-
222
- /**
223
- * Regex-based fallback for memory extraction.
224
- * Used when AI extraction returns empty or fails.
225
- */
226
- extractiveMemoryFallback(conversationText: string): ExtractedFact[] {
227
- const facts: ExtractedFact[] = [];
228
- const patterns: Array<{ re: RegExp; category: PPMBotMemoryCategory }> = [
229
- { re: /(?:decided|chose|went with|picked|selected)\s+(.{10,100})/gi, category: "decision" },
230
- { re: /(?:prefer|always use|like to|rather)\s+(.{10,80})/gi, category: "preference" },
231
- { re: /(?:uses?|built with|stack is|powered by|database is)\s+(.{5,80})/gi, category: "architecture" },
232
- { re: /(?:bug|issue|problem|broken|fails?|error)\s+(?:with|in|when)\s+(.{10,100})/gi, category: "issue" },
233
- ];
234
- for (const { re, category } of patterns) {
235
- let match: RegExpExecArray | null;
236
- while ((match = re.exec(conversationText)) !== null) {
237
- const content = (match[1] || match[0]).trim();
238
- if (content.length > 10) {
239
- facts.push({ content, category, importance: 0.7 });
240
- }
241
- }
242
- }
243
- return facts.slice(0, 10);
244
- }
245
-
246
- /** Run importance decay on old memories */
247
- runDecay(): void {
248
- try {
249
- decayPPMBotMemories();
250
- } catch (err) {
251
- console.error("[ppmbot-memory] Decay error:", (err as Error).message);
252
- }
253
- }
254
-
255
- /** Remove excess memories beyond the per-project cap */
256
- pruneExcess(project: string, maxCount = MAX_MEMORIES_PER_PROJECT): void {
257
- try {
258
- const count = (getDb().query(
259
- `SELECT COUNT(*) as cnt FROM clawbot_memories
260
- WHERE project = ? AND superseded_by IS NULL`,
261
- ).get(project) as { cnt: number })?.cnt ?? 0;
262
-
263
- if (count <= maxCount) return;
264
-
265
- const excess = count - maxCount;
266
- getDb().query(
267
- `DELETE FROM clawbot_memories WHERE id IN (
268
- SELECT id FROM clawbot_memories
269
- WHERE project = ? AND superseded_by IS NULL
270
- ORDER BY importance ASC, updated_at ASC
271
- LIMIT ?
272
- )`,
273
- ).run(project, excess);
274
- } catch (err) {
275
- console.error("[ppmbot-memory] Prune error:", (err as Error).message);
276
- }
277
- }
278
-
279
77
  // ── Private ─────────────────────────────────────────────────────
280
78
 
281
- /** Detect project names mentioned in user message */
282
- private detectMentionedProjects(message: string, currentProject: string): string[] {
283
- const allProjects = configService.get("projects") as ProjectConfig[];
284
- if (!allProjects?.length) return [];
285
- return allProjects
286
- .filter((p) => p.name !== currentProject)
287
- .filter((p) => message.toLowerCase().includes(p.name.toLowerCase()))
288
- .map((p) => p.name);
289
- }
290
-
291
79
  /** Find an existing memory similar to the given content */
292
80
  private findSimilar(project: string, content: string): number | null {
293
81
  const words = content
@@ -320,14 +108,4 @@ If nothing worth remembering, return []`;
320
108
  .replace(/\s+/g, " ")
321
109
  .trim();
322
110
  }
323
-
324
- /** Validate category string against known values */
325
- private validateCategory(cat: string): PPMBotMemoryCategory {
326
- const valid: PPMBotMemoryCategory[] = [
327
- "fact", "decision", "preference", "architecture", "issue",
328
- ];
329
- return valid.includes(cat as PPMBotMemoryCategory)
330
- ? (cat as PPMBotMemoryCategory)
331
- : "fact";
332
- }
333
111
  }
@@ -4,7 +4,6 @@ import {
4
4
  isPairedChat,
5
5
  getPairingByChatId,
6
6
  createPairingRequest,
7
- approvePairing,
8
7
  getSessionTitles,
9
8
  getApprovedPairedChats,
10
9
  } from "../db.service.ts";
@@ -41,12 +40,6 @@ class PPMBotService {
41
40
  /** Chat IDs that just received identity onboarding prompt */
42
41
  private identityPending = new Set<string>();
43
42
 
44
- /** Message count per session for periodic memory save */
45
- private messageCount = new Map<string, number>();
46
-
47
- /** Interval (messages) between automatic memory saves */
48
- private readonly MEMORY_SAVE_INTERVAL = 5;
49
-
50
43
  // ── Lifecycle ─────────────────────────────────────────────────
51
44
 
52
45
  async start(): Promise<void> {
@@ -66,9 +59,6 @@ class PPMBotService {
66
59
  this.telegram = new PPMBotTelegram(telegramConfig.bot_token);
67
60
  this.running = true;
68
61
 
69
- // Run memory decay on startup
70
- this.memory.runDecay();
71
-
72
62
  // Start polling (non-blocking)
73
63
  this.telegram.startPolling((update) => this.handleUpdate(update));
74
64
 
@@ -92,7 +82,6 @@ class PPMBotService {
92
82
  this.processing.clear();
93
83
  this.messageQueue.clear();
94
84
  this.identityPending.clear();
95
- this.messageCount.clear();
96
85
 
97
86
  console.log("[ppmbot] Stopped");
98
87
  }
@@ -247,7 +236,6 @@ class PPMBotService {
247
236
  return;
248
237
  }
249
238
 
250
- await this.saveSessionMemory(chatId);
251
239
  const session = await this.sessions.switchProject(chatId, args);
252
240
  await this.telegram!.sendMessage(
253
241
  Number(chatId),
@@ -256,7 +244,6 @@ class PPMBotService {
256
244
  }
257
245
 
258
246
  private async cmdNew(chatId: string): Promise<void> {
259
- await this.saveSessionMemory(chatId);
260
247
  const active = this.sessions.getActiveSession(chatId);
261
248
  const projectName = active?.projectName;
262
249
  await this.sessions.closeSession(chatId);
@@ -294,13 +281,19 @@ class PPMBotService {
294
281
  }
295
282
 
296
283
  private async cmdResume(chatId: string, args: string): Promise<void> {
297
- const index = parseInt(args, 10);
298
- if (!index || index < 1) {
299
- await this.telegram!.sendMessage(Number(chatId), "Usage: /resume &lt;number&gt;");
284
+ if (!args.trim()) {
285
+ await this.telegram!.sendMessage(Number(chatId), "Usage: /resume &lt;number or session-id&gt;");
300
286
  return;
301
287
  }
302
- await this.saveSessionMemory(chatId);
303
- const session = await this.sessions.resumeSessionById(chatId, index);
288
+
289
+ // Support both index (e.g. "2") and session ID prefix (e.g. "fdc4ddaa")
290
+ const index = parseInt(args, 10);
291
+ const isIndex = !isNaN(index) && index >= 1 && String(index) === args.trim();
292
+
293
+ const session = isIndex
294
+ ? await this.sessions.resumeSessionById(chatId, index)
295
+ : await this.sessions.resumeSessionByIdPrefix(chatId, args.trim());
296
+
304
297
  if (!session) {
305
298
  await this.telegram!.sendMessage(Number(chatId), "Session not found.");
306
299
  return;
@@ -325,7 +318,6 @@ class PPMBotService {
325
318
  }
326
319
 
327
320
  private async cmdStop(chatId: string): Promise<void> {
328
- await this.saveSessionMemory(chatId);
329
321
  await this.sessions.closeSession(chatId);
330
322
  await this.telegram!.sendMessage(Number(chatId), "Session ended ✓");
331
323
  }
@@ -383,8 +375,8 @@ class PPMBotService {
383
375
  const markerPath = join(homedir(), ".ppm", "restart-notify.json");
384
376
  writeFileSync(markerPath, JSON.stringify({ chatIds, ts: Date.now() }));
385
377
 
386
- console.log("[ppmbot] Restart requested via Telegram, exiting...");
387
- process.exit(0);
378
+ console.log("[ppmbot] Restart requested via Telegram, exiting with code 42...");
379
+ process.exit(42);
388
380
  }, 500);
389
381
  }
390
382
 
@@ -493,16 +485,11 @@ class PPMBotService {
493
485
  this.titledSessions.add(session.sessionId);
494
486
  }
495
487
 
496
- // Recall memories (with cross-project detection)
497
- const memories = this.memory.recallWithCrossProject(
498
- session.projectName,
499
- text,
500
- text,
501
- );
488
+ // Recall identity & preferences (global + project)
489
+ const memories = this.memory.getSummary(session.projectName);
502
490
 
503
- // Build system prompt with memory + core directives
504
- const coreDirective = "IMPORTANT: Do NOT write files, save to MEMORY.md, or manage your own memory/identity files. Your memory is managed externally by PPMBot. Just respond naturally.";
505
- let systemPrompt = coreDirective + "\n\n" + (config?.system_prompt ?? "");
491
+ // Build system prompt with identity/preferences
492
+ let systemPrompt = config?.system_prompt ?? "";
506
493
  const memorySection = this.memory.buildRecallPrompt(memories);
507
494
  if (memorySection) {
508
495
  systemPrompt += memorySection;
@@ -546,15 +533,6 @@ class PPMBotService {
546
533
  },
547
534
  );
548
535
 
549
- // Periodic memory extraction — fire-and-forget every N messages
550
- const count = (this.messageCount.get(session.sessionId) ?? 0) + 1;
551
- this.messageCount.set(session.sessionId, count);
552
- if (count % this.MEMORY_SAVE_INTERVAL === 0) {
553
- this.saveSessionMemory(chatId).catch((err) =>
554
- console.warn("[ppmbot] Periodic memory save failed:", (err as Error).message),
555
- );
556
- }
557
-
558
536
  // Check context window — auto-rotate if near limit
559
537
  if (
560
538
  result.contextWindowPct != null &&
@@ -581,48 +559,14 @@ class PPMBotService {
581
559
  }
582
560
  }
583
561
 
584
- // ── Memory Save / Session Rotate ────────────────────────────────
585
-
586
- private async saveSessionMemory(chatId: string): Promise<void> {
587
- const session = this.sessions.getActiveSession(chatId);
588
- if (!session) return;
589
-
590
- try {
591
- const extractionPrompt = this.memory.buildExtractionPrompt();
592
- const events = chatService.sendMessage(
593
- session.providerId,
594
- session.sessionId,
595
- extractionPrompt,
596
- { permissionMode: "bypassPermissions" },
597
- );
598
-
599
- let responseText = "";
600
- for await (const event of events) {
601
- if (event.type === "text") responseText += event.content;
602
- }
603
-
604
- const facts = this.memory.parseExtractionResponse(responseText);
605
- if (facts.length > 0) {
606
- const count = this.memory.save(session.projectName, facts, session.sessionId);
607
- console.log(`[ppmbot] Saved ${count} memories for ${session.projectName}`);
608
- } else {
609
- // Fallback: regex-based extraction
610
- // Note: we don't have conversation history text here easily,
611
- // so regex fallback only triggers when AI extraction fails
612
- console.log("[ppmbot] No memories extracted via AI");
613
- }
614
- } catch (err) {
615
- console.warn("[ppmbot] Memory save failed:", (err as Error).message);
616
- }
617
- }
562
+ // ── Session Rotate ──────────────────────────────────────────────
618
563
 
619
564
  private async rotateSession(chatId: string, projectName: string): Promise<void> {
620
- await this.saveSessionMemory(chatId);
621
565
  await this.sessions.closeSession(chatId);
622
566
  await this.sessions.getOrCreateSession(chatId, projectName);
623
567
  await this.telegram?.sendMessage(
624
568
  Number(chatId),
625
- "<i>Context window near limit — starting fresh session. Memories saved.</i>",
569
+ "<i>Context window near limit — starting fresh session.</i>",
626
570
  );
627
571
  }
628
572
 
@@ -9,6 +9,7 @@ import {
9
9
  deactivatePPMBotSession,
10
10
  touchPPMBotSession,
11
11
  getRecentPPMBotSessions,
12
+ getDistinctPPMBotProjectNames,
12
13
  setSessionTitle,
13
14
  } from "../db.service.ts";
14
15
  import type { PPMBotActiveSession, PPMBotSessionRow } from "../../types/ppmbot.ts";
@@ -93,6 +94,23 @@ export class PPMBotSessionManager {
93
94
  return this.resumeFromDb(chatId, target, project);
94
95
  }
95
96
 
97
+ /** Resume a session by session ID prefix match */
98
+ async resumeSessionByIdPrefix(
99
+ chatId: string,
100
+ prefix: string,
101
+ ): Promise<PPMBotActiveSession | null> {
102
+ const sessions = getRecentPPMBotSessions(chatId, 20);
103
+ const target = sessions.find((s) => s.session_id.startsWith(prefix));
104
+ if (!target) return null;
105
+
106
+ await this.closeSession(chatId);
107
+
108
+ const project = this.resolveProject(target.project_name);
109
+ if (!project) return null;
110
+
111
+ return this.resumeFromDb(chatId, target, project);
112
+ }
113
+
96
114
  /**
97
115
  * Resolve a project name against configured projects.
98
116
  * Case-insensitive, supports prefix matching.
@@ -119,10 +137,12 @@ export class PPMBotSessionManager {
119
137
  setSessionTitle(sessionId, title);
120
138
  }
121
139
 
122
- /** Get list of available project names (for /start greeting) */
140
+ /** Get list of available project names (config + sessions history) */
123
141
  getProjectNames(): string[] {
124
- const projects = configService.get("projects") as ProjectConfig[];
125
- return projects?.map((p) => p.name) ?? [];
142
+ const configured = (configService.get("projects") as ProjectConfig[])?.map((p) => p.name) ?? [];
143
+ const fromSessions = getDistinctPPMBotProjectNames();
144
+ const merged = new Set([...configured, ...fromSessions]);
145
+ return [...merged].sort();
126
146
  }
127
147
 
128
148
  // ── Private ─────────────────────────────────────────────────────
@@ -130,11 +130,17 @@ export async function spawnServer(
130
130
  const exitCode = await serverChild.exited;
131
131
  serverChild = null;
132
132
 
133
- if (exitCode === 0 || shuttingDown) {
133
+ if (exitCode === 0 && shuttingDown) {
134
134
  log("INFO", `Server exited cleanly (code ${exitCode})`);
135
135
  return;
136
136
  }
137
137
 
138
+ // Exit code 42 = restart requested (e.g. /restart from Telegram)
139
+ if (exitCode === 42 || (exitCode === 0 && !shuttingDown)) {
140
+ log("INFO", `Server restart requested (code ${exitCode}), respawning immediately`);
141
+ return spawnServer(serverArgs, logFd);
142
+ }
143
+
138
144
  // SIGUSR2 restart — skip backoff, respawn immediately
139
145
  if (serverRestartRequested) {
140
146
  serverRestartRequested = false;