pikiclaw 0.2.35

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.
@@ -0,0 +1,527 @@
1
+ /**
2
+ * bot-feishu-render.ts — Feishu-specific rendering.
3
+ *
4
+ * Converts structured data from bot-commands.ts into Feishu Markdown (for interactive cards).
5
+ * Also provides a LivePreviewRenderer for streaming output.
6
+ */
7
+ import { encodeCommandAction } from './bot-command-ui.js';
8
+ import { fmtUptime, fmtTokens, fmtBytes, formatThinkingForDisplay, thinkLabel } from './bot.js';
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';
12
+ import path from 'node:path';
13
+ import { listSubdirs } from './bot.js';
14
+ // ---------------------------------------------------------------------------
15
+ // Helpers
16
+ // ---------------------------------------------------------------------------
17
+ function fmtCompactUptime(ms) {
18
+ return fmtUptime(ms).replace(/\s+/g, '');
19
+ }
20
+ function footerStatusSymbol(status) {
21
+ switch (status) {
22
+ case 'running': return '●';
23
+ case 'done': return '✓';
24
+ case 'failed': return '✗';
25
+ }
26
+ }
27
+ function formatFooterSummary(agent, elapsedMs, meta, contextPercent) {
28
+ const parts = [agent];
29
+ const ctx = contextPercent ?? meta?.contextPercent ?? null;
30
+ if (ctx != null)
31
+ parts.push(`${ctx}%`);
32
+ parts.push(fmtCompactUptime(Math.max(0, Math.round(elapsedMs))));
33
+ return parts.join(' · ');
34
+ }
35
+ function formatPreviewFooter(agent, elapsedMs, meta) {
36
+ return `${footerStatusSymbol('running')} ${formatFooterSummary(agent, elapsedMs, meta)}`;
37
+ }
38
+ function formatFinalFooter(status, agent, elapsedMs, contextPercent) {
39
+ return `${footerStatusSymbol(status)} ${formatFooterSummary(agent, elapsedMs, null, contextPercent ?? null)}`;
40
+ }
41
+ function trimActivityForPreview(text, maxChars = 900) {
42
+ if (text.length <= maxChars)
43
+ return text;
44
+ const lines = text.split('\n').filter(l => l.trim());
45
+ if (lines.length <= 1)
46
+ return text.slice(0, Math.max(0, maxChars - 3)).trimEnd() + '...';
47
+ const tailCount = Math.min(2, Math.max(1, lines.length - 1));
48
+ const tail = lines.slice(-tailCount);
49
+ const headCandidates = lines.slice(0, Math.max(0, lines.length - tailCount));
50
+ const reserved = tail.join('\n').length + 5;
51
+ const budget = Math.max(0, maxChars - reserved);
52
+ const head = [];
53
+ let used = 0;
54
+ for (const line of headCandidates) {
55
+ const extra = line.length + (head.length ? 1 : 0);
56
+ if (used + extra > budget)
57
+ break;
58
+ head.push(line);
59
+ used += extra;
60
+ }
61
+ if (!head.length)
62
+ return text.slice(0, Math.max(0, maxChars - 3)).trimEnd() + '...';
63
+ return [...head, '...', ...tail].join('\n');
64
+ }
65
+ function truncateLabel(label, maxChars = 24) {
66
+ return label.length > maxChars ? `${label.slice(0, Math.max(1, maxChars - 1))}…` : label;
67
+ }
68
+ function cardButton(label, action, primary = false) {
69
+ const button = {
70
+ tag: 'button',
71
+ text: { tag: 'plain_text', content: truncateLabel(label) },
72
+ value: { action },
73
+ };
74
+ if (primary)
75
+ button.type = 'primary';
76
+ return button;
77
+ }
78
+ function cardRows(actions, size = 3) {
79
+ const rows = [];
80
+ for (let i = 0; i < actions.length; i += size) {
81
+ const rowActions = actions.slice(i, i + size);
82
+ if (!rowActions.length)
83
+ continue;
84
+ rows.push({ actions: rowActions });
85
+ }
86
+ return rows;
87
+ }
88
+ function selectionStateSymbol(state) {
89
+ switch (state) {
90
+ case 'current': return '●';
91
+ case 'running': return '🟢';
92
+ case 'unavailable': return '❌';
93
+ default: return '○';
94
+ }
95
+ }
96
+ function formatCommandItemMarkdown(item, index) {
97
+ const parts = [
98
+ selectionStateSymbol(item.state),
99
+ `**${index + 1}.**`,
100
+ item.label,
101
+ ];
102
+ if (item.detail)
103
+ parts.push(item.detail);
104
+ return parts.join(' ');
105
+ }
106
+ function formatCommandButtonLabel(button) {
107
+ const prefix = button.state && button.state !== 'default'
108
+ ? `${selectionStateSymbol(button.state)} `
109
+ : '';
110
+ return truncateLabel(`${prefix}${button.label}`.trim());
111
+ }
112
+ function actionButton(button) {
113
+ return cardButton(formatCommandButtonLabel(button), encodeCommandAction(button.action), !!button.primary);
114
+ }
115
+ export function renderCommandNotice(notice) {
116
+ const lines = [`**${notice.title}**`];
117
+ if (notice.value) {
118
+ lines.push(notice.valueMode === 'plain' ? notice.value : `\`${notice.value}\``);
119
+ }
120
+ if (notice.detail)
121
+ lines.push(notice.detail);
122
+ return lines.join('\n\n');
123
+ }
124
+ export function renderCommandSelectionMarkdown(view) {
125
+ const title = view.detail ? `**${view.title}** · \`${view.detail}\`` : `**${view.title}**`;
126
+ const lines = [title];
127
+ if (view.metaLines.length)
128
+ lines.push(...view.metaLines.map(line => `*${line}*`));
129
+ if (view.items.length) {
130
+ lines.push('', ...view.items.map((item, index) => formatCommandItemMarkdown(item, index)));
131
+ }
132
+ else if (view.emptyText) {
133
+ lines.push('', `*${view.emptyText}*`);
134
+ }
135
+ if (view.helperText)
136
+ lines.push('', `*${view.helperText}*`);
137
+ return lines.join('\n');
138
+ }
139
+ export function renderCommandSelectionCard(view) {
140
+ return {
141
+ markdown: renderCommandSelectionMarkdown(view),
142
+ rows: view.rows.map(row => ({ actions: row.map(actionButton) })),
143
+ };
144
+ }
145
+ function escapeFeishuMarkdownText(text) {
146
+ return text.replace(/([\\`*_{}[\]()#+\-.!|>~])/g, '\\$1');
147
+ }
148
+ function renderFeishuQuote(text) {
149
+ return text
150
+ .split('\n')
151
+ .map(line => `> ${escapeFeishuMarkdownText(line)}`)
152
+ .join('\n');
153
+ }
154
+ export function renderSessionTurnMarkdown(userText, assistantText) {
155
+ const parts = [];
156
+ const user = String(userText || '').trim();
157
+ const assistant = String(assistantText || '').trim();
158
+ if (user || assistant)
159
+ parts.push('**Recent Context**');
160
+ if (user)
161
+ parts.push('**User**', renderFeishuQuote(user));
162
+ if (assistant)
163
+ parts.push('**Assistant**', assistant);
164
+ return parts.join('\n\n');
165
+ }
166
+ // ---------------------------------------------------------------------------
167
+ // LivePreview renderer — produces Markdown for Feishu card elements
168
+ // ---------------------------------------------------------------------------
169
+ export function buildInitialPreviewMarkdown(agent) {
170
+ return formatPreviewFooter(agent, 0);
171
+ }
172
+ export function buildStreamPreviewMarkdown(input) {
173
+ const maxBody = 2400;
174
+ const display = input.bodyText.trim();
175
+ const rawThinking = input.thinking.trim();
176
+ const thinkDisplay = formatThinkingForDisplay(input.thinking, maxBody);
177
+ const planDisplay = renderPlanForPreview(input.plan ?? null);
178
+ const activityDisplay = summarizeActivityForPreview(input.activity);
179
+ const maxActivity = !display && !thinkDisplay && !planDisplay ? 1800 : 900;
180
+ const parts = [];
181
+ const label = thinkLabel(input.agent);
182
+ if (planDisplay) {
183
+ parts.push(`**Plan**\n${planDisplay}`);
184
+ }
185
+ if (activityDisplay) {
186
+ parts.push(`**Activity**\n${trimActivityForPreview(activityDisplay, maxActivity)}`);
187
+ }
188
+ if (thinkDisplay && !display) {
189
+ parts.push(`**${label}**\n${thinkDisplay}`);
190
+ }
191
+ else if (display) {
192
+ if (rawThinking)
193
+ parts.push(`*${label} (${rawThinking.length} chars)*`);
194
+ const preview = display.length > maxBody ? '(...truncated)\n' + display.slice(-maxBody) : display;
195
+ parts.push(preview);
196
+ }
197
+ parts.push(formatPreviewFooter(input.agent, input.elapsedMs, input.meta ?? null));
198
+ return parts.join('\n\n');
199
+ }
200
+ export const feishuPreviewRenderer = {
201
+ renderInitial: buildInitialPreviewMarkdown,
202
+ renderStream: buildStreamPreviewMarkdown,
203
+ };
204
+ export function buildFinalReplyRender(agent, result) {
205
+ const footerStatus = result.incomplete || !result.ok ? 'failed' : 'done';
206
+ const footerText = `\n\n${formatFinalFooter(footerStatus, agent, result.elapsedS * 1000, result.contextPercent ?? null)}`;
207
+ let activityText = '';
208
+ let activityNoteText = '';
209
+ if (result.activity) {
210
+ const summary = parseActivitySummary(result.activity);
211
+ const narrative = summary.narrative.join('\n');
212
+ if (narrative) {
213
+ let display = narrative;
214
+ if (display.length > 800)
215
+ display = '...\n' + display.slice(-800);
216
+ activityText = `**Activity**\n${display}\n\n`;
217
+ }
218
+ const commandSummary = formatActivityCommandSummary(summary.completedCommands, summary.activeCommands, summary.failedCommands);
219
+ if (commandSummary)
220
+ activityNoteText = `*${commandSummary}*\n\n`;
221
+ }
222
+ let thinkingText = '';
223
+ if (result.thinking) {
224
+ thinkingText = `**${thinkLabel(agent)}**\n${formatThinkingForDisplay(result.thinking, 800)}\n\n`;
225
+ }
226
+ let statusText = '';
227
+ if (result.incomplete) {
228
+ const statusLines = [];
229
+ if (result.stopReason === 'max_tokens')
230
+ statusLines.push('Output limit reached. Response may be truncated.');
231
+ if (result.stopReason === 'timeout') {
232
+ statusLines.push(`Timed out after ${fmtUptime(Math.max(0, Math.round(result.elapsedS * 1000)))} before the agent reported completion.`);
233
+ }
234
+ if (!result.ok) {
235
+ const detail = result.error?.trim();
236
+ if (detail && detail !== result.message.trim() && !statusLines.includes(detail))
237
+ statusLines.push(detail);
238
+ else if (result.stopReason !== 'timeout')
239
+ statusLines.push('Agent exited before reporting completion.');
240
+ }
241
+ statusText = `**⚠ Incomplete Response**\n${statusLines.join('\n')}\n\n`;
242
+ }
243
+ const headerText = `${activityText}${activityNoteText}${statusText}${thinkingText}`;
244
+ const bodyText = result.message;
245
+ return {
246
+ fullText: `${headerText}${bodyText}${footerText}`,
247
+ headerText,
248
+ bodyText,
249
+ footerText,
250
+ };
251
+ }
252
+ // ---------------------------------------------------------------------------
253
+ // Command renderers — produce Markdown for Feishu cards
254
+ // ---------------------------------------------------------------------------
255
+ export function renderStart(d) {
256
+ const lines = [
257
+ `**${d.title}** v${d.version}`,
258
+ d.subtitle,
259
+ '',
260
+ `**Agent:** ${d.agent}`,
261
+ `**Workdir:** \`${d.workdir}\``,
262
+ '',
263
+ '**Commands**',
264
+ ...d.commands.map(c => `/${c.command} — ${c.description}`),
265
+ ];
266
+ return lines.join('\n');
267
+ }
268
+ export function renderSessionsPage(d) {
269
+ const lines = [
270
+ `**${d.agent} sessions** (${d.total}) p${d.page + 1}/${d.totalPages}`,
271
+ '',
272
+ ];
273
+ if (!d.sessions.length) {
274
+ lines.push('*No sessions found.*');
275
+ }
276
+ else {
277
+ for (let i = 0; i < d.sessions.length; i++) {
278
+ const s = d.sessions[i];
279
+ const icon = s.isRunning ? '🟢' : s.isCurrent ? '●' : '○';
280
+ lines.push(`${icon} **${i + 1}.** ${s.title} ${s.time}${s.isCurrent ? ' ← current' : ''}`);
281
+ }
282
+ lines.push('');
283
+ lines.push('*Use the controls below to switch, or reply with session number / "new".*');
284
+ }
285
+ if (d.totalPages > 1) {
286
+ lines.push(`\nPage ${d.page + 1}/${d.totalPages}. Use the page controls below or reply "p2", "p3" etc. to navigate.`);
287
+ }
288
+ return lines.join('\n');
289
+ }
290
+ export function renderAgentsList(d) {
291
+ const lines = ['**Available Agents**', ''];
292
+ for (const a of d.agents) {
293
+ const status = !a.installed ? '❌' : a.isCurrent ? '●' : '○';
294
+ lines.push(`${status} **${a.agent}**${a.isCurrent ? ' (current)' : ''}`);
295
+ if (a.installed) {
296
+ if (a.version)
297
+ lines.push(` Version: \`${a.version}\``);
298
+ }
299
+ else {
300
+ lines.push(' Not installed');
301
+ }
302
+ }
303
+ lines.push('');
304
+ lines.push('*Use the controls below to switch, or reply with agent name (e.g. "claude", "codex").*');
305
+ return lines.join('\n');
306
+ }
307
+ export function renderModelsList(d) {
308
+ const lines = [`**Models for ${d.agent}**`];
309
+ if (d.sources.length)
310
+ lines.push(`*Source: ${d.sources.join(', ')}*`);
311
+ if (d.note)
312
+ lines.push(`*${d.note}*`);
313
+ lines.push('');
314
+ if (!d.models.length) {
315
+ lines.push('*No discoverable models found.*');
316
+ }
317
+ else {
318
+ for (let i = 0; i < d.models.length; i++) {
319
+ const m = d.models[i];
320
+ const status = m.isCurrent ? '●' : '○';
321
+ const display = m.alias ? `${m.alias} (${m.id})` : m.id;
322
+ lines.push(`${status} **${i + 1}.** \`${display}\`${m.isCurrent ? ' ← current' : ''}`);
323
+ }
324
+ lines.push('');
325
+ lines.push('*Use the controls below to switch, or reply with model number / ID.*');
326
+ }
327
+ if (d.effort) {
328
+ lines.push('');
329
+ lines.push(`**Thinking Effort:** \`${d.effort.current}\``);
330
+ lines.push(d.effort.levels.map(l => l.isCurrent ? `**[${l.label}]**` : l.label).join(' | '));
331
+ }
332
+ return lines.join('\n');
333
+ }
334
+ export function renderSkillsList(d) {
335
+ const lines = [`**Project Skills** (${d.skills.length})`, '', `**Agent:** ${d.agent}`, `**Workdir:** \`${d.workdir}\``];
336
+ if (!d.skills.length) {
337
+ lines.push('', '*No project skills found in `.pikiclaw/skills/` or `.claude/commands/`.*');
338
+ return lines.join('\n');
339
+ }
340
+ lines.push('');
341
+ for (const skill of d.skills) {
342
+ lines.push(`**/${skill.command}** — ${skill.label}`);
343
+ if (skill.description)
344
+ lines.push(skill.description);
345
+ }
346
+ lines.push('', '*Tap a button below or send the command directly.*');
347
+ return lines.join('\n');
348
+ }
349
+ export function renderSessionsPageCard(d) {
350
+ const sessionButtons = d.sessions.map(s => {
351
+ const prefix = s.isCurrent ? '● ' : s.isRunning ? '🟢 ' : '';
352
+ return cardButton(`${prefix}${s.title}`, `sess:${s.key}`, s.isCurrent);
353
+ });
354
+ const navButtons = [];
355
+ if (d.page > 0)
356
+ navButtons.push(cardButton(`◀ p${d.page}`, `sp:${d.page - 1}`));
357
+ navButtons.push(cardButton('+ New', 'sess:new'));
358
+ if (d.page < d.totalPages - 1)
359
+ navButtons.push(cardButton(`p${d.page + 2} ▶`, `sp:${d.page + 1}`));
360
+ return {
361
+ markdown: renderSessionsPage(d),
362
+ rows: [
363
+ ...cardRows(sessionButtons),
364
+ ...(navButtons.length ? [{ actions: navButtons }] : []),
365
+ ],
366
+ };
367
+ }
368
+ export function renderAgentsListCard(d) {
369
+ const actions = d.agents
370
+ .filter(a => a.installed)
371
+ .map(a => cardButton(a.isCurrent ? `● ${a.agent}` : a.agent, `ag:${a.agent}`, a.isCurrent));
372
+ return {
373
+ markdown: renderAgentsList(d),
374
+ rows: cardRows(actions),
375
+ };
376
+ }
377
+ export function renderModelsListCard(d) {
378
+ const modelRows = cardRows(d.models.map(m => cardButton(m.isCurrent ? `● ${m.alias || m.id}` : (m.alias || m.id), `mod:${m.id}`, m.isCurrent)));
379
+ const effortRows = d.effort
380
+ ? cardRows(d.effort.levels.map(l => cardButton(l.isCurrent ? `● ${l.label}` : l.label, `eff:${l.id}`, l.isCurrent)))
381
+ : [];
382
+ return {
383
+ markdown: renderModelsList(d),
384
+ rows: [...modelRows, ...effortRows],
385
+ };
386
+ }
387
+ export function renderSkillsCard(d) {
388
+ return {
389
+ markdown: renderSkillsList(d),
390
+ rows: cardRows(d.skills.map(skill => cardButton(skill.label, `skill:${skill.command}`)), 2),
391
+ };
392
+ }
393
+ export function renderStatus(d) {
394
+ const lines = [
395
+ `**pikiclaw** v${d.version}`,
396
+ '',
397
+ `**Uptime:** ${fmtUptime(d.uptime)}`,
398
+ `**Memory:** ${(d.memRss / 1024 / 1024).toFixed(0)}MB RSS / ${(d.memHeap / 1024 / 1024).toFixed(0)}MB heap`,
399
+ `**PID:** ${d.pid}`,
400
+ `**Workdir:** \`${d.workdir}\``,
401
+ '',
402
+ `**Agent:** ${d.agent}`,
403
+ `**Model:** ${d.model}`,
404
+ `**Session:** ${d.sessionId ? `\`${d.sessionId.slice(0, 16)}\`` : '(new)'}`,
405
+ `**Active Tasks:** ${d.activeTasksCount}`,
406
+ ];
407
+ if (d.running) {
408
+ lines.push(`**Running:** ${fmtUptime(Date.now() - d.running.startedAt)} - ${summarizePromptForStatus(d.running.prompt)}`);
409
+ }
410
+ // Provider usage
411
+ const usageLines = formatProviderUsageLines(d.usage);
412
+ if (usageLines.length > 1) {
413
+ lines.push('');
414
+ // Strip HTML tags from usage lines (they're HTML-formatted)
415
+ for (const line of usageLines) {
416
+ lines.push(line.replace(/<\/?[^>]+>/g, '').replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>'));
417
+ }
418
+ }
419
+ lines.push('', '**Bot Usage**', ` Turns: ${d.stats.totalTurns}`);
420
+ if (d.stats.totalInputTokens || d.stats.totalOutputTokens) {
421
+ lines.push(` In: ${fmtTokens(d.stats.totalInputTokens)} Out: ${fmtTokens(d.stats.totalOutputTokens)}`);
422
+ if (d.stats.totalCachedTokens)
423
+ lines.push(` Cached: ${fmtTokens(d.stats.totalCachedTokens)}`);
424
+ }
425
+ return lines.join('\n');
426
+ }
427
+ // ---------------------------------------------------------------------------
428
+ // Directory browser (interactive workdir switcher)
429
+ // ---------------------------------------------------------------------------
430
+ class PathRegistry {
431
+ pathToId = new Map();
432
+ idToPath = new Map();
433
+ nextId = 1;
434
+ register(dirPath) {
435
+ let id = this.pathToId.get(dirPath);
436
+ if (id != null)
437
+ return id;
438
+ id = this.nextId++;
439
+ this.pathToId.set(dirPath, id);
440
+ this.idToPath.set(id, dirPath);
441
+ if (this.pathToId.size > 500) {
442
+ const oldest = [...this.pathToId.entries()].slice(0, 200);
443
+ for (const [oldPath, oldId] of oldest) {
444
+ this.pathToId.delete(oldPath);
445
+ this.idToPath.delete(oldId);
446
+ }
447
+ }
448
+ return id;
449
+ }
450
+ resolve(id) {
451
+ return this.idToPath.get(id);
452
+ }
453
+ }
454
+ const feishuPathRegistry = new PathRegistry();
455
+ const DIR_PAGE_SIZE = 8;
456
+ export function resolveFeishuRegisteredPath(id) {
457
+ return feishuPathRegistry.resolve(id);
458
+ }
459
+ export function buildSwitchWorkdirCard(currentWorkdir, browsePath, page = 0) {
460
+ const dirs = listSubdirs(browsePath);
461
+ const totalPages = Math.max(1, Math.ceil(dirs.length / DIR_PAGE_SIZE));
462
+ const currentPage = Math.min(Math.max(0, page), totalPages - 1);
463
+ const slice = dirs.slice(currentPage * DIR_PAGE_SIZE, (currentPage + 1) * DIR_PAGE_SIZE);
464
+ // Text
465
+ const lines = ['**Workdir**'];
466
+ lines.push(`● \`${currentWorkdir}\``);
467
+ if (browsePath !== currentWorkdir)
468
+ lines.push(`○ \`${browsePath}\``);
469
+ // Directory buttons (2 per row)
470
+ const dirRows = [];
471
+ for (let i = 0; i < slice.length; i += 2) {
472
+ const rowActions = [];
473
+ for (let j = i; j < Math.min(i + 2, slice.length); j++) {
474
+ const fullPath = path.join(browsePath, slice[j]);
475
+ const id = feishuPathRegistry.register(fullPath);
476
+ rowActions.push(cardButton(slice[j], `sw:n:${id}:0`));
477
+ }
478
+ dirRows.push({ actions: rowActions });
479
+ }
480
+ // Nav row: parent + pagination
481
+ const navActions = [];
482
+ const parent = path.dirname(browsePath);
483
+ if (parent !== browsePath) {
484
+ navActions.push(cardButton('⬆ ..', `sw:n:${feishuPathRegistry.register(parent)}:0`));
485
+ }
486
+ if (totalPages > 1) {
487
+ const browseId = feishuPathRegistry.register(browsePath);
488
+ if (currentPage > 0)
489
+ navActions.push(cardButton(`◀ ${currentPage}/${totalPages}`, `sw:n:${browseId}:${currentPage - 1}`));
490
+ if (currentPage < totalPages - 1)
491
+ navActions.push(cardButton(`${currentPage + 2}/${totalPages} ▶`, `sw:n:${browseId}:${currentPage + 1}`));
492
+ }
493
+ // Select button
494
+ const selectActions = [
495
+ cardButton('✓ Use This', `sw:s:${feishuPathRegistry.register(browsePath)}`, true),
496
+ ];
497
+ const rows = [
498
+ ...dirRows,
499
+ ...(navActions.length ? [{ actions: navActions }] : []),
500
+ { actions: selectActions },
501
+ ];
502
+ return { markdown: lines.join('\n'), rows };
503
+ }
504
+ export function renderHost(d) {
505
+ const lines = [
506
+ '**Host**',
507
+ '',
508
+ `**Name:** ${d.hostName}`,
509
+ `**CPU:** ${d.cpuModel} x${d.cpuCount}`,
510
+ d.cpuUsage
511
+ ? `**CPU Usage:** ${d.cpuUsage.usedPercent.toFixed(1)}% (${d.cpuUsage.userPercent.toFixed(1)}% user, ${d.cpuUsage.sysPercent.toFixed(1)}% sys, ${d.cpuUsage.idlePercent.toFixed(1)}% idle)`
512
+ : '**CPU Usage:** unavailable',
513
+ `**Memory:** ${fmtBytes(d.memoryUsed)} / ${fmtBytes(d.totalMem)} (${d.memoryPercent.toFixed(0)}%)`,
514
+ `**Available:** ${fmtBytes(d.memoryAvailable)}`,
515
+ `**Battery:** ${d.battery ? `${d.battery.percent} (${d.battery.state})` : 'unavailable'}`,
516
+ ];
517
+ if (d.disk)
518
+ lines.push(`**Disk:** ${d.disk.used} used / ${d.disk.total} total (${d.disk.percent})`);
519
+ lines.push(`\n**Process:** PID ${d.selfPid} | RSS ${fmtBytes(d.selfRss)} | Heap ${fmtBytes(d.selfHeap)}`);
520
+ if (d.topProcs.length > 1) {
521
+ lines.push('\n**Top Processes**');
522
+ lines.push('```');
523
+ lines.push(...d.topProcs);
524
+ lines.push('```');
525
+ }
526
+ return lines.join('\n');
527
+ }