@hienlh/ppm 0.9.31 → 0.9.32

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 (51) hide show
  1. package/.claude.bak/agent-memory/tester/MEMORY.md +3 -0
  2. package/.claude.bak/agent-memory/tester/project-ppm-test-conventions.md +32 -0
  3. package/CHANGELOG.md +16 -0
  4. package/dist/web/assets/{browser-tab-DmDrxklj.js → browser-tab-B9nNKjZX.js} +1 -1
  5. package/dist/web/assets/{chat-tab-CMwOy57v.js → chat-tab-6XGhEKaC.js} +2 -2
  6. package/dist/web/assets/{code-editor-jsL0PK8A.js → code-editor-DMZMpzt2.js} +1 -1
  7. package/dist/web/assets/{database-viewer-CBo5yPV-.js → database-viewer-CnP1FFS2.js} +1 -1
  8. package/dist/web/assets/{diff-viewer-Dk-plEOm.js → diff-viewer-Cvwd0XBO.js} +1 -1
  9. package/dist/web/assets/{extension-webview-B0tE14-C.js → extension-webview-DkhsRepr.js} +1 -1
  10. package/dist/web/assets/{git-graph-BsYuai5I.js → git-graph-C3670Nxm.js} +1 -1
  11. package/dist/web/assets/index-CcFDEPCo.css +2 -0
  12. package/dist/web/assets/index-DjIQL8ar.js +30 -0
  13. package/dist/web/assets/keybindings-store-DHh6rwm-.js +1 -0
  14. package/dist/web/assets/{markdown-renderer-lUfZhpU0.js → markdown-renderer-Co04dDdI.js} +1 -1
  15. package/dist/web/assets/{postgres-viewer-sZclUhuS.js → postgres-viewer-D8K1qnnA.js} +1 -1
  16. package/dist/web/assets/{settings-tab-CvbLGbR6.js → settings-tab-64ODAeQZ.js} +1 -1
  17. package/dist/web/assets/{sqlite-viewer-BAjul3Ct.js → sqlite-viewer-ClX7FICB.js} +1 -1
  18. package/dist/web/assets/{terminal-tab-Ds9ymO7D.js → terminal-tab-Dw4IKWGM.js} +1 -1
  19. package/dist/web/assets/{use-monaco-theme-D9bFLaXR.js → use-monaco-theme-DA7EyR70.js} +1 -1
  20. package/dist/web/index.html +2 -2
  21. package/dist/web/sw.js +1 -1
  22. package/docs/codebase-summary.md +33 -3
  23. package/docs/project-changelog.md +47 -0
  24. package/docs/project-roadmap.md +14 -7
  25. package/docs/system-architecture.md +65 -2
  26. package/package.json +1 -1
  27. package/src/server/index.ts +7 -0
  28. package/src/server/routes/proxy.ts +15 -0
  29. package/src/server/routes/settings.ts +74 -1
  30. package/src/services/clawbot/clawbot-formatter.ts +88 -0
  31. package/src/services/clawbot/clawbot-memory.ts +333 -0
  32. package/src/services/clawbot/clawbot-service.ts +500 -0
  33. package/src/services/clawbot/clawbot-session.ts +188 -0
  34. package/src/services/clawbot/clawbot-streamer.ts +245 -0
  35. package/src/services/clawbot/clawbot-telegram.ts +251 -0
  36. package/src/services/config.service.ts +1 -1
  37. package/src/services/db.service.ts +279 -1
  38. package/src/services/proxy-openai-bridge.ts +241 -0
  39. package/src/services/proxy-sdk-bridge.ts +63 -21
  40. package/src/services/proxy.service.ts +33 -0
  41. package/src/types/clawbot.ts +103 -0
  42. package/src/types/config.ts +22 -0
  43. package/src/web/components/chat/chat-history-bar.tsx +8 -3
  44. package/src/web/components/settings/clawbot-settings-section.tsx +270 -0
  45. package/src/web/components/settings/proxy-settings-section.tsx +50 -37
  46. package/src/web/components/settings/proxy-test-section.tsx +48 -25
  47. package/src/web/components/settings/settings-tab.tsx +4 -1
  48. package/src/web/lib/api-settings.ts +2 -0
  49. package/dist/web/assets/index-CJvp0DJT.css +0 -2
  50. package/dist/web/assets/index-DMiaze7L.js +0 -37
  51. package/dist/web/assets/keybindings-store-B01E0k20.js +0 -1
@@ -109,14 +109,15 @@ PPM is the **lightest path from phone to code** — a self-hosted, BYOK, multi-d
109
109
 
110
110
  ### v0.11.0 — "Intelligence" (Q3–Q4 2026)
111
111
 
112
- **Theme:** Event system + PPM's own AI layer. Hooks → Skills API → Clawbot dependency chain.
112
+ **Theme:** Event system + PPM's own AI layer + Telegram bot. Hooks → Skills API → Clawbot dependency chain.
113
113
 
114
- | Feature | Priority | Description |
115
- |---------|----------|-------------|
116
- | **Hooks system** | High | Event hooks for PPM lifecycle (file save, git commit, chat message, etc.). Foundation for Skills API and deeper extension integration. |
117
- | **PPM Skills API** | High | Stable internal API for AI to control PPM: file.read/write/search, terminal.run, git.status/commit/diff, db.query, editor.open/goto, project.switch. Skills are the bridge between AI and PPM features. |
118
- | **Built-in Clawbot** | High | Lightweight AI agent built into PPM using Anthropic Messages API (not Agent SDK). Uses Skills API + MCP tools. Instant response, no external CLI deps. For quick tasks: file search, code explanation, simple refactors. |
119
- | **More providers** | Medium | Gemini CLI (Tier 2), OpenAI Codex (Tier 2), Tier 3 chat-only (any OpenAI-compatible API). Provider interface already clean from v0.9. |
114
+ | Feature | Priority | Status | Description |
115
+ |---------|----------|--------|-------------|
116
+ | **Hooks system** | High | — | Event hooks for PPM lifecycle (file save, git commit, chat message, etc.). Foundation for Skills API and deeper extension integration. |
117
+ | **PPM Skills API** | High | — | Stable internal API for AI to control PPM: file.read/write/search, terminal.run, git.status/commit/diff, db.query, editor.open/goto, project.switch. Skills are the bridge between AI and PPM features. |
118
+ | **Telegram Bot (ClawBot)** | High | Done (v0.9.10) | Long-polling Telegram bot with session routing, FTS5 memory system, progressive message editing, pairing-based security. Routes through existing providers (Claude SDK, Cursor). No external bot infrastructure. |
119
+ | **Built-in Clawbot** | High | | Lightweight AI agent built into PPM using Anthropic Messages API (not Agent SDK). Uses Skills API + MCP tools. Instant response, no external CLI deps. For quick tasks: file search, code explanation, simple refactors. |
120
+ | **More providers** | Medium | — | Gemini CLI (Tier 2), OpenAI Codex (Tier 2), Tier 3 chat-only (any OpenAI-compatible API). Provider interface already clean from v0.9. |
120
121
 
121
122
  **Built-in Clawbot — why it matters:**
122
123
  - Claude Agent SDK spawns subprocess — heavy, slow startup, requires CLI installed
@@ -124,6 +125,12 @@ PPM is the **lightest path from phone to code** — a self-hosted, BYOK, multi-d
124
125
  - Users can use Clawbot to create extensions → zero-friction extension authoring
125
126
  - Foundation for "AI creates extensions on demand" vision
126
127
 
128
+ **Telegram Bot — why it matters:**
129
+ - Extends PPM beyond the web UI → reach users on mobile, messaging apps
130
+ - Long-polling avoids webhooks — simpler for self-hosted, no public URL needed
131
+ - Paired devices enable secure multi-user access — owner controls who can use the bot
132
+ - Persistent memory in FTS5 — bot learns conversation context over time
133
+
127
134
  ---
128
135
 
129
136
  ### v1.0.0 — "Production Ready" (Q4 2026)
@@ -193,8 +193,14 @@ Tab IDs are deterministic: `{type}:{identifier}` (e.g., `editor:src/index.ts`, `
193
193
  | **AccountService** | Account CRUD, token encryption/decryption | getAccounts, createAccount, updateAccount, deleteAccount |
194
194
  | **AccountSelectorService** | Select active account based on config | getActiveAccount, setActiveAccount, selectByProject |
195
195
  | **UpgradeService** | Version checking, installation, self-replace signaling | checkForUpdate, applyUpgrade, getInstallMethod, compareSemver |
196
+ | **ClawBotService** | Telegram bot orchestrator | start, stop, handleMessage, routeToChat |
197
+ | **ClawBotTelegramService** | Telegram long-polling, message ops | getUpdates, sendMessage, editMessage, setTyping, handleCommands |
198
+ | **ClawBotSessionService** | chatID → PPM sessionID mapping | mapSession, getSession, deleteSession |
199
+ | **ClawBotMemoryService** | FTS5 memory persistence | saveMemory, recallMemories, decayMemories, searchByProject |
200
+ | **ClawBotFormatterService** | Markdown → Telegram HTML + chunking | formatMarkdown, chunkMessage |
201
+ | **ClawBotStreamerService** | ChatEvent → progressive Telegram edits | streamMessageEdits |
196
202
 
197
- **Key Files:** `src/services/*.service.ts`
203
+ **Key Files:** `src/services/*.service.ts`, `src/services/clawbot/*.ts`
198
204
 
199
205
  ---
200
206
 
@@ -263,6 +269,63 @@ PPM supports multiple AI providers through a generic `AIProvider` interface and
263
269
 
264
270
  ---
265
271
 
272
+ ### ClawBot Service Layer (Telegram Bot Integration)
273
+ **Component:** Telegram bot service + subsidiary services
274
+
275
+ **Responsibilities:**
276
+ - Receive Telegram messages via long-polling (no webhooks needed)
277
+ - Route Telegram user (chatID) to PPM session with pairing-based security
278
+ - Persist session state + conversation memory in SQLite (FTS5)
279
+ - Stream AI responses back to Telegram with progressive message editing
280
+ - Format responses as Telegram HTML with proper chunking (4096 char limit)
281
+
282
+ **Architecture:**
283
+ ```
284
+ Telegram → ClawBotTelegramService (polling) → ClawBotService (orchestrator)
285
+
286
+ ClawBotSessionService (chatID→sessionID)
287
+ ClawBotMemoryService (FTS5 recall)
288
+ ChatService + ProviderRegistry
289
+ ClawBotStreamerService (ChatEvent→edits)
290
+ ClawBotFormatterService (Markdown→HTML)
291
+ ```
292
+
293
+ **Services (src/services/clawbot/):**
294
+ - **ClawBotService** — Lifecycle management (start/stop), message queue, routing logic
295
+ - **ClawBotTelegramService** — Telegram Bot API wrapper (getUpdates long-polling, sendMessage, editMessage, setTyping, command handlers)
296
+ - **ClawBotSessionService** — chatID ↔ PPM sessionID bidirectional mapping, session state tracking
297
+ - **ClawBotMemoryService** — FTS5 persistent memory (save, recall with relevance, decay factor, supersede logic, cross-project search by name mention)
298
+ - **ClawBotFormatterService** — Markdown → Telegram HTML conversion, message chunking (respects 4096 char limit), code block formatting
299
+ - **ClawBotStreamerService** — ChatEvent async generator → progressive Telegram message edits (1s throttle for rate limiting)
300
+
301
+ **Security Model:**
302
+ - **Pairing System** — Replace allowlists with code-based pairing: User requests pairing → receives code → owner approves in web UI → chatID registered in `clawbot_paired_chats`
303
+ - **Per-User Sessions** — Each Telegram chatID maps to isolated PPM session (no cross-user interference)
304
+ - **bypassPermissions** — ClawBot bot runs headless, auto-approves tools (no manual approval flow)
305
+
306
+ **Database Schema (v13):**
307
+ - `clawbot_sessions` — chatID (PK), sessionID (FK chat_sessions), pairedAt, lastUsed
308
+ - `clawbot_memories` — id (PK), sessionID (FK), content (text), role (user|assistant), created, decay_factor (FTS5 full-text index)
309
+ - `clawbot_paired_chats` — chatID (PK), pairingCode (unique, 6 chars), approvedAt, approvedBy (user ID)
310
+
311
+ **Key Design Decisions:**
312
+ 1. **Long-polling** — No webhooks = no public URL required, simpler for self-hosted
313
+ 2. **Message queue** — Concurrent Telegram messages queued FIFO, prevents race conditions
314
+ 3. **Progressive edits** — Edit same message for long responses, reduce Telegram API calls, better UX
315
+ 4. **Memory system** — Hybrid extraction (AI primary + regex fallback), supports cross-project search by project name mention
316
+ 5. **Config reuse** — Shares existing Telegram bot_token with notifications, separate ClawBotConfig section
317
+ 6. **Session tagging** — [Claw] prefix visible in web UI without schema changes, robot icon for identification
318
+
319
+ **Settings Endpoints:**
320
+ - `GET /api/settings/clawbot` — Fetch config (enabled, bot token, default project, system prompt, debounce, display toggles)
321
+ - `PUT /api/settings/clawbot` — Update config
322
+ - `GET /api/clawbot/paired-chats` — List paired Telegram chatIDs
323
+ - `POST /api/clawbot/pairing` — Request pairing code (returns code)
324
+ - `POST /api/clawbot/pairing/:code/approve` — Approve pairing code (owner only)
325
+ - `DELETE /api/clawbot/paired-chats/:chatId` — Revoke pairing
326
+
327
+ ---
328
+
266
329
  ### Data Access Layer (SQLite + Filesystem + Git)
267
330
  **Components:** SQLite via bun:sqlite, direct filesystem access, simple-git wrapper
268
331
 
@@ -274,7 +337,7 @@ PPM supports multiple AI providers through a generic `AIProvider` interface and
274
337
  - Enforce security (no parent directory access)
275
338
 
276
339
  **Key Patterns:**
277
- - SQLite: WAL mode, foreign keys, lazy init, schema v10 (10 tables: config, connections, accounts, usage_history, session_logs, push_subscriptions, session_map, table_metadata, session_logs, workspace_state)
340
+ - SQLite: WAL mode, foreign keys, lazy init, schema v13 (13 tables: config, connections, accounts, usage_history, session_logs, push_subscriptions, session_map, table_metadata, workspace_state, extension_storage, mcp_servers, clawbot_sessions, clawbot_memories, clawbot_paired_chats)
278
341
  - Path validation: `projectPath/relativePath` only, reject `..`
279
342
  - Caching: Directory trees cached with TTL
280
343
  - Error handling: Descriptive messages (file not found, permission denied)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hienlh/ppm",
3
- "version": "0.9.31",
3
+ "version": "0.9.32",
4
4
  "description": "Personal Project Manager — mobile-first web IDE with AI assistance",
5
5
  "author": "hienlh",
6
6
  "license": "MIT",
@@ -477,5 +477,12 @@ if (process.argv.includes("__serve__")) {
477
477
  console.error("[ExtService] Startup error:", e);
478
478
  });
479
479
 
480
+ // Start ClawBot Telegram poller (if enabled)
481
+ import("../services/clawbot/clawbot-service.ts")
482
+ .then(({ clawbotService }) => clawbotService.start())
483
+ .catch((e) => {
484
+ console.error("[clawbot] Startup error:", e);
485
+ });
486
+
480
487
  console.log(`Server child ready on port ${port}`);
481
488
  }
@@ -57,6 +57,21 @@ proxyRoutes.post("/v1/messages", async (c) => {
57
57
  return proxyService.forward("/v1/messages", "POST", headers, body);
58
58
  });
59
59
 
60
+ /** POST /proxy/v1/chat/completions — OpenAI-compatible chat completions proxy */
61
+ proxyRoutes.post("/v1/chat/completions", async (c) => {
62
+ if (!proxyService.isEnabled()) {
63
+ return c.json({ error: { message: "Proxy is disabled", type: "server_error" } }, 503);
64
+ }
65
+
66
+ const authHeader = c.req.header("authorization") || c.req.header("x-api-key");
67
+ if (!validateProxyAuth(authHeader)) {
68
+ return c.json({ error: { message: "Invalid proxy auth key", type: "authentication_error" } }, 401);
69
+ }
70
+
71
+ const body = await c.req.text();
72
+ return proxyService.forwardOpenAi(body);
73
+ });
74
+
60
75
  /** POST /proxy/v1/messages/count_tokens — token counting proxy */
61
76
  proxyRoutes.post("/v1/messages/count_tokens", async (c) => {
62
77
  if (!proxyService.isEnabled()) {
@@ -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 } 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 ClawBotConfig,
10
12
  type ThemeConfig,
11
13
  } from "../../types/config.ts";
12
14
  import { ok, err } from "../../types/api.ts";
@@ -292,8 +294,10 @@ async function buildProxyResponse() {
292
294
  authKey: proxyService.getAuthKey() ?? null,
293
295
  requestCount: proxyService.getRequestCount(),
294
296
  localEndpoint: `${localOrigin}/proxy/v1/messages`,
297
+ localOpenAiEndpoint: `${localOrigin}/proxy/v1/chat/completions`,
295
298
  tunnelUrl: tunnelUrl ?? null,
296
299
  proxyEndpoint: tunnelUrl ? `${tunnelUrl}/proxy/v1/messages` : null,
300
+ openAiEndpoint: tunnelUrl ? `${tunnelUrl}/proxy/v1/chat/completions` : null,
297
301
  };
298
302
  }
299
303
 
@@ -314,3 +318,72 @@ settingsRoutes.put("/proxy", async (c) => {
314
318
  return c.json(err((e as Error).message), 400);
315
319
  }
316
320
  });
321
+
322
+ // ── ClawBot ─────────────────────────────────────────────────────
323
+
324
+ /** GET /settings/clawbot — return current clawbot config */
325
+ settingsRoutes.get("/clawbot", (c) => {
326
+ const config = configService.get("clawbot") as ClawBotConfig | undefined;
327
+ if (!config) return c.json(ok(DEFAULT_CONFIG.clawbot));
328
+ return c.json(ok(config));
329
+ });
330
+
331
+ /** PUT /settings/clawbot — update clawbot config */
332
+ settingsRoutes.put("/clawbot", async (c) => {
333
+ try {
334
+ const body = await c.req.json<Partial<ClawBotConfig>>();
335
+ const current = (configService.get("clawbot") as ClawBotConfig | undefined)
336
+ ?? structuredClone(DEFAULT_CONFIG.clawbot!);
337
+ const updated: ClawBotConfig = { ...current, ...body };
338
+
339
+ if (updated.debounce_ms < 0 || updated.debounce_ms > 30000) {
340
+ return c.json(err("debounce_ms must be 0-30000"), 400);
341
+ }
342
+
343
+ configService.set("clawbot", updated);
344
+ configService.save();
345
+
346
+ // Restart clawbot if running state changed
347
+ try {
348
+ const { clawbotService } = await import("../../services/clawbot/clawbot-service.ts");
349
+ if (updated.enabled && !clawbotService.isRunning) {
350
+ await clawbotService.start();
351
+ } else if (!updated.enabled && clawbotService.isRunning) {
352
+ clawbotService.stop();
353
+ }
354
+ } catch { /* ClawBot module not loaded yet — OK */ }
355
+
356
+ return c.json(ok(updated));
357
+ } catch (e) {
358
+ return c.json(err((e as Error).message), 400);
359
+ }
360
+ });
361
+
362
+ /** GET /settings/clawbot/paired — list paired devices */
363
+ settingsRoutes.get("/clawbot/paired", (c) => {
364
+ return c.json(ok(listPairedChats()));
365
+ });
366
+
367
+ /** POST /settings/clawbot/paired/approve — approve pairing by code */
368
+ settingsRoutes.post("/clawbot/paired/approve", async (c) => {
369
+ try {
370
+ const { code } = await c.req.json<{ code: string }>();
371
+ const pairing = getPairingByCode(code);
372
+ if (!pairing) return c.json(err("Invalid pairing code"), 404);
373
+ approvePairing(pairing.telegram_chat_id);
374
+ // Notify user on Telegram
375
+ try {
376
+ const { clawbotService } = await import("../../services/clawbot/clawbot-service.ts");
377
+ await clawbotService.notifyPairingApproved(pairing.telegram_chat_id);
378
+ } catch { /* OK */ }
379
+ return c.json(ok({ approved: pairing.telegram_chat_id }));
380
+ } catch (e) {
381
+ return c.json(err((e as Error).message), 400);
382
+ }
383
+ });
384
+
385
+ /** DELETE /settings/clawbot/paired/:chatId — revoke pairing */
386
+ settingsRoutes.delete("/clawbot/paired/:chatId", (c) => {
387
+ revokePairing(c.req.param("chatId"));
388
+ return c.json(ok({ revoked: true }));
389
+ });
@@ -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
+ }