@hienlh/ppm 0.9.46 → 0.9.48

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,18 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.9.48] - 2026-04-06
4
+
5
+ ### Changed
6
+ - **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.
7
+ - **Removed "don't write memory" directive**: AI provider manages its own contextual memory naturally.
8
+
9
+ ## [0.9.47] - 2026-04-06
10
+
11
+ ### Fixed
12
+ - **AI writes to Claude memory files**: Added core directive preventing AI from managing its own memory/identity files. Memory is handled by PPMBot externally.
13
+ - **Garbage identity saved**: Removed `hasCheckedIdentity` fallback that saved random messages as identity. Identity only collected through `/start` onboarding flow.
14
+ - **Identity onboarding context**: AI now gets a hint that the message is an identity intro, so it acknowledges warmly instead of treating it as a task.
15
+
3
16
  ## [0.9.46] - 2026-04-06
4
17
 
5
18
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hienlh/ppm",
3
- "version": "0.9.46",
3
+ "version": "0.9.48",
4
4
  "description": "Personal Project Manager — mobile-first web IDE with AI assistance",
5
5
  "author": "hienlh",
6
6
  "license": "MIT",
@@ -1097,19 +1097,6 @@ export function deletePPMBotMemoriesByTopic(
1097
1097
  return matches.length;
1098
1098
  }
1099
1099
 
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
1100
 
1114
1101
  // ---------------------------------------------------------------------------
1115
1102
  // 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,15 +40,6 @@ class PPMBotService {
41
40
  /** Chat IDs that just received identity onboarding prompt */
42
41
  private identityPending = new Set<string>();
43
42
 
44
- /** Chat IDs where we've already checked for identity (once per session) */
45
- private hasCheckedIdentity = new Set<string>();
46
-
47
- /** Message count per session for periodic memory save */
48
- private messageCount = new Map<string, number>();
49
-
50
- /** Interval (messages) between automatic memory saves */
51
- private readonly MEMORY_SAVE_INTERVAL = 5;
52
-
53
43
  // ── Lifecycle ─────────────────────────────────────────────────
54
44
 
55
45
  async start(): Promise<void> {
@@ -69,9 +59,6 @@ class PPMBotService {
69
59
  this.telegram = new PPMBotTelegram(telegramConfig.bot_token);
70
60
  this.running = true;
71
61
 
72
- // Run memory decay on startup
73
- this.memory.runDecay();
74
-
75
62
  // Start polling (non-blocking)
76
63
  this.telegram.startPolling((update) => this.handleUpdate(update));
77
64
 
@@ -95,8 +82,6 @@ class PPMBotService {
95
82
  this.processing.clear();
96
83
  this.messageQueue.clear();
97
84
  this.identityPending.clear();
98
- this.hasCheckedIdentity.clear();
99
- this.messageCount.clear();
100
85
 
101
86
  console.log("[ppmbot] Stopped");
102
87
  }
@@ -251,7 +236,6 @@ class PPMBotService {
251
236
  return;
252
237
  }
253
238
 
254
- await this.saveSessionMemory(chatId);
255
239
  const session = await this.sessions.switchProject(chatId, args);
256
240
  await this.telegram!.sendMessage(
257
241
  Number(chatId),
@@ -260,7 +244,6 @@ class PPMBotService {
260
244
  }
261
245
 
262
246
  private async cmdNew(chatId: string): Promise<void> {
263
- await this.saveSessionMemory(chatId);
264
247
  const active = this.sessions.getActiveSession(chatId);
265
248
  const projectName = active?.projectName;
266
249
  await this.sessions.closeSession(chatId);
@@ -303,7 +286,6 @@ class PPMBotService {
303
286
  await this.telegram!.sendMessage(Number(chatId), "Usage: /resume &lt;number&gt;");
304
287
  return;
305
288
  }
306
- await this.saveSessionMemory(chatId);
307
289
  const session = await this.sessions.resumeSessionById(chatId, index);
308
290
  if (!session) {
309
291
  await this.telegram!.sendMessage(Number(chatId), "Session not found.");
@@ -329,7 +311,6 @@ class PPMBotService {
329
311
  }
330
312
 
331
313
  private async cmdStop(chatId: string): Promise<void> {
332
- await this.saveSessionMemory(chatId);
333
314
  await this.sessions.closeSession(chatId);
334
315
  await this.telegram!.sendMessage(Number(chatId), "Session ended ✓");
335
316
  }
@@ -497,14 +478,10 @@ class PPMBotService {
497
478
  this.titledSessions.add(session.sessionId);
498
479
  }
499
480
 
500
- // Recall memories (with cross-project detection)
501
- const memories = this.memory.recallWithCrossProject(
502
- session.projectName,
503
- text,
504
- text,
505
- );
481
+ // Recall identity & preferences (global + project)
482
+ const memories = this.memory.getSummary(session.projectName);
506
483
 
507
- // Build system prompt with memory
484
+ // Build system prompt with identity/preferences
508
485
  let systemPrompt = config?.system_prompt ?? "";
509
486
  const memorySection = this.memory.buildRecallPrompt(memories);
510
487
  if (memorySection) {
@@ -516,23 +493,19 @@ class PPMBotService {
516
493
  permissionMode: (config?.permission_mode ?? "bypassPermissions") as PermissionMode,
517
494
  };
518
495
 
519
- let fullMessage = text;
520
- if (systemPrompt) {
521
- fullMessage = `<system-context>\n${systemPrompt}\n</system-context>\n\n${text}`;
522
- }
523
-
524
496
  // Save identity BEFORE streaming — must persist even if streaming times out
497
+ let messageForAI = text;
525
498
  if (this.identityPending.has(chatId)) {
526
499
  this.identityPending.delete(chatId);
527
500
  this.memory.saveOne("_global", `User identity: ${text}`, "preference", session.sessionId);
528
501
  console.log("[ppmbot] Saved identity memory from onboarding");
529
- } else if (!this.hasCheckedIdentity.has(chatId)) {
530
- this.hasCheckedIdentity.add(chatId);
531
- const globalMems = this.memory.getSummary("_global", 50);
532
- if (!globalMems.some((m) => m.category === "preference" && /identity/i.test(m.content))) {
533
- this.memory.saveOne("_global", `User identity: ${text}`, "preference", session.sessionId);
534
- console.log("[ppmbot] Saved identity memory (first message, no identity found)");
535
- }
502
+ // Tell AI this is an identity intro so it acknowledges warmly
503
+ messageForAI = `[User just introduced themselves in response to onboarding prompt. Acknowledge warmly and briefly.]\n\n${text}`;
504
+ }
505
+
506
+ let fullMessage = messageForAI;
507
+ if (systemPrompt) {
508
+ fullMessage = `<system-context>\n${systemPrompt}\n</system-context>\n\n${messageForAI}`;
536
509
  }
537
510
 
538
511
  const events = chatService.sendMessage(
@@ -553,15 +526,6 @@ class PPMBotService {
553
526
  },
554
527
  );
555
528
 
556
- // Periodic memory extraction — fire-and-forget every N messages
557
- const count = (this.messageCount.get(session.sessionId) ?? 0) + 1;
558
- this.messageCount.set(session.sessionId, count);
559
- if (count % this.MEMORY_SAVE_INTERVAL === 0) {
560
- this.saveSessionMemory(chatId).catch((err) =>
561
- console.warn("[ppmbot] Periodic memory save failed:", (err as Error).message),
562
- );
563
- }
564
-
565
529
  // Check context window — auto-rotate if near limit
566
530
  if (
567
531
  result.contextWindowPct != null &&
@@ -588,48 +552,14 @@ class PPMBotService {
588
552
  }
589
553
  }
590
554
 
591
- // ── Memory Save / Session Rotate ────────────────────────────────
592
-
593
- private async saveSessionMemory(chatId: string): Promise<void> {
594
- const session = this.sessions.getActiveSession(chatId);
595
- if (!session) return;
596
-
597
- try {
598
- const extractionPrompt = this.memory.buildExtractionPrompt();
599
- const events = chatService.sendMessage(
600
- session.providerId,
601
- session.sessionId,
602
- extractionPrompt,
603
- { permissionMode: "bypassPermissions" },
604
- );
605
-
606
- let responseText = "";
607
- for await (const event of events) {
608
- if (event.type === "text") responseText += event.content;
609
- }
610
-
611
- const facts = this.memory.parseExtractionResponse(responseText);
612
- if (facts.length > 0) {
613
- const count = this.memory.save(session.projectName, facts, session.sessionId);
614
- console.log(`[ppmbot] Saved ${count} memories for ${session.projectName}`);
615
- } else {
616
- // Fallback: regex-based extraction
617
- // Note: we don't have conversation history text here easily,
618
- // so regex fallback only triggers when AI extraction fails
619
- console.log("[ppmbot] No memories extracted via AI");
620
- }
621
- } catch (err) {
622
- console.warn("[ppmbot] Memory save failed:", (err as Error).message);
623
- }
624
- }
555
+ // ── Session Rotate ──────────────────────────────────────────────
625
556
 
626
557
  private async rotateSession(chatId: string, projectName: string): Promise<void> {
627
- await this.saveSessionMemory(chatId);
628
558
  await this.sessions.closeSession(chatId);
629
559
  await this.sessions.getOrCreateSession(chatId, projectName);
630
560
  await this.telegram?.sendMessage(
631
561
  Number(chatId),
632
- "<i>Context window near limit — starting fresh session. Memories saved.</i>",
562
+ "<i>Context window near limit — starting fresh session.</i>",
633
563
  );
634
564
  }
635
565