@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
|
@@ -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
|
-
|
|
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 (
|
|
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
|
-
/**
|
|
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##
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
await this.telegram!.sendMessage(Number(chatId), "Usage: /resume <number>");
|
|
284
|
+
if (!args.trim()) {
|
|
285
|
+
await this.telegram!.sendMessage(Number(chatId), "Usage: /resume <number or session-id>");
|
|
300
286
|
return;
|
|
301
287
|
}
|
|
302
|
-
|
|
303
|
-
|
|
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(
|
|
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
|
|
497
|
-
const memories = this.memory.
|
|
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
|
|
504
|
-
|
|
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
|
-
// ──
|
|
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
|
|
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 (
|
|
140
|
+
/** Get list of available project names (config + sessions history) */
|
|
123
141
|
getProjectNames(): string[] {
|
|
124
|
-
const
|
|
125
|
-
|
|
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
|
|
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;
|