@hienlh/ppm 0.9.39 → 0.9.41

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.
Files changed (56) hide show
  1. package/CHANGELOG.md +3 -50
  2. package/dist/web/assets/browser-tab--V6I70pH.js +1 -0
  3. package/dist/web/assets/chat-tab-CrkhvVjF.js +10 -0
  4. package/dist/web/assets/code-editor-BfMyExLp.js +2 -0
  5. package/dist/web/assets/{database-viewer-TjRo2b8_.js → database-viewer-CeRUrZKj.js} +1 -1
  6. package/dist/web/assets/{diff-viewer-BMhCz0xk.js → diff-viewer-D2p3WTMS.js} +1 -1
  7. package/dist/web/assets/{extension-webview-DiVdlE2r.js → extension-webview-DQWAHMlR.js} +1 -1
  8. package/dist/web/assets/git-graph-BWRMlCdK.js +1 -0
  9. package/dist/web/assets/index-C7esr4gM.css +2 -0
  10. package/dist/web/assets/index-DU6UVgQY.js +30 -0
  11. package/dist/web/assets/keybindings-store-BE2T8jM9.js +1 -0
  12. package/dist/web/assets/{markdown-renderer-IyEzLrC6.js → markdown-renderer-C7lKs47M.js} +4 -4
  13. package/dist/web/assets/{postgres-viewer-CSynGGkJ.js → postgres-viewer-Cr9jpBNd.js} +1 -1
  14. package/dist/web/assets/{settings-tab-BdI4HhRa.js → settings-tab-DKy-YDg2.js} +1 -1
  15. package/dist/web/assets/{sqlite-viewer-C5mviyU5.js → sqlite-viewer-9AmeF-Zs.js} +1 -1
  16. package/dist/web/assets/square-oPKIkJiw.js +1 -0
  17. package/dist/web/assets/{terminal-tab-CDyC1grg.js → terminal-tab-DFhB4Rxh.js} +1 -1
  18. package/dist/web/assets/{use-monaco-theme-DcVicB_i.js → use-monaco-theme-B7XLw-OX.js} +1 -1
  19. package/dist/web/index.html +2 -3
  20. package/dist/web/sw.js +1 -1
  21. package/docs/codebase-summary.md +3 -33
  22. package/docs/project-changelog.md +0 -47
  23. package/docs/project-roadmap.md +7 -14
  24. package/docs/system-architecture.md +2 -65
  25. package/package.json +1 -1
  26. package/src/server/index.ts +0 -7
  27. package/src/server/routes/settings.ts +1 -72
  28. package/src/services/config.service.ts +1 -1
  29. package/src/services/db.service.ts +1 -279
  30. package/src/services/git.service.ts +2 -2
  31. package/src/types/config.ts +0 -26
  32. package/src/web/components/browser/browser-tab.tsx +128 -97
  33. package/src/web/components/chat/chat-history-bar.tsx +3 -8
  34. package/src/web/components/layout/command-palette.tsx +1 -1
  35. package/src/web/components/settings/settings-tab.tsx +1 -4
  36. package/src/web/hooks/use-url-sync.ts +1 -1
  37. package/dist/web/assets/browser-tab-DnIsHiCc.js +0 -1
  38. package/dist/web/assets/chat-tab-il6D4jql.js +0 -10
  39. package/dist/web/assets/code-editor-BUc1jBqm.js +0 -2
  40. package/dist/web/assets/git-graph-4eGJ8B1A.js +0 -1
  41. package/dist/web/assets/index-BmcV1di6.js +0 -30
  42. package/dist/web/assets/index-CcFDEPCo.css +0 -2
  43. package/dist/web/assets/keybindings-store--5T5hsAj.js +0 -1
  44. package/dist/web/assets/tab-store-BXMIUvsE.js +0 -1
  45. package/docs/streaming-input-guide.md +0 -267
  46. package/snapshot-state.md +0 -1526
  47. package/src/services/ppmbot/ppmbot-formatter.ts +0 -88
  48. package/src/services/ppmbot/ppmbot-memory.ts +0 -333
  49. package/src/services/ppmbot/ppmbot-service.ts +0 -545
  50. package/src/services/ppmbot/ppmbot-session.ts +0 -199
  51. package/src/services/ppmbot/ppmbot-streamer.ts +0 -288
  52. package/src/services/ppmbot/ppmbot-telegram.ts +0 -279
  53. package/src/types/ppmbot.ts +0 -103
  54. package/src/web/components/settings/ppmbot-settings-section.tsx +0 -270
  55. package/test-session-ops.mjs +0 -444
  56. package/test-tokens.mjs +0 -212
@@ -1,199 +0,0 @@
1
- import { existsSync, mkdirSync } from "node:fs";
2
- import { join } from "node:path";
3
- import { homedir } from "node:os";
4
- import { chatService } from "../chat.service.ts";
5
- import { configService } from "../config.service.ts";
6
- import {
7
- getActivePPMBotSession,
8
- createPPMBotSession,
9
- deactivatePPMBotSession,
10
- touchPPMBotSession,
11
- getRecentPPMBotSessions,
12
- setSessionTitle,
13
- } from "../db.service.ts";
14
- import type { PPMBotActiveSession, PPMBotSessionRow } from "../../types/ppmbot.ts";
15
- import type { PPMBotConfig, ProjectConfig } from "../../types/config.ts";
16
-
17
- export class PPMBotSessionManager {
18
- /** In-memory cache: telegramChatId → active session */
19
- private activeSessions = new Map<string, PPMBotActiveSession>();
20
-
21
- /**
22
- * Get active session for chatId. If none exists, create one for the
23
- * given project (or default project from config).
24
- */
25
- async getOrCreateSession(
26
- chatId: string,
27
- projectName?: string,
28
- ): Promise<PPMBotActiveSession> {
29
- const cached = this.activeSessions.get(chatId);
30
- if (cached && (!projectName || cached.projectName === projectName)) {
31
- touchPPMBotSession(cached.sessionId);
32
- return cached;
33
- }
34
-
35
- const input = projectName || this.getDefaultProject();
36
- const resolvedProject = input
37
- ? this.resolveProject(input)
38
- : this.getFallbackProject();
39
- if (!resolvedProject) {
40
- throw new Error(`Project not found: "${projectName || "(default)"}"`);
41
- }
42
-
43
- const dbSession = getActivePPMBotSession(chatId, resolvedProject.name);
44
- if (dbSession) {
45
- return this.resumeFromDb(chatId, dbSession, resolvedProject);
46
- }
47
-
48
- return this.createNewSession(chatId, resolvedProject);
49
- }
50
-
51
- /** Switch to a different project. Deactivates current session. */
52
- async switchProject(
53
- chatId: string,
54
- projectName: string,
55
- ): Promise<PPMBotActiveSession> {
56
- await this.closeSession(chatId);
57
- return this.getOrCreateSession(chatId, projectName);
58
- }
59
-
60
- /** Close (deactivate) the current session for a chatId */
61
- async closeSession(chatId: string): Promise<void> {
62
- const active = this.activeSessions.get(chatId);
63
- if (active) {
64
- deactivatePPMBotSession(active.sessionId);
65
- this.activeSessions.delete(chatId);
66
- }
67
- }
68
-
69
- /** Get active session from cache (no DB hit) */
70
- getActiveSession(chatId: string): PPMBotActiveSession | null {
71
- return this.activeSessions.get(chatId) ?? null;
72
- }
73
-
74
- /** List recent sessions for a chat (from DB) */
75
- listRecentSessions(chatId: string, limit = 10): PPMBotSessionRow[] {
76
- return getRecentPPMBotSessions(chatId, limit);
77
- }
78
-
79
- /** Resume a specific session by 1-indexed position in history */
80
- async resumeSessionById(
81
- chatId: string,
82
- sessionIndex: number,
83
- ): Promise<PPMBotActiveSession | null> {
84
- const sessions = getRecentPPMBotSessions(chatId, 20);
85
- const target = sessions[sessionIndex - 1];
86
- if (!target) return null;
87
-
88
- await this.closeSession(chatId);
89
-
90
- const project = this.resolveProject(target.project_name);
91
- if (!project) return null;
92
-
93
- return this.resumeFromDb(chatId, target, project);
94
- }
95
-
96
- /**
97
- * Resolve a project name against configured projects.
98
- * Case-insensitive, supports prefix matching.
99
- */
100
- resolveProject(input: string): { name: string; path: string } | null {
101
- const projects = configService.get("projects") as ProjectConfig[];
102
- if (!projects?.length) return null;
103
-
104
- const lower = input.toLowerCase();
105
-
106
- const exact = projects.find((p) => p.name.toLowerCase() === lower);
107
- if (exact) return { name: exact.name, path: exact.path };
108
-
109
- const prefix = projects.filter((p) => p.name.toLowerCase().startsWith(lower));
110
- if (prefix.length === 1) return { name: prefix[0]!.name, path: prefix[0]!.path };
111
-
112
- return null;
113
- }
114
-
115
- /** Update session title (e.g. after first message) */
116
- updateSessionTitle(sessionId: string, firstMessage: string): void {
117
- const preview = firstMessage.slice(0, 60).replace(/\n/g, " ");
118
- const title = `[PPM] ${preview}`;
119
- setSessionTitle(sessionId, title);
120
- }
121
-
122
- /** Get list of available project names (for /start greeting) */
123
- getProjectNames(): string[] {
124
- const projects = configService.get("projects") as ProjectConfig[];
125
- return projects?.map((p) => p.name) ?? [];
126
- }
127
-
128
- // ── Private ─────────────────────────────────────────────────────
129
-
130
- private getDefaultProject(): string {
131
- const cfg = configService.get("clawbot") as PPMBotConfig | undefined;
132
- return cfg?.default_project || "";
133
- }
134
-
135
- /** Fallback project when nothing is configured: ~/.ppm/bot/ */
136
- private getFallbackProject(): { name: string; path: string } {
137
- const botDir = join(homedir(), ".ppm", "bot");
138
- if (!existsSync(botDir)) mkdirSync(botDir, { recursive: true });
139
- return { name: "bot", path: botDir };
140
- }
141
-
142
- private getDefaultProvider(): string {
143
- const cfg = configService.get("clawbot") as PPMBotConfig | undefined;
144
- return cfg?.default_provider || configService.get("ai").default_provider;
145
- }
146
-
147
- private async createNewSession(
148
- chatId: string,
149
- project: { name: string; path: string },
150
- ): Promise<PPMBotActiveSession> {
151
- const providerId = this.getDefaultProvider();
152
-
153
- const session = await chatService.createSession(providerId, {
154
- projectName: project.name,
155
- projectPath: project.path,
156
- title: `[PPM] New session`,
157
- });
158
-
159
- createPPMBotSession(chatId, session.id, providerId, project.name, project.path);
160
-
161
- const active: PPMBotActiveSession = {
162
- telegramChatId: chatId,
163
- sessionId: session.id,
164
- providerId,
165
- projectName: project.name,
166
- projectPath: project.path,
167
- };
168
-
169
- this.activeSessions.set(chatId, active);
170
- return active;
171
- }
172
-
173
- private async resumeFromDb(
174
- chatId: string,
175
- dbSession: PPMBotSessionRow,
176
- project: { name: string; path: string },
177
- ): Promise<PPMBotActiveSession> {
178
- try {
179
- await chatService.resumeSession(dbSession.provider_id, dbSession.session_id);
180
- } catch {
181
- console.warn(`[ppmbot] Failed to resume session ${dbSession.session_id}, creating new`);
182
- deactivatePPMBotSession(dbSession.session_id);
183
- return this.createNewSession(chatId, project);
184
- }
185
-
186
- touchPPMBotSession(dbSession.session_id);
187
-
188
- const active: PPMBotActiveSession = {
189
- telegramChatId: chatId,
190
- sessionId: dbSession.session_id,
191
- providerId: dbSession.provider_id,
192
- projectName: project.name,
193
- projectPath: project.path,
194
- };
195
-
196
- this.activeSessions.set(chatId, active);
197
- return active;
198
- }
199
- }
@@ -1,288 +0,0 @@
1
- import type { ChatEvent, ResultSubtype } from "../../types/chat.ts";
2
- import type { PPMBotTelegram } from "./ppmbot-telegram.ts";
3
- import {
4
- markdownToTelegramHtml,
5
- chunkMessage,
6
- escapeHtml,
7
- } from "./ppmbot-formatter.ts";
8
-
9
- const MAX_MSG_LEN = 4096;
10
- const TYPING_REFRESH_MS = 4000;
11
- const EVENT_TIMEOUT_MS = 180_000; // 3 min max wait per event (tool execution can be slow)
12
- const PLACEHOLDER = "\u2026"; // ellipsis
13
-
14
- /**
15
- * Wrap an async iterable with per-event timeout.
16
- * If .next() doesn't resolve within timeoutMs, yields a timeout error.
17
- */
18
- async function* withEventTimeout<T>(
19
- iterable: AsyncIterable<T>,
20
- timeoutMs: number,
21
- ): AsyncGenerator<T> {
22
- const iterator = iterable[Symbol.asyncIterator]();
23
- try {
24
- while (true) {
25
- const result = await Promise.race([
26
- iterator.next(),
27
- new Promise<{ done: true; value: undefined; timedOut: true }>((resolve) =>
28
- setTimeout(() => resolve({ done: true, value: undefined, timedOut: true }), timeoutMs),
29
- ),
30
- ]);
31
- if ("timedOut" in result) {
32
- throw new Error("No response within 3 minutes");
33
- }
34
- if (result.done) break;
35
- yield result.value;
36
- }
37
- } finally {
38
- iterator.return?.();
39
- }
40
- }
41
-
42
- export interface StreamConfig {
43
- showToolCalls: boolean;
44
- showThinking: boolean;
45
- }
46
-
47
- export interface StreamResult {
48
- contextWindowPct?: number;
49
- resultSubtype?: ResultSubtype;
50
- messageIds: number[];
51
- newSessionId?: string;
52
- }
53
-
54
- /**
55
- * Segments of accumulated response.
56
- * - "md" = raw markdown from AI (needs conversion)
57
- * - "html" = pre-formatted HTML (tool calls, thinking, errors — already escaped)
58
- */
59
- type Segment = { type: "md"; text: string } | { type: "html"; text: string };
60
-
61
- /** Render segments into Telegram HTML */
62
- function renderSegments(segments: Segment[]): string {
63
- return segments
64
- .map((s) => (s.type === "md" ? markdownToTelegramHtml(s.text) : s.text))
65
- .join("");
66
- }
67
-
68
- /** Check if segments have meaningful content */
69
- function hasContent(segments: Segment[]): boolean {
70
- return segments.some((s) => s.text.trim().length > 0);
71
- }
72
-
73
- /** Get raw text length (approximation for split decisions) */
74
- function segmentsLength(segments: Segment[]): number {
75
- return segments.reduce((sum, s) => sum + s.text.length, 0);
76
- }
77
-
78
- /** Append markdown text — merges into last segment if also md */
79
- function appendMd(segments: Segment[], text: string): void {
80
- const last = segments[segments.length - 1];
81
- if (last?.type === "md") {
82
- last.text += text;
83
- } else {
84
- segments.push({ type: "md", text });
85
- }
86
- }
87
-
88
- /** Append pre-formatted HTML */
89
- function appendHtml(segments: Segment[], html: string): void {
90
- segments.push({ type: "html", html: html } as any);
91
- // fix: use correct field
92
- segments[segments.length - 1] = { type: "html", text: html };
93
- }
94
-
95
- export async function streamToTelegram(
96
- chatId: number | string,
97
- events: AsyncIterable<ChatEvent>,
98
- telegram: PPMBotTelegram,
99
- config: StreamConfig,
100
- ): Promise<StreamResult> {
101
- const result: StreamResult = { messageIds: [] };
102
- const segments: Segment[] = [];
103
- let currentMsgId: number | null = null;
104
- let lastTypingTime = 0;
105
-
106
- const refreshTyping = async (): Promise<void> => {
107
- const now = Date.now();
108
- if (now - lastTypingTime >= TYPING_REFRESH_MS) {
109
- lastTypingTime = now;
110
- await telegram.sendTyping(chatId);
111
- }
112
- };
113
-
114
- // Send placeholder
115
- await telegram.sendTyping(chatId);
116
- lastTypingTime = Date.now();
117
- const placeholder = await telegram.sendMessage(chatId, PLACEHOLDER);
118
- if (placeholder) {
119
- currentMsgId = placeholder.message_id;
120
- result.messageIds.push(currentMsgId);
121
- }
122
-
123
- const editCurrent = async (): Promise<void> => {
124
- if (!currentMsgId || !hasContent(segments)) return;
125
-
126
- const html = renderSegments(segments);
127
- if (html.length > MAX_MSG_LEN) {
128
- // Finalize current message with what fits, start new one
129
- await telegram.editMessageFinal(chatId, currentMsgId, html.slice(0, MAX_MSG_LEN));
130
- currentMsgId = null;
131
-
132
- const overflow = html.slice(MAX_MSG_LEN);
133
- if (overflow.trim()) {
134
- const chunks = chunkMessage(overflow, MAX_MSG_LEN);
135
- for (const chunk of chunks) {
136
- const sent = await telegram.sendMessage(chatId, chunk);
137
- if (sent) {
138
- currentMsgId = sent.message_id;
139
- result.messageIds.push(currentMsgId);
140
- }
141
- }
142
- }
143
- // Reset segments — only keep any un-rendered text
144
- segments.length = 0;
145
- return;
146
- }
147
-
148
- await telegram.editMessage(chatId, currentMsgId, html);
149
- };
150
-
151
- // Process event stream with per-event timeout
152
- let eventCount = 0;
153
- try {
154
- eventLoop: for await (const event of withEventTimeout(events, EVENT_TIMEOUT_MS)) {
155
- eventCount++;
156
- // Debug: log each event type to help diagnose streaming issues
157
- if (event.type !== "text") {
158
- console.log(`[ppmbot-stream] event #${eventCount}: ${event.type}${event.type === "tool_use" ? ` (${(event as any).tool})` : ""}`);
159
- }
160
- await refreshTyping();
161
-
162
- switch (event.type) {
163
- case "text": {
164
- appendMd(segments, event.content);
165
- await editCurrent();
166
- break;
167
- }
168
-
169
- case "thinking": {
170
- if (config.showThinking && event.content) {
171
- appendHtml(segments, `\n<i>💭 ${escapeHtml(event.content)}</i>\n`);
172
- await editCurrent();
173
- }
174
- break;
175
- }
176
-
177
- case "tool_use": {
178
- if (config.showToolCalls) {
179
- const toolName = event.tool;
180
- const inputPreview = formatToolInput(event.input);
181
- appendHtml(
182
- segments,
183
- `\n🔧 <code>${escapeHtml(toolName)}</code>(${escapeHtml(inputPreview)})\n`,
184
- );
185
- await editCurrent();
186
- }
187
- break;
188
- }
189
-
190
- case "tool_result": {
191
- if (config.showToolCalls && event.isError) {
192
- appendHtml(
193
- segments,
194
- `\n⚠️ <code>${escapeHtml(event.output.slice(0, 200))}</code>\n`,
195
- );
196
- await editCurrent();
197
- }
198
- break;
199
- }
200
-
201
- case "error": {
202
- appendHtml(segments, `\n\n❌ <b>Error:</b> ${escapeHtml(event.message)}`);
203
- await editCurrent();
204
- break;
205
- }
206
-
207
- case "done": {
208
- result.contextWindowPct = event.contextWindowPct;
209
- result.resultSubtype = event.resultSubtype;
210
- break eventLoop; // break the for-await, not just the switch
211
- }
212
-
213
- case "session_migrated": {
214
- result.newSessionId = event.newSessionId;
215
- break;
216
- }
217
-
218
- case "account_retry": {
219
- appendHtml(
220
- segments,
221
- `\n⏳ <i>Switching account: ${escapeHtml(event.reason)}</i>\n`,
222
- );
223
- await editCurrent();
224
- break;
225
- }
226
-
227
- default:
228
- break;
229
- }
230
- }
231
- } catch (err) {
232
- console.error(`[ppmbot-stream] Stream ended with error after ${eventCount} events: ${(err as Error).message}`);
233
- appendHtml(
234
- segments,
235
- `\n\n❌ <b>Stream error:</b> ${escapeHtml((err as Error).message)}`,
236
- );
237
- }
238
-
239
- console.log(`[ppmbot-stream] Complete: ${eventCount} events, ${segments.length} segments`);
240
-
241
- // Final edit with complete content
242
- if (currentMsgId && hasContent(segments)) {
243
- const html = renderSegments(segments);
244
- const chunks = chunkMessage(html, MAX_MSG_LEN);
245
-
246
- if (chunks.length === 1) {
247
- await telegram.editMessageFinal(chatId, currentMsgId, chunks[0]!);
248
- } else {
249
- await telegram.editMessageFinal(chatId, currentMsgId, chunks[0]!);
250
- for (let i = 1; i < chunks.length; i++) {
251
- const sent = await telegram.sendMessage(chatId, chunks[i]!);
252
- if (sent) result.messageIds.push(sent.message_id);
253
- }
254
- }
255
- } else if (currentMsgId && !hasContent(segments)) {
256
- await telegram.editMessageFinal(
257
- chatId,
258
- currentMsgId,
259
- "<i>No response generated.</i>",
260
- );
261
- }
262
-
263
- return result;
264
- }
265
-
266
- // ── Helpers ─────────────────────────────────────────────────────
267
-
268
- function formatToolInput(input: unknown): string {
269
- if (!input) return "";
270
- if (typeof input === "string") return input.slice(0, 80);
271
-
272
- try {
273
- const obj = input as Record<string, unknown>;
274
- const keys = Object.keys(obj);
275
- if (keys.length === 0) return "";
276
-
277
- if ("command" in obj) return String(obj.command).slice(0, 80);
278
- if ("file_path" in obj) return String(obj.file_path).slice(0, 80);
279
- if ("pattern" in obj) return String(obj.pattern).slice(0, 80);
280
- if ("query" in obj) return String(obj.query).slice(0, 80);
281
- if ("url" in obj) return String(obj.url).slice(0, 80);
282
-
283
- const firstKey = keys[0]!;
284
- return `${firstKey}=${String(obj[firstKey]).slice(0, 60)}`;
285
- } catch {
286
- return "";
287
- }
288
- }