@hienlh/ppm 0.9.41 → 0.9.43

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 (61) hide show
  1. package/CHANGELOG.md +80 -1
  2. package/dist/web/assets/{chat-tab-CrkhvVjF.js → chat-tab-DvNEQYEe.js} +2 -2
  3. package/dist/web/assets/{code-editor-BfMyExLp.js → code-editor-CoT017Ah.js} +2 -2
  4. package/dist/web/assets/{csv-preview--ZSEumXf.js → csv-preview-sx6DC51G.js} +1 -1
  5. package/dist/web/assets/{database-viewer-CeRUrZKj.js → database-viewer-C3wK7cDk.js} +1 -1
  6. package/dist/web/assets/{diff-viewer-D2p3WTMS.js → diff-viewer-D0tuen4I.js} +1 -1
  7. package/dist/web/assets/{extension-webview-DQWAHMlR.js → extension-webview-Ba5aeo9r.js} +1 -1
  8. package/dist/web/assets/{git-graph-BWRMlCdK.js → git-graph-BnJrVPxJ.js} +1 -1
  9. package/dist/web/assets/index-BEfMoc_W.css +2 -0
  10. package/dist/web/assets/index-DUQgLj0D.js +30 -0
  11. package/dist/web/assets/keybindings-store-CkGFjxkX.js +1 -0
  12. package/dist/web/assets/{markdown-renderer-C7lKs47M.js → markdown-renderer-BuGSrE3y.js} +1 -1
  13. package/dist/web/assets/port-forwarding-tab-DsbrWNUP.js +1 -0
  14. package/dist/web/assets/{postgres-viewer-Cr9jpBNd.js → postgres-viewer-Bh6YmZPq.js} +1 -1
  15. package/dist/web/assets/{settings-tab-DKy-YDg2.js → settings-tab-BnzFtexC.js} +1 -1
  16. package/dist/web/assets/{sqlite-viewer-9AmeF-Zs.js → sqlite-viewer-Cu3_hf07.js} +1 -1
  17. package/dist/web/assets/{terminal-tab-DFhB4Rxh.js → terminal-tab-fnZvscaH.js} +1 -1
  18. package/dist/web/assets/{use-monaco-theme-B7XLw-OX.js → use-monaco-theme-BdcKAZ69.js} +1 -1
  19. package/dist/web/index.html +2 -2
  20. package/dist/web/sw.js +1 -1
  21. package/docs/codebase-summary.md +33 -3
  22. package/docs/project-changelog.md +47 -0
  23. package/docs/project-roadmap.md +14 -7
  24. package/docs/streaming-input-guide.md +267 -0
  25. package/docs/system-architecture.md +65 -2
  26. package/package.json +1 -1
  27. package/snapshot-state.md +1526 -0
  28. package/src/server/index.ts +10 -3
  29. package/src/server/routes/{browser-preview.ts → port-forwarding.ts} +7 -7
  30. package/src/server/routes/settings.ts +102 -17
  31. package/src/services/config.service.ts +1 -1
  32. package/src/services/db.service.ts +285 -1
  33. package/src/services/ppmbot/ppmbot-formatter.ts +88 -0
  34. package/src/services/ppmbot/ppmbot-memory.ts +333 -0
  35. package/src/services/ppmbot/ppmbot-service.ts +617 -0
  36. package/src/services/ppmbot/ppmbot-session.ts +199 -0
  37. package/src/services/ppmbot/ppmbot-streamer.ts +288 -0
  38. package/src/services/ppmbot/ppmbot-telegram.ts +280 -0
  39. package/src/services/telegram-notification.service.ts +44 -21
  40. package/src/types/config.ts +25 -1
  41. package/src/types/ppmbot.ts +103 -0
  42. package/src/web/components/chat/chat-history-bar.tsx +8 -3
  43. package/src/web/components/layout/command-palette.tsx +1 -1
  44. package/src/web/components/layout/editor-panel.tsx +1 -1
  45. package/src/web/components/layout/mobile-nav.tsx +1 -1
  46. package/src/web/components/layout/tab-bar.tsx +1 -1
  47. package/src/web/components/layout/tab-content.tsx +3 -3
  48. package/src/web/components/{browser/browser-tab.tsx → ports/port-forwarding-tab.tsx} +5 -5
  49. package/src/web/components/settings/ppmbot-settings-section.tsx +437 -0
  50. package/src/web/components/settings/settings-tab.tsx +10 -5
  51. package/src/web/hooks/use-url-sync.ts +3 -3
  52. package/src/web/stores/panel-utils.ts +2 -2
  53. package/src/web/stores/tab-store.ts +1 -1
  54. package/test-session-ops.mjs +444 -0
  55. package/test-tokens.mjs +212 -0
  56. package/dist/web/assets/browser-tab--V6I70pH.js +0 -1
  57. package/dist/web/assets/index-C7esr4gM.css +0 -2
  58. package/dist/web/assets/index-DU6UVgQY.js +0 -30
  59. package/dist/web/assets/keybindings-store-BE2T8jM9.js +0 -1
  60. /package/dist/web/assets/{dist-DKlZwvf8.js → dist-C40JmyoH.js} +0 -0
  61. /package/dist/web/assets/{lib-BeaDXEkP.js → lib-mag4ySk-.js} +0 -0
@@ -15,7 +15,7 @@ import { fsBrowseRoutes } from "./routes/fs-browse.ts";
15
15
  import { accountsRoutes } from "./routes/accounts.ts";
16
16
  import { proxyRoutes } from "./routes/proxy.ts";
17
17
  import { mcpRoutes } from "./routes/mcp.ts";
18
- import { browserPreviewRoutes } from "./routes/browser-preview.ts";
18
+ import { portForwardingRoutes } from "./routes/port-forwarding.ts";
19
19
  import { initAdapters } from "../services/database/init-adapters.ts";
20
20
  import { terminalWebSocket } from "./ws/terminal.ts";
21
21
  import { chatWebSocket } from "./ws/chat.ts";
@@ -133,8 +133,8 @@ app.route("/proxy", proxyRoutes);
133
133
  app.use("/api/*", authMiddleware);
134
134
  app.get("/api/auth/check", (c) => c.json(ok(true)));
135
135
 
136
- // Browser preview reverse proxy proxies to localhost:<port> for iframe embedding
137
- app.route("/api/preview", browserPreviewRoutes);
136
+ // Port forwardingstarts per-port Cloudflare tunnels
137
+ app.route("/api/preview", portForwardingRoutes);
138
138
 
139
139
  // Filesystem operations (browse, list, read, write) — consolidated in fs-browse route
140
140
  app.route("/api/fs", fsBrowseRoutes);
@@ -477,5 +477,12 @@ if (process.argv.includes("__serve__")) {
477
477
  console.error("[ExtService] Startup error:", e);
478
478
  });
479
479
 
480
+ // Start PPMBot Telegram poller (if enabled)
481
+ import("../services/ppmbot/ppmbot-service.ts")
482
+ .then(({ ppmbotService }) => ppmbotService.start())
483
+ .catch((e) => {
484
+ console.error("[ppmbot] Startup error:", e);
485
+ });
486
+
480
487
  console.log(`Server child ready on port ${port}`);
481
488
  }
@@ -3,14 +3,14 @@ import { ok, err } from "../../types/api.ts";
3
3
  import { ensureCloudflared } from "../../services/cloudflared.service.ts";
4
4
 
5
5
  /**
6
- * Browser preview API — starts per-port Cloudflare Quick Tunnels so the
7
- * frontend can iframe any localhost dev server without CORS/path issues.
6
+ * Port forwarding API — starts per-port Cloudflare Quick Tunnels so the
7
+ * frontend can open any localhost dev server via tunnel URL.
8
8
  *
9
9
  * POST /api/preview/tunnel { port: 3000 } → { url: "https://xxx.trycloudflare.com" }
10
10
  * DELETE /api/preview/tunnel/:port → stops tunnel for that port
11
11
  * GET /api/preview/tunnels → list active tunnels
12
12
  */
13
- export const browserPreviewRoutes = new Hono();
13
+ export const portForwardingRoutes = new Hono();
14
14
 
15
15
  const TUNNEL_URL_REGEX = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
16
16
 
@@ -25,7 +25,7 @@ interface ActiveTunnel {
25
25
  export const activeTunnels = new Map<number, ActiveTunnel>();
26
26
 
27
27
  /** Start a tunnel for a localhost port */
28
- browserPreviewRoutes.post("/tunnel", async (c) => {
28
+ portForwardingRoutes.post("/tunnel", async (c) => {
29
29
  const body = await c.req.json<{ port: number }>().catch(() => null);
30
30
  const port = body?.port;
31
31
  if (!port || port < 1 || port > 65535) {
@@ -95,7 +95,7 @@ browserPreviewRoutes.post("/tunnel", async (c) => {
95
95
  });
96
96
 
97
97
  /** Stop a tunnel */
98
- browserPreviewRoutes.delete("/tunnel/:port{[0-9]+}", (c) => {
98
+ portForwardingRoutes.delete("/tunnel/:port{[0-9]+}", (c) => {
99
99
  const port = parseInt(c.req.param("port"), 10);
100
100
  const tunnel = activeTunnels.get(port);
101
101
  if (!tunnel) {
@@ -109,7 +109,7 @@ browserPreviewRoutes.delete("/tunnel/:port{[0-9]+}", (c) => {
109
109
  });
110
110
 
111
111
  /** List active tunnels */
112
- browserPreviewRoutes.get("/tunnels", (c) => {
112
+ portForwardingRoutes.get("/tunnels", (c) => {
113
113
  const list = Array.from(activeTunnels.values()).map((t) => ({
114
114
  port: t.port,
115
115
  url: t.url,
@@ -151,7 +151,7 @@ async function cleanupGhostTunnels() {
151
151
  setInterval(cleanupGhostTunnels, 30_000);
152
152
 
153
153
  /** Cleanup all tunnels on server shutdown */
154
- export function stopAllPreviewTunnels() {
154
+ export function stopAllPortTunnels() {
155
155
  for (const [port, tunnel] of activeTunnels) {
156
156
  try { tunnel.process.kill(); } catch {}
157
157
  activeTunnels.delete(port);
@@ -1,12 +1,14 @@
1
1
  import { Hono } from "hono";
2
2
  import { configService } from "../../services/config.service.ts";
3
- import { getConfigValue, setConfigValue } from "../../services/db.service.ts";
3
+ import { getConfigValue, setConfigValue, listPairedChats, getPairingByCode, approvePairing, revokePairing, getPPMBotMemories, getDb } from "../../services/db.service.ts";
4
4
  import {
5
5
  validateAIProviderConfig,
6
6
  validateDefaultProvider,
7
7
  VALID_PROVIDERS,
8
+ DEFAULT_CONFIG,
8
9
  type AIProviderConfig,
9
10
  type TelegramConfig,
11
+ type PPMBotConfig,
10
12
  type ThemeConfig,
11
13
  } from "../../types/config.ts";
12
14
  import { ok, err } from "../../types/api.ts";
@@ -201,50 +203,45 @@ settingsRoutes.put("/keybindings", async (c) => {
201
203
  }
202
204
  });
203
205
 
204
- // ── Telegram ──────────────────────────────────────────────────────────
206
+ // ── Telegram (bot_token managed via PPMBot settings) ────────────────
205
207
 
206
208
  /** GET /settings/telegram — return current telegram config (masks bot_token) */
207
209
  settingsRoutes.get("/telegram", (c) => {
208
210
  const tg = configService.get("telegram") as TelegramConfig | undefined;
209
- if (!tg) return c.json(ok({ bot_token: "", chat_id: "" }));
211
+ if (!tg) return c.json(ok({ bot_token: "" }));
210
212
  return c.json(ok({
211
213
  bot_token: tg.bot_token ? `${tg.bot_token.slice(0, 6)}...` : "",
212
- chat_id: tg.chat_id,
213
214
  }));
214
215
  });
215
216
 
216
- /** PUT /settings/telegram — save telegram bot_token + chat_id */
217
+ /** PUT /settings/telegram — save telegram bot_token */
217
218
  settingsRoutes.put("/telegram", async (c) => {
218
219
  try {
219
- const body = await c.req.json<{ bot_token?: string; chat_id?: string }>();
220
- const current = (configService.get("telegram") as TelegramConfig | undefined) ?? { bot_token: "", chat_id: "" };
220
+ const body = await c.req.json<{ bot_token?: string }>();
221
+ const current = (configService.get("telegram") as TelegramConfig | undefined) ?? { bot_token: "" };
221
222
  const updated: TelegramConfig = {
222
223
  bot_token: body.bot_token ?? current.bot_token,
223
- chat_id: body.chat_id ?? current.chat_id,
224
224
  };
225
225
  configService.set("telegram", updated);
226
226
  configService.save();
227
227
  return c.json(ok({
228
228
  bot_token: updated.bot_token ? `${updated.bot_token.slice(0, 6)}...` : "",
229
- chat_id: updated.chat_id,
230
229
  }));
231
230
  } catch (e) {
232
231
  return c.json(err((e as Error).message), 400);
233
232
  }
234
233
  });
235
234
 
236
- /** POST /settings/telegram/test — send a test message */
235
+ /** POST /settings/telegram/test — send a test notification to all approved paired chats */
237
236
  settingsRoutes.post("/telegram/test", async (c) => {
238
237
  try {
239
- const body = await c.req.json<{ bot_token?: string; chat_id?: string }>();
240
- const current = (configService.get("telegram") as TelegramConfig | undefined) ?? { bot_token: "", chat_id: "" };
241
- const token = body.bot_token || current.bot_token;
242
- const chatId = body.chat_id || current.chat_id;
243
- if (!token || !chatId) {
244
- return c.json(err("bot_token and chat_id are required"), 400);
238
+ const current = (configService.get("telegram") as TelegramConfig | undefined) ?? { bot_token: "" };
239
+ const token = current.bot_token;
240
+ if (!token) {
241
+ return c.json(err("Bot token not configured"), 400);
245
242
  }
246
243
  const { telegramService } = await import("../../services/telegram-notification.service.ts");
247
- const result = await telegramService.sendTest(token, chatId);
244
+ const result = await telegramService.sendTest(token);
248
245
  if (!result.ok) return c.json(err(result.error ?? "Failed"), 500);
249
246
  return c.json(ok({ sent: true }));
250
247
  } catch (e) {
@@ -316,3 +313,91 @@ settingsRoutes.put("/proxy", async (c) => {
316
313
  return c.json(err((e as Error).message), 400);
317
314
  }
318
315
  });
316
+
317
+ // ── PPMBot ─────────────────────────────────────────────────────
318
+
319
+ /** GET /settings/clawbot — return current clawbot config */
320
+ settingsRoutes.get("/clawbot", (c) => {
321
+ const config = configService.get("clawbot") as PPMBotConfig | undefined;
322
+ if (!config) return c.json(ok(DEFAULT_CONFIG.clawbot));
323
+ return c.json(ok(config));
324
+ });
325
+
326
+ /** PUT /settings/clawbot — update clawbot config */
327
+ settingsRoutes.put("/clawbot", async (c) => {
328
+ try {
329
+ const body = await c.req.json<Partial<PPMBotConfig>>();
330
+ const current = (configService.get("clawbot") as PPMBotConfig | undefined)
331
+ ?? structuredClone(DEFAULT_CONFIG.clawbot!);
332
+ const updated: PPMBotConfig = { ...current, ...body };
333
+
334
+ if (updated.debounce_ms < 0 || updated.debounce_ms > 30000) {
335
+ return c.json(err("debounce_ms must be 0-30000"), 400);
336
+ }
337
+
338
+ configService.set("clawbot", updated);
339
+ configService.save();
340
+
341
+ // Restart clawbot if running state changed
342
+ try {
343
+ const { ppmbotService } = await import("../../services/ppmbot/ppmbot-service.ts");
344
+ if (updated.enabled && !ppmbotService.isRunning) {
345
+ await ppmbotService.start();
346
+ } else if (!updated.enabled && ppmbotService.isRunning) {
347
+ ppmbotService.stop();
348
+ }
349
+ } catch { /* PPMBot module not loaded yet — OK */ }
350
+
351
+ return c.json(ok(updated));
352
+ } catch (e) {
353
+ return c.json(err((e as Error).message), 400);
354
+ }
355
+ });
356
+
357
+ /** GET /settings/clawbot/paired — list paired devices */
358
+ settingsRoutes.get("/clawbot/paired", (c) => {
359
+ return c.json(ok(listPairedChats()));
360
+ });
361
+
362
+ /** POST /settings/clawbot/paired/approve — approve pairing by code */
363
+ settingsRoutes.post("/clawbot/paired/approve", async (c) => {
364
+ try {
365
+ const { code } = await c.req.json<{ code: string }>();
366
+ const pairing = getPairingByCode(code);
367
+ if (!pairing) return c.json(err("Invalid pairing code"), 404);
368
+ approvePairing(pairing.telegram_chat_id);
369
+ // Notify user on Telegram
370
+ try {
371
+ const { ppmbotService } = await import("../../services/ppmbot/ppmbot-service.ts");
372
+ await ppmbotService.notifyPairingApproved(pairing.telegram_chat_id);
373
+ } catch { /* OK */ }
374
+ return c.json(ok({ approved: pairing.telegram_chat_id }));
375
+ } catch (e) {
376
+ return c.json(err((e as Error).message), 400);
377
+ }
378
+ });
379
+
380
+ /** DELETE /settings/clawbot/paired/:chatId — revoke pairing */
381
+ settingsRoutes.delete("/clawbot/paired/:chatId", (c) => {
382
+ revokePairing(c.req.param("chatId"));
383
+ return c.json(ok({ revoked: true }));
384
+ });
385
+
386
+ /** GET /settings/clawbot/memories?project=xxx — list memories for a project */
387
+ settingsRoutes.get("/clawbot/memories", (c) => {
388
+ const project = c.req.query("project") || "_global";
389
+ const memories = getPPMBotMemories(project, 50);
390
+ return c.json(ok(memories));
391
+ });
392
+
393
+ /** DELETE /settings/clawbot/memories/:id — delete a specific memory */
394
+ settingsRoutes.delete("/clawbot/memories/:id", (c) => {
395
+ const id = Number(c.req.param("id"));
396
+ if (!id) return c.json(err("Invalid memory ID"), 400);
397
+ try {
398
+ getDb().query("DELETE FROM clawbot_memories WHERE id = ?").run(id);
399
+ return c.json(ok({ deleted: id }));
400
+ } catch (e) {
401
+ return c.json(err((e as Error).message), 500);
402
+ }
403
+ });
@@ -19,7 +19,7 @@ const PPM_DIR = resolve(homedir(), ".ppm");
19
19
 
20
20
  /** Top-level config keys stored in the config table (not projects) */
21
21
  const CONFIG_TABLE_KEYS: (keyof PpmConfig)[] = [
22
- "device_name", "port", "host", "theme", "auth", "ai", "push", "telegram",
22
+ "device_name", "port", "host", "theme", "auth", "ai", "push", "telegram", "clawbot",
23
23
  ];
24
24
 
25
25
  class ConfigService {
@@ -4,7 +4,7 @@ import { homedir } from "node:os";
4
4
  import { mkdirSync, existsSync } from "node:fs";
5
5
 
6
6
  const PPM_DIR = process.env.PPM_HOME || resolve(homedir(), ".ppm");
7
- const CURRENT_SCHEMA_VERSION = 12;
7
+ const CURRENT_SCHEMA_VERSION = 13;
8
8
 
9
9
  let db: Database | null = null;
10
10
  let dbProfile: string | null = null;
@@ -313,6 +313,80 @@ function runMigrations(database: Database): void {
313
313
  PRAGMA user_version = 12;
314
314
  `);
315
315
  }
316
+
317
+ if (current < 13) {
318
+ database.exec(`
319
+ CREATE TABLE IF NOT EXISTS clawbot_sessions (
320
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
321
+ telegram_chat_id TEXT NOT NULL,
322
+ session_id TEXT NOT NULL,
323
+ provider_id TEXT NOT NULL DEFAULT 'claude',
324
+ project_name TEXT NOT NULL,
325
+ project_path TEXT NOT NULL,
326
+ is_active INTEGER NOT NULL DEFAULT 1,
327
+ created_at INTEGER NOT NULL DEFAULT (unixepoch()),
328
+ last_message_at INTEGER NOT NULL DEFAULT (unixepoch())
329
+ );
330
+
331
+ CREATE INDEX IF NOT EXISTS idx_clawbot_sessions_chat
332
+ ON clawbot_sessions(telegram_chat_id, is_active);
333
+ CREATE INDEX IF NOT EXISTS idx_clawbot_sessions_session
334
+ ON clawbot_sessions(session_id);
335
+
336
+ CREATE TABLE IF NOT EXISTS clawbot_memories (
337
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
338
+ project TEXT NOT NULL,
339
+ content TEXT NOT NULL,
340
+ category TEXT NOT NULL DEFAULT 'fact',
341
+ importance REAL NOT NULL DEFAULT 1.0,
342
+ created_at INTEGER NOT NULL DEFAULT (unixepoch()),
343
+ updated_at INTEGER NOT NULL DEFAULT (unixepoch()),
344
+ session_id TEXT,
345
+ superseded_by INTEGER REFERENCES clawbot_memories(id)
346
+ );
347
+
348
+ CREATE INDEX IF NOT EXISTS idx_clawbot_memories_project
349
+ ON clawbot_memories(project, superseded_by);
350
+ CREATE INDEX IF NOT EXISTS idx_clawbot_memories_importance
351
+ ON clawbot_memories(importance DESC);
352
+
353
+ CREATE VIRTUAL TABLE IF NOT EXISTS clawbot_memories_fts USING fts5(
354
+ content,
355
+ content='clawbot_memories',
356
+ content_rowid='id'
357
+ );
358
+
359
+ -- FTS5 sync triggers
360
+ CREATE TRIGGER IF NOT EXISTS clawbot_memories_ai AFTER INSERT ON clawbot_memories BEGIN
361
+ INSERT INTO clawbot_memories_fts(rowid, content) VALUES (new.id, new.content);
362
+ END;
363
+
364
+ CREATE TRIGGER IF NOT EXISTS clawbot_memories_ad AFTER DELETE ON clawbot_memories BEGIN
365
+ INSERT INTO clawbot_memories_fts(clawbot_memories_fts, rowid, content) VALUES('delete', old.id, old.content);
366
+ END;
367
+
368
+ CREATE TRIGGER IF NOT EXISTS clawbot_memories_au AFTER UPDATE ON clawbot_memories BEGIN
369
+ INSERT INTO clawbot_memories_fts(clawbot_memories_fts, rowid, content) VALUES('delete', old.id, old.content);
370
+ INSERT INTO clawbot_memories_fts(rowid, content) VALUES (new.id, new.content);
371
+ END;
372
+
373
+ CREATE TABLE IF NOT EXISTS clawbot_paired_chats (
374
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
375
+ telegram_chat_id TEXT NOT NULL UNIQUE,
376
+ telegram_user_id TEXT,
377
+ display_name TEXT,
378
+ pairing_code TEXT,
379
+ status TEXT NOT NULL DEFAULT 'pending',
380
+ created_at INTEGER NOT NULL DEFAULT (unixepoch()),
381
+ approved_at INTEGER
382
+ );
383
+
384
+ CREATE INDEX IF NOT EXISTS idx_clawbot_paired_status
385
+ ON clawbot_paired_chats(status);
386
+
387
+ PRAGMA user_version = 13;
388
+ `);
389
+ }
316
390
  }
317
391
 
318
392
  // ---------------------------------------------------------------------------
@@ -894,5 +968,215 @@ export function deleteExtensionStorage(extId: string): void {
894
968
  getDb().query("DELETE FROM extension_storage WHERE ext_id = ?").run(extId);
895
969
  }
896
970
 
971
+ // ---------------------------------------------------------------------------
972
+ // PPMBot session helpers
973
+ // ---------------------------------------------------------------------------
974
+
975
+ import type { PPMBotSessionRow, PPMBotMemoryRow, PPMBotPairedChat } from "../types/ppmbot.ts";
976
+
977
+ export function getActivePPMBotSession(
978
+ telegramChatId: string,
979
+ projectName: string,
980
+ ): PPMBotSessionRow | null {
981
+ return getDb().query(
982
+ `SELECT * FROM clawbot_sessions
983
+ WHERE telegram_chat_id = ? AND project_name = ? AND is_active = 1
984
+ ORDER BY last_message_at DESC LIMIT 1`,
985
+ ).get(telegramChatId, projectName) as PPMBotSessionRow | null;
986
+ }
987
+
988
+ export function createPPMBotSession(
989
+ telegramChatId: string,
990
+ sessionId: string,
991
+ providerId: string,
992
+ projectName: string,
993
+ projectPath: string,
994
+ ): void {
995
+ getDb().query(
996
+ `INSERT INTO clawbot_sessions
997
+ (telegram_chat_id, session_id, provider_id, project_name, project_path)
998
+ VALUES (?, ?, ?, ?, ?)`,
999
+ ).run(telegramChatId, sessionId, providerId, projectName, projectPath);
1000
+ }
1001
+
1002
+ export function deactivatePPMBotSession(sessionId: string): void {
1003
+ getDb().query(
1004
+ "UPDATE clawbot_sessions SET is_active = 0 WHERE session_id = ?",
1005
+ ).run(sessionId);
1006
+ }
1007
+
1008
+ export function touchPPMBotSession(sessionId: string): void {
1009
+ getDb().query(
1010
+ "UPDATE clawbot_sessions SET last_message_at = unixepoch() WHERE session_id = ?",
1011
+ ).run(sessionId);
1012
+ }
1013
+
1014
+ export function getRecentPPMBotSessions(
1015
+ telegramChatId: string,
1016
+ limit = 10,
1017
+ ): PPMBotSessionRow[] {
1018
+ return getDb().query(
1019
+ `SELECT * FROM clawbot_sessions
1020
+ WHERE telegram_chat_id = ?
1021
+ ORDER BY last_message_at DESC LIMIT ?`,
1022
+ ).all(telegramChatId, limit) as PPMBotSessionRow[];
1023
+ }
1024
+
1025
+ // ---------------------------------------------------------------------------
1026
+ // PPMBot memory helpers
1027
+ // ---------------------------------------------------------------------------
1028
+
1029
+ export function insertPPMBotMemory(
1030
+ project: string,
1031
+ content: string,
1032
+ category: string,
1033
+ importance: number,
1034
+ sessionId?: string,
1035
+ ): number {
1036
+ const result = getDb().query(
1037
+ `INSERT INTO clawbot_memories (project, content, category, importance, session_id)
1038
+ VALUES (?, ?, ?, ?, ?)`,
1039
+ ).run(project, content, category, importance, sessionId ?? null);
1040
+ return Number(result.lastInsertRowid);
1041
+ }
1042
+
1043
+ export function searchPPMBotMemories(
1044
+ project: string,
1045
+ query: string,
1046
+ limit = 20,
1047
+ ): Array<PPMBotMemoryRow & { rank: number }> {
1048
+ return getDb().query(
1049
+ `SELECT m.*, fts.rank
1050
+ FROM clawbot_memories m
1051
+ JOIN clawbot_memories_fts fts ON m.id = fts.rowid
1052
+ WHERE clawbot_memories_fts MATCH ?
1053
+ AND m.project IN (?, '_global')
1054
+ AND m.superseded_by IS NULL
1055
+ ORDER BY fts.rank
1056
+ LIMIT ?`,
1057
+ ).all(query, project, limit) as Array<PPMBotMemoryRow & { rank: number }>;
1058
+ }
1059
+
1060
+ export function getPPMBotMemories(
1061
+ project: string,
1062
+ limit = 20,
1063
+ ): PPMBotMemoryRow[] {
1064
+ return getDb().query(
1065
+ `SELECT * FROM clawbot_memories
1066
+ WHERE project IN (?, '_global')
1067
+ AND superseded_by IS NULL
1068
+ ORDER BY importance DESC, updated_at DESC
1069
+ LIMIT ?`,
1070
+ ).all(project, limit) as PPMBotMemoryRow[];
1071
+ }
1072
+
1073
+ export function supersedePPMBotMemory(
1074
+ oldId: number,
1075
+ newId: number,
1076
+ ): void {
1077
+ getDb().query(
1078
+ "UPDATE clawbot_memories SET superseded_by = ? WHERE id = ?",
1079
+ ).run(newId, oldId);
1080
+ }
1081
+
1082
+ export function deletePPMBotMemoriesByTopic(
1083
+ project: string,
1084
+ topic: string,
1085
+ ): number {
1086
+ const matches = getDb().query(
1087
+ `SELECT m.id FROM clawbot_memories m
1088
+ JOIN clawbot_memories_fts fts ON m.id = fts.rowid
1089
+ WHERE clawbot_memories_fts MATCH ?
1090
+ AND m.project IN (?, '_global')
1091
+ AND m.superseded_by IS NULL`,
1092
+ ).all(topic, project) as { id: number }[];
1093
+
1094
+ for (const row of matches) {
1095
+ getDb().query("DELETE FROM clawbot_memories WHERE id = ?").run(row.id);
1096
+ }
1097
+ return matches.length;
1098
+ }
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
+
1114
+ // ---------------------------------------------------------------------------
1115
+ // PPMBot pairing helpers
1116
+ // ---------------------------------------------------------------------------
1117
+
1118
+ export function createPairingRequest(
1119
+ chatId: string,
1120
+ userId: string,
1121
+ displayName: string,
1122
+ code: string,
1123
+ ): void {
1124
+ getDb().query(
1125
+ `INSERT INTO clawbot_paired_chats (telegram_chat_id, telegram_user_id, display_name, pairing_code, status)
1126
+ VALUES (?, ?, ?, ?, 'pending')
1127
+ ON CONFLICT(telegram_chat_id) DO UPDATE SET
1128
+ telegram_user_id = excluded.telegram_user_id,
1129
+ display_name = excluded.display_name,
1130
+ pairing_code = excluded.pairing_code,
1131
+ status = 'pending',
1132
+ approved_at = NULL`,
1133
+ ).run(chatId, userId, displayName, code);
1134
+ }
1135
+
1136
+ export function approvePairing(chatId: string): void {
1137
+ getDb().query(
1138
+ `UPDATE clawbot_paired_chats
1139
+ SET status = 'approved', pairing_code = NULL, approved_at = unixepoch()
1140
+ WHERE telegram_chat_id = ? AND status = 'pending'`,
1141
+ ).run(chatId);
1142
+ }
1143
+
1144
+ export function revokePairing(chatId: string): void {
1145
+ getDb().query(
1146
+ "UPDATE clawbot_paired_chats SET status = 'revoked' WHERE telegram_chat_id = ?",
1147
+ ).run(chatId);
1148
+ }
1149
+
1150
+ export function getPairingByCode(code: string): PPMBotPairedChat | null {
1151
+ return getDb().query(
1152
+ "SELECT * FROM clawbot_paired_chats WHERE pairing_code = ? AND status = 'pending'",
1153
+ ).get(code) as PPMBotPairedChat | null;
1154
+ }
1155
+
1156
+ export function getPairingByChatId(chatId: string): PPMBotPairedChat | null {
1157
+ return getDb().query(
1158
+ "SELECT * FROM clawbot_paired_chats WHERE telegram_chat_id = ?",
1159
+ ).get(chatId) as PPMBotPairedChat | null;
1160
+ }
1161
+
1162
+ export function listPairedChats(): PPMBotPairedChat[] {
1163
+ return getDb().query(
1164
+ "SELECT * FROM clawbot_paired_chats WHERE status != 'revoked' ORDER BY created_at DESC",
1165
+ ).all() as PPMBotPairedChat[];
1166
+ }
1167
+
1168
+ export function getApprovedPairedChats(): PPMBotPairedChat[] {
1169
+ return getDb().query(
1170
+ "SELECT * FROM clawbot_paired_chats WHERE status = 'approved' ORDER BY created_at DESC",
1171
+ ).all() as PPMBotPairedChat[];
1172
+ }
1173
+
1174
+ export function isPairedChat(chatId: string): boolean {
1175
+ const row = getDb().query(
1176
+ "SELECT 1 FROM clawbot_paired_chats WHERE telegram_chat_id = ? AND status = 'approved'",
1177
+ ).get(chatId);
1178
+ return row != null;
1179
+ }
1180
+
897
1181
  // Auto-close on process exit
898
1182
  process.on("beforeExit", closeDb);
@@ -0,0 +1,88 @@
1
+ const MAX_MESSAGE_LENGTH = 4096;
2
+
3
+ /** Escape HTML special chars for Telegram HTML parse mode */
4
+ export function escapeHtml(str: string): string {
5
+ return str
6
+ .replace(/&/g, "&amp;")
7
+ .replace(/</g, "&lt;")
8
+ .replace(/>/g, "&gt;")
9
+ .replace(/"/g, "&quot;");
10
+ }
11
+
12
+ /**
13
+ * Convert Markdown to Telegram-compatible HTML.
14
+ * Handles: **bold**, *italic*, `code`, ```pre```, [links](url), ~~strikethrough~~
15
+ * Does NOT handle nested formatting (Telegram limitation).
16
+ */
17
+ export function markdownToTelegramHtml(md: string): string {
18
+ let html = md;
19
+
20
+ // Code blocks first (prevent inner processing)
21
+ html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_match, lang, code) => {
22
+ const langAttr = lang ? ` class="language-${escapeHtml(lang)}"` : "";
23
+ return `<pre><code${langAttr}>${escapeHtml(code.trimEnd())}</code></pre>`;
24
+ });
25
+
26
+ // Inline code: `code` → <code>code</code>
27
+ html = html.replace(/`([^`\n]+)`/g, (_match, code) => {
28
+ return `<code>${escapeHtml(code)}</code>`;
29
+ });
30
+
31
+ // Bold: **text** → <b>text</b>
32
+ html = html.replace(/\*\*(.+?)\*\*/g, "<b>$1</b>");
33
+
34
+ // Italic: *text* → <i>text</i>
35
+ html = html.replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, "<i>$1</i>");
36
+
37
+ // Strikethrough: ~~text~~ → <s>text</s>
38
+ html = html.replace(/~~(.+?)~~/g, "<s>$1</s>");
39
+
40
+ // Links: [text](url) → <a href="url">text</a>
41
+ html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
42
+
43
+ return html;
44
+ }
45
+
46
+ /**
47
+ * Split text into chunks that fit Telegram's 4096 char limit.
48
+ * Tries to break at newlines, falling back to word boundaries.
49
+ */
50
+ export function chunkMessage(text: string, maxLen = MAX_MESSAGE_LENGTH): string[] {
51
+ if (text.length <= maxLen) return [text];
52
+
53
+ const chunks: string[] = [];
54
+ let remaining = text;
55
+
56
+ while (remaining.length > 0) {
57
+ if (remaining.length <= maxLen) {
58
+ chunks.push(remaining);
59
+ break;
60
+ }
61
+
62
+ let breakAt = -1;
63
+ const searchWindow = remaining.slice(0, maxLen);
64
+
65
+ // Try double newline
66
+ breakAt = searchWindow.lastIndexOf("\n\n");
67
+ if (breakAt === -1 || breakAt < maxLen * 0.3) {
68
+ breakAt = searchWindow.lastIndexOf("\n");
69
+ }
70
+ if (breakAt === -1 || breakAt < maxLen * 0.3) {
71
+ breakAt = searchWindow.lastIndexOf(" ");
72
+ }
73
+ if (breakAt === -1 || breakAt < maxLen * 0.3) {
74
+ breakAt = maxLen;
75
+ }
76
+
77
+ chunks.push(remaining.slice(0, breakAt));
78
+ remaining = remaining.slice(breakAt).trimStart();
79
+ }
80
+
81
+ return chunks;
82
+ }
83
+
84
+ /** Truncate text for preview (e.g. session titles), adding ellipsis */
85
+ export function truncateForPreview(text: string, maxLen = 200): string {
86
+ if (text.length <= maxLen) return text;
87
+ return text.slice(0, maxLen - 1) + "\u2026";
88
+ }