pikiclaw 0.2.62 → 0.2.64

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/README.md CHANGED
@@ -8,6 +8,8 @@
8
8
 
9
9
  > npx pikiclaw@latest
10
10
 
11
+ <img src="docs/promo-install.gif" alt="Quick install" width="700">
12
+
11
13
  <p align="center">
12
14
  <a href="https://www.npmjs.com/package/pikiclaw"><img src="https://img.shields.io/npm/v/pikiclaw" alt="npm"></a>
13
15
  <a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg" alt="License: MIT"></a>
@@ -45,7 +47,20 @@ pikiclaw 的目标很直接:
45
47
  你的电脑
46
48
  ```
47
49
 
48
- 它适合的不是“演示一次 AI”,而是你离开电脑以后,Agent 还能继续在本机把事做完。
50
+ 它适合的不是”演示一次 AI”,而是你离开电脑以后,Agent 还能继续在本机把事做完。
51
+
52
+ ### 在 Telegram 里长这样
53
+
54
+ <table>
55
+ <tr>
56
+ <td align=”center”><b>命令与 Agent 切换</b><br><img src=”docs/promo-tg-commands.png” alt=”Commands” width=”320”></td>
57
+ <td align=”center”><b>代码审查</b><br><img src=”docs/promo-tg-task.png” alt=”Code review” width=”320”></td>
58
+ </tr>
59
+ <tr>
60
+ <td align=”center”><b>多轮编码 + 文件回传</b><br><img src=”docs/promo-tg-complex.png” alt=”Complex task” width=”320”></td>
61
+ <td align=”center”><b>状态监控 + 会话管理</b><br><img src=”docs/promo-tg-sessions.png” alt=”Sessions” width=”320”></td>
62
+ </tr>
63
+ </table>
49
64
 
50
65
  ---
51
66
 
@@ -76,6 +91,23 @@ npx pikiclaw@latest
76
91
  - 工作目录切换
77
92
  - 会话和运行状态查看
78
93
 
94
+ <details>
95
+ <summary>Dashboard 截图</summary>
96
+
97
+ **配置管理** — IM 接入、AI Agent、系统权限
98
+
99
+ <img src="docs/promo-dashboard-config.png" alt="Config" width="700">
100
+
101
+ **插件中心** — 浏览器操控、桌面自动化
102
+
103
+ <img src="docs/promo-dashboard-extensions.png" alt="Extensions" width="700">
104
+
105
+ **会话管理** — 按 Agent 分组的会话泳道
106
+
107
+ <img src="docs/promo-dashboard-sessions.png" alt="Sessions" width="700">
108
+
109
+ </details>
110
+
79
111
  如果你更喜欢终端向导:
80
112
 
81
113
  ```bash
@@ -152,6 +184,13 @@ npx pikiclaw@latest --doctor
152
184
 
153
185
  普通文本消息会直接转给当前 Agent。
154
186
 
187
+ <details>
188
+ <summary>Telegram 命令效果预览</summary>
189
+
190
+ <img src="docs/promo-tg-commands.png" alt="Commands in Telegram" width="360">
191
+
192
+ </details>
193
+
155
194
  ---
156
195
 
157
196
  ## Config And Setup Notes
@@ -3,8 +3,9 @@ import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import { spawn } from 'node:child_process';
5
5
  import { getAgentLabel, getAgentPackage } from './agent-npm.js';
6
- const AGENT_UPDATE_LOCK_STALE_MS = 60 * 60_000;
7
- const AGENT_UPDATE_COMMAND_TIMEOUT_MS = 15 * 60_000;
6
+ import { AGENT_UPDATE_TIMEOUTS } from './constants.js';
7
+ const AGENT_UPDATE_LOCK_STALE_MS = AGENT_UPDATE_TIMEOUTS.lockStale;
8
+ const AGENT_UPDATE_COMMAND_TIMEOUT_MS = AGENT_UPDATE_TIMEOUTS.commandTimeout;
8
9
  function updaterLockPath() {
9
10
  return path.join(os.homedir(), '.pikiclaw', 'agent-auto-update.lock');
10
11
  }
@@ -95,11 +96,11 @@ async function runCommand(cmd, args, opts = {}) {
95
96
  });
96
97
  }
97
98
  async function getNpmGlobalPrefix() {
98
- const result = await runCommand('npm', ['prefix', '-g'], { timeoutMs: 10_000 });
99
+ const result = await runCommand('npm', ['prefix', '-g'], { timeoutMs: AGENT_UPDATE_TIMEOUTS.npmPrefix });
99
100
  return result.ok ? result.stdout.trim().split('\n')[0] || null : null;
100
101
  }
101
102
  async function getLatestPackageVersion(pkg) {
102
- const result = await runCommand('npm', ['view', pkg, 'version', '--json'], { timeoutMs: 20_000 });
103
+ const result = await runCommand('npm', ['view', pkg, 'version', '--json'], { timeoutMs: AGENT_UPDATE_TIMEOUTS.npmView });
103
104
  if (!result.ok)
104
105
  return null;
105
106
  const raw = result.stdout.trim();
@@ -5,64 +5,21 @@
5
5
  * Also provides a LivePreviewRenderer for streaming output.
6
6
  */
7
7
  import { encodeCommandAction } from './bot-command-ui.js';
8
- import { fmtUptime, fmtTokens, fmtBytes, formatThinkingForDisplay, thinkLabel } from './bot.js';
8
+ import { fmtUptime, fmtTokens, fmtBytes } from './bot.js';
9
9
  import { summarizePromptForStatus } from './bot-commands.js';
10
- import { formatProviderUsageLines } from './bot-telegram-render.js';
11
- import { formatActivityCommandSummary, parseActivitySummary, renderPlanForPreview, summarizeActivityForPreview } from './bot-streaming.js';
10
+ import { footerStatusSymbol, formatFooterSummary, trimActivityForPreview, buildProviderUsageLines, extractFinalReplyData, extractStreamPreviewData, } from './bot-render-shared.js';
12
11
  import { currentHumanLoopQuestion, humanLoopAnsweredCount, isHumanLoopAwaitingText, isHumanLoopQuestionAnswered, summarizeHumanLoopAnswer, } from './human-loop.js';
13
12
  import path from 'node:path';
14
13
  import { listSubdirs } from './bot.js';
15
14
  // ---------------------------------------------------------------------------
16
15
  // Helpers
17
16
  // ---------------------------------------------------------------------------
18
- function fmtCompactUptime(ms) {
19
- return fmtUptime(ms).replace(/\s+/g, '');
20
- }
21
- function footerStatusSymbol(status) {
22
- switch (status) {
23
- case 'running': return '●';
24
- case 'done': return '✓';
25
- case 'failed': return '✗';
26
- }
27
- }
28
- function formatFooterSummary(agent, elapsedMs, meta, contextPercent) {
29
- const parts = [agent];
30
- const ctx = contextPercent ?? meta?.contextPercent ?? null;
31
- if (ctx != null)
32
- parts.push(`${ctx}%`);
33
- parts.push(fmtCompactUptime(Math.max(0, Math.round(elapsedMs))));
34
- return parts.join(' · ');
35
- }
36
17
  function formatPreviewFooter(agent, elapsedMs, meta) {
37
18
  return `${footerStatusSymbol('running')} ${formatFooterSummary(agent, elapsedMs, meta)}`;
38
19
  }
39
20
  function formatFinalFooter(status, agent, elapsedMs, contextPercent) {
40
21
  return `${footerStatusSymbol(status)} ${formatFooterSummary(agent, elapsedMs, null, contextPercent ?? null)}`;
41
22
  }
42
- function trimActivityForPreview(text, maxChars = 900) {
43
- if (text.length <= maxChars)
44
- return text;
45
- const lines = text.split('\n').filter(l => l.trim());
46
- if (lines.length <= 1)
47
- return text.slice(0, Math.max(0, maxChars - 3)).trimEnd() + '...';
48
- const tailCount = Math.min(2, Math.max(1, lines.length - 1));
49
- const tail = lines.slice(-tailCount);
50
- const headCandidates = lines.slice(0, Math.max(0, lines.length - tailCount));
51
- const reserved = tail.join('\n').length + 5;
52
- const budget = Math.max(0, maxChars - reserved);
53
- const head = [];
54
- let used = 0;
55
- for (const line of headCandidates) {
56
- const extra = line.length + (head.length ? 1 : 0);
57
- if (used + extra > budget)
58
- break;
59
- head.push(line);
60
- used += extra;
61
- }
62
- if (!head.length)
63
- return text.slice(0, Math.max(0, maxChars - 3)).trimEnd() + '...';
64
- return [...head, '...', ...tail].join('\n');
65
- }
66
23
  function truncateLabel(label, maxChars = 24) {
67
24
  return label.length > maxChars ? `${label.slice(0, Math.max(1, maxChars - 1))}…` : label;
68
25
  }
@@ -212,31 +169,22 @@ export function buildInitialPreviewMarkdown(agent, model, effort, waiting = fals
212
169
  return parts.join(' · ');
213
170
  }
214
171
  function buildPreviewMarkdown(input, options) {
215
- const maxBody = 2400;
216
- const display = input.bodyText.trim();
217
- const rawThinking = input.thinking.trim();
218
- const thinkDisplay = formatThinkingForDisplay(input.thinking, maxBody);
219
- const planDisplay = renderPlanForPreview(input.plan ?? null);
220
- const activityDisplay = summarizeActivityForPreview(input.activity);
221
- const maxActivity = !display && !thinkDisplay && !planDisplay ? 2400 : 1400;
172
+ const data = extractStreamPreviewData(input);
222
173
  const parts = [];
223
- const label = thinkLabel(input.agent);
224
- if (planDisplay) {
225
- parts.push(`**Plan**\n${planDisplay}`);
174
+ if (data.planDisplay) {
175
+ parts.push(`**Plan**\n${data.planDisplay}`);
226
176
  }
227
- if (activityDisplay) {
228
- parts.push(`**Activity**\n${trimActivityForPreview(activityDisplay, maxActivity)}`);
177
+ if (data.activityDisplay) {
178
+ parts.push(`**Activity**\n${trimActivityForPreview(data.activityDisplay, data.maxActivity)}`);
229
179
  }
230
- if (thinkDisplay && !display) {
231
- parts.push(`**${label}**\n${thinkDisplay}`);
180
+ if (data.thinkDisplay && !data.display) {
181
+ parts.push(`**${data.label}**\n${data.thinkDisplay}`);
232
182
  }
233
- else if (display) {
234
- if (rawThinking) {
235
- const thinkSnippet = formatThinkingForDisplay(input.thinking, 600);
236
- parts.push(`**${label}**\n${thinkSnippet}`);
183
+ else if (data.display) {
184
+ if (data.rawThinking) {
185
+ parts.push(`**${data.label}**\n${data.thinkSnippet}`);
237
186
  }
238
- const preview = display.length > maxBody ? '(...truncated)\n' + display.slice(-maxBody) : display;
239
- parts.push(preview);
187
+ parts.push(data.preview);
240
188
  }
241
189
  if (options?.includeFooter !== false) {
242
190
  parts.push(formatPreviewFooter(input.agent, input.elapsedMs, input.meta ?? null));
@@ -260,46 +208,26 @@ export const feishuStreamingPreviewRenderer = {
260
208
  renderStream: buildStreamingBodyMarkdown,
261
209
  };
262
210
  export function buildFinalReplyRender(agent, result) {
263
- const footerStatus = result.incomplete || !result.ok ? 'failed' : 'done';
264
- const footerText = `\n\n${formatFinalFooter(footerStatus, agent, result.elapsedS * 1000, result.contextPercent ?? null)}`;
211
+ const data = extractFinalReplyData(agent, result);
212
+ const footerText = `\n\n${formatFinalFooter(data.footerStatus, agent, data.elapsedMs, result.contextPercent ?? null)}`;
265
213
  let activityText = '';
266
214
  let activityNoteText = '';
267
- if (result.activity) {
268
- const summary = parseActivitySummary(result.activity);
269
- const narrative = summary.narrative.join('\n');
270
- if (narrative) {
271
- let display = narrative;
272
- if (display.length > 1600)
273
- display = '...\n' + display.slice(-1600);
274
- activityText = `**Activity**\n${display}\n\n`;
275
- }
276
- const commandSummary = formatActivityCommandSummary(summary.completedCommands, summary.activeCommands, summary.failedCommands);
277
- if (commandSummary)
278
- activityNoteText = `*${commandSummary}*\n\n`;
215
+ if (data.activityNarrative) {
216
+ activityText = `**Activity**\n${data.activityNarrative}\n\n`;
217
+ }
218
+ if (data.activityCommandSummary) {
219
+ activityNoteText = `*${data.activityCommandSummary}*\n\n`;
279
220
  }
280
221
  let thinkingText = '';
281
- if (result.thinking) {
282
- thinkingText = `**${thinkLabel(agent)}**\n${formatThinkingForDisplay(result.thinking, 1600)}\n\n`;
222
+ if (data.thinkingDisplay) {
223
+ thinkingText = `**${data.thinkLabel}**\n${data.thinkingDisplay}\n\n`;
283
224
  }
284
225
  let statusText = '';
285
- if (result.incomplete) {
286
- const statusLines = [];
287
- if (result.stopReason === 'max_tokens')
288
- statusLines.push('Output limit reached. Response may be truncated.');
289
- if (result.stopReason === 'timeout') {
290
- statusLines.push(`Timed out after ${fmtUptime(Math.max(0, Math.round(result.elapsedS * 1000)))} before the agent reported completion.`);
291
- }
292
- if (!result.ok) {
293
- const detail = result.error?.trim();
294
- if (detail && detail !== result.message.trim() && !statusLines.includes(detail))
295
- statusLines.push(detail);
296
- else if (result.stopReason !== 'timeout')
297
- statusLines.push('Agent exited before reporting completion.');
298
- }
299
- statusText = `**⚠ Incomplete Response**\n${statusLines.join('\n')}\n\n`;
226
+ if (data.statusLines) {
227
+ statusText = `**⚠ Incomplete Response**\n${data.statusLines.join('\n')}\n\n`;
300
228
  }
301
229
  const headerText = `${activityText}${activityNoteText}${statusText}${thinkingText}`;
302
- const bodyText = result.message;
230
+ const bodyText = data.bodyMessage;
303
231
  return {
304
232
  fullText: `${headerText}${bodyText}${footerText}`,
305
233
  headerText,
@@ -474,12 +402,11 @@ export function renderStatus(d) {
474
402
  lines.push(`**Running:** ${fmtUptime(Date.now() - d.running.startedAt)} - ${summarizePromptForStatus(d.running.prompt)}`);
475
403
  }
476
404
  // Provider usage
477
- const usageLines = formatProviderUsageLines(d.usage);
405
+ const usageLines = buildProviderUsageLines(d.usage);
478
406
  if (usageLines.length > 1) {
479
407
  lines.push('');
480
- // Strip HTML tags from usage lines (they're HTML-formatted)
481
408
  for (const line of usageLines) {
482
- lines.push(line.replace(/<\/?[^>]+>/g, '').replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>'));
409
+ lines.push(line.text);
483
410
  }
484
411
  }
485
412
  lines.push('', '**Bot Usage**', ` Turns: ${d.stats.totalTurns}`);
@@ -24,6 +24,7 @@ import { currentHumanLoopQuestion, humanLoopOptionSelected } from './human-loop.
24
24
  import { FeishuChannel } from './channel-feishu.js';
25
25
  import { splitText, supportsChannelCapability } from './channel-base.js';
26
26
  import { getActiveUserConfig } from './user-config.js';
27
+ import { FEISHU_BOT_CARD_MAX } from './constants.js';
27
28
  const SHUTDOWN_EXIT_CODE = {
28
29
  SIGINT: 130,
29
30
  SIGTERM: 143,
@@ -640,7 +641,7 @@ export class FeishuBot extends Bot {
640
641
  async sendFinalReply(ctx, placeholderId, agent, result) {
641
642
  const rendered = buildFinalReplyRender(agent, result);
642
643
  const messageIds = [];
643
- const MAX_CARD = 25_000;
644
+ const MAX_CARD = FEISHU_BOT_CARD_MAX;
644
645
  if (rendered.fullText.length <= MAX_CARD) {
645
646
  // Fits in one card — edit the placeholder
646
647
  if (placeholderId) {
@@ -1,5 +1,6 @@
1
1
  import { buildDefaultMenuCommands } from './bot-menu.js';
2
- export const BOT_SHUTDOWN_FORCE_EXIT_MS = 3_000;
2
+ import { BOT_SHUTDOWN_FORCE_EXIT_MS as _BOT_SHUTDOWN_FORCE_EXIT_MS } from './constants.js';
3
+ export const BOT_SHUTDOWN_FORCE_EXIT_MS = _BOT_SHUTDOWN_FORCE_EXIT_MS;
3
4
  export function buildBotMenuState(bot) {
4
5
  const agents = bot.fetchAgents().agents;
5
6
  const installedCount = agents.filter(agent => agent.installed).length;
@@ -0,0 +1,162 @@
1
+ /**
2
+ * bot-render-shared.ts — Shared rendering logic used by both Telegram and Feishu renderers.
3
+ *
4
+ * Contains types, pure-data helpers, and functions that are identical across platforms.
5
+ * Platform-specific formatting (HTML vs Markdown) stays in the respective render files.
6
+ */
7
+ import { fmtUptime, formatThinkingForDisplay, thinkLabel } from './bot.js';
8
+ import { formatActivityCommandSummary, parseActivitySummary, renderPlanForPreview, summarizeActivityForPreview } from './bot-streaming.js';
9
+ // ---------------------------------------------------------------------------
10
+ // Footer helpers
11
+ // ---------------------------------------------------------------------------
12
+ export function fmtCompactUptime(ms) {
13
+ return fmtUptime(ms).replace(/\s+/g, '');
14
+ }
15
+ export function footerStatusSymbol(status) {
16
+ switch (status) {
17
+ case 'running': return '●';
18
+ case 'done': return '✓';
19
+ case 'failed': return '✗';
20
+ }
21
+ }
22
+ export function formatFooterSummary(agent, elapsedMs, meta, contextPercent) {
23
+ const parts = [agent];
24
+ const ctx = contextPercent ?? meta?.contextPercent ?? null;
25
+ if (ctx != null)
26
+ parts.push(`${ctx}%`);
27
+ parts.push(fmtCompactUptime(Math.max(0, Math.round(elapsedMs))));
28
+ return parts.join(' · ');
29
+ }
30
+ // ---------------------------------------------------------------------------
31
+ // Activity trimming
32
+ // ---------------------------------------------------------------------------
33
+ export function trimActivityForPreview(text, maxChars = 900) {
34
+ if (text.length <= maxChars)
35
+ return text;
36
+ const lines = text.split('\n').filter(line => line.trim());
37
+ if (lines.length <= 1)
38
+ return text.slice(0, Math.max(0, maxChars - 3)).trimEnd() + '...';
39
+ const tailCount = Math.min(2, Math.max(1, lines.length - 1));
40
+ const tail = lines.slice(-tailCount);
41
+ const headCandidates = lines.slice(0, Math.max(0, lines.length - tailCount));
42
+ const reserved = tail.join('\n').length + 5;
43
+ const budget = Math.max(0, maxChars - reserved);
44
+ const head = [];
45
+ let used = 0;
46
+ for (const line of headCandidates) {
47
+ const extra = line.length + (head.length ? 1 : 0);
48
+ if (used + extra > budget)
49
+ break;
50
+ head.push(line);
51
+ used += extra;
52
+ }
53
+ if (!head.length)
54
+ return text.slice(0, Math.max(0, maxChars - 3)).trimEnd() + '...';
55
+ return [...head, '...', ...tail].join('\n');
56
+ }
57
+ // ---------------------------------------------------------------------------
58
+ // Provider usage (plain-text builder — caller wraps as needed)
59
+ // ---------------------------------------------------------------------------
60
+ function rawUsageLine(parts) {
61
+ return parts.filter(part => !!part && String(part).trim()).join(' ');
62
+ }
63
+ export function buildProviderUsageLines(usage) {
64
+ const lines = [
65
+ { text: '', bold: false },
66
+ { text: 'Provider Usage', bold: true },
67
+ ];
68
+ if (!usage.ok) {
69
+ lines.push({ text: ` Unavailable: ${usage.error || 'No recent usage data found.'}` });
70
+ return lines;
71
+ }
72
+ if (usage.capturedAt) {
73
+ const capturedAtMs = Date.parse(usage.capturedAt);
74
+ if (Number.isFinite(capturedAtMs)) {
75
+ lines.push({ text: ` Updated: ${fmtUptime(Math.max(0, Date.now() - capturedAtMs))} ago` });
76
+ }
77
+ }
78
+ if (!usage.windows.length) {
79
+ lines.push({ text: ` ${usage.status ? `status=${usage.status}` : 'No window data'}` });
80
+ return lines;
81
+ }
82
+ for (const window of usage.windows) {
83
+ const details = rawUsageLine([
84
+ window.usedPercent != null ? `${window.usedPercent}% used` : null,
85
+ window.status ? `status=${window.status}` : null,
86
+ window.resetAfterSeconds != null ? `resetAfterSeconds=${window.resetAfterSeconds}` : null,
87
+ ]);
88
+ lines.push({ text: ` ${window.label}: ${details || 'No details'}` });
89
+ }
90
+ return lines;
91
+ }
92
+ export function extractFinalReplyData(agent, result) {
93
+ const footerStatus = result.incomplete || !result.ok ? 'failed' : 'done';
94
+ const elapsedMs = result.elapsedS * 1000;
95
+ const footerSummary = formatFooterSummary(agent, elapsedMs, null, result.contextPercent ?? null);
96
+ let activityNarrative = null;
97
+ let activityCommandSummary = null;
98
+ if (result.activity) {
99
+ const summary = parseActivitySummary(result.activity);
100
+ const narrative = summary.narrative.join('\n');
101
+ if (narrative) {
102
+ activityNarrative = narrative.length > 1600 ? '...\n' + narrative.slice(-1600) : narrative;
103
+ }
104
+ const cmdSummary = formatActivityCommandSummary(summary.completedCommands, summary.activeCommands, summary.failedCommands);
105
+ if (cmdSummary)
106
+ activityCommandSummary = cmdSummary;
107
+ }
108
+ let thinkingDisplay = null;
109
+ if (result.thinking) {
110
+ thinkingDisplay = formatThinkingForDisplay(result.thinking, 1600);
111
+ }
112
+ let statusLines = null;
113
+ if (result.incomplete) {
114
+ statusLines = [];
115
+ if (result.stopReason === 'max_tokens')
116
+ statusLines.push('Output limit reached. Response may be truncated.');
117
+ if (result.stopReason === 'timeout') {
118
+ statusLines.push(`Timed out after ${fmtUptime(Math.max(0, Math.round(elapsedMs)))} before the agent reported completion.`);
119
+ }
120
+ if (!result.ok) {
121
+ const detail = result.error?.trim();
122
+ if (detail && detail !== result.message.trim() && !statusLines.includes(detail))
123
+ statusLines.push(detail);
124
+ else if (result.stopReason !== 'timeout')
125
+ statusLines.push('Agent exited before reporting completion.');
126
+ }
127
+ }
128
+ return {
129
+ footerStatus,
130
+ footerSummary,
131
+ activityNarrative,
132
+ activityCommandSummary,
133
+ thinkingDisplay,
134
+ thinkLabel: thinkLabel(agent),
135
+ statusLines,
136
+ bodyMessage: result.message,
137
+ elapsedMs,
138
+ };
139
+ }
140
+ export function extractStreamPreviewData(input) {
141
+ const maxBody = 2400;
142
+ const display = input.bodyText.trim();
143
+ const rawThinking = input.thinking.trim();
144
+ const thinkDisplay = formatThinkingForDisplay(input.thinking, maxBody);
145
+ const planDisplay = renderPlanForPreview(input.plan ?? null);
146
+ const activityDisplay = summarizeActivityForPreview(input.activity);
147
+ const maxActivity = !display && !thinkDisplay && !planDisplay ? 2400 : 1400;
148
+ const label = thinkLabel(input.agent);
149
+ const thinkSnippet = rawThinking ? formatThinkingForDisplay(input.thinking, 600) : '';
150
+ const preview = display.length > maxBody ? '(...truncated)\n' + display.slice(-maxBody) : display;
151
+ return {
152
+ display,
153
+ rawThinking,
154
+ thinkDisplay,
155
+ planDisplay,
156
+ activityDisplay,
157
+ maxActivity,
158
+ label,
159
+ thinkSnippet,
160
+ preview,
161
+ };
162
+ }
@@ -1,7 +1,8 @@
1
1
  import { hasPreviewMeta, samePreviewMeta, samePreviewPlan } from './bot-streaming.js';
2
- const STREAM_PREVIEW_HEARTBEAT_MS = 5_000;
3
- const STREAM_TYPING_HEARTBEAT_MS = 4_000;
4
- const STREAM_STALLED_NOTICE_MS = 15_000;
2
+ import { STREAM_PREVIEW_TIMEOUTS } from './constants.js';
3
+ const STREAM_PREVIEW_HEARTBEAT_MS = STREAM_PREVIEW_TIMEOUTS.heartbeat;
4
+ const STREAM_TYPING_HEARTBEAT_MS = STREAM_PREVIEW_TIMEOUTS.typing;
5
+ const STREAM_STALLED_NOTICE_MS = STREAM_PREVIEW_TIMEOUTS.stalledNotice;
5
6
  // ---------------------------------------------------------------------------
6
7
  // LivePreview — generic streaming preview controller
7
8
  // ---------------------------------------------------------------------------