pikiloop 0.4.0

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 (154) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +353 -0
  3. package/README.v2.md +287 -0
  4. package/README.zh-CN.md +352 -0
  5. package/dashboard/dist/assets/AgentTab-UZPIhlkr.js +1 -0
  6. package/dashboard/dist/assets/DirBrowser-Ckcmi-Pi.js +1 -0
  7. package/dashboard/dist/assets/ExtensionsTab-KZhEDrdu.js +1 -0
  8. package/dashboard/dist/assets/IMAccessTab-Bd_IY1GQ.js +1 -0
  9. package/dashboard/dist/assets/Modal-CTeL0y7P.js +1 -0
  10. package/dashboard/dist/assets/Modals-axftHasy.js +1 -0
  11. package/dashboard/dist/assets/Select-C8tOdPhe.js +1 -0
  12. package/dashboard/dist/assets/SessionPanel-C1geSRxw.js +1 -0
  13. package/dashboard/dist/assets/SystemTab-DBDkaPiO.js +1 -0
  14. package/dashboard/dist/assets/anthropic-BAdojD7P.ico +0 -0
  15. package/dashboard/dist/assets/codex-DYadqqp0.png +0 -0
  16. package/dashboard/dist/assets/deepseek-BeYNZEk0.ico +0 -0
  17. package/dashboard/dist/assets/doubao-DloFDuFR.png +0 -0
  18. package/dashboard/dist/assets/feishu-C4OMrjCW.ico +0 -0
  19. package/dashboard/dist/assets/gemini-BYkEpiWr.svg +1 -0
  20. package/dashboard/dist/assets/hermes-BAarh-tH.png +0 -0
  21. package/dashboard/dist/assets/index-CpM4CqZJ.js +23 -0
  22. package/dashboard/dist/assets/index-DXSohzrE.js +3 -0
  23. package/dashboard/dist/assets/index-reSbuley.css +1 -0
  24. package/dashboard/dist/assets/markdown-DxQYQFeH.js +29 -0
  25. package/dashboard/dist/assets/minimax-PuEGTfrF.ico +0 -0
  26. package/dashboard/dist/assets/mlx-DhWwjtMw.png +0 -0
  27. package/dashboard/dist/assets/ollama-Bt9O-2K_.png +0 -0
  28. package/dashboard/dist/assets/openrouter-CsJ_bD5Q.ico +0 -0
  29. package/dashboard/dist/assets/playwright-BldPFZgC.ico +0 -0
  30. package/dashboard/dist/assets/qwen-xykkX0_y.png +0 -0
  31. package/dashboard/dist/assets/react-vendor-C7Sl8SE7.js +9 -0
  32. package/dashboard/dist/assets/router-DHISdpPk.js +3 -0
  33. package/dashboard/dist/assets/shared-BIP_4k4I.js +1 -0
  34. package/dashboard/dist/favicon.svg +28 -0
  35. package/dashboard/dist/index.html +17 -0
  36. package/dist/agent/acp-client.js +261 -0
  37. package/dist/agent/auto-update.js +432 -0
  38. package/dist/agent/await-resume.js +50 -0
  39. package/dist/agent/cli/auth.js +325 -0
  40. package/dist/agent/cli/catalog.js +40 -0
  41. package/dist/agent/cli/detector.js +136 -0
  42. package/dist/agent/cli/index.js +7 -0
  43. package/dist/agent/cli/registry.js +33 -0
  44. package/dist/agent/driver.js +39 -0
  45. package/dist/agent/drivers/claude-tui.js +2297 -0
  46. package/dist/agent/drivers/claude.js +2689 -0
  47. package/dist/agent/drivers/codex.js +2210 -0
  48. package/dist/agent/drivers/gemini.js +1059 -0
  49. package/dist/agent/drivers/hermes.js +795 -0
  50. package/dist/agent/goal.js +274 -0
  51. package/dist/agent/handover.js +130 -0
  52. package/dist/agent/images.js +355 -0
  53. package/dist/agent/index.js +50 -0
  54. package/dist/agent/mcp/bridge.js +791 -0
  55. package/dist/agent/mcp/extensions.js +637 -0
  56. package/dist/agent/mcp/oauth.js +353 -0
  57. package/dist/agent/mcp/registry.js +119 -0
  58. package/dist/agent/mcp/session-server.js +229 -0
  59. package/dist/agent/mcp/tools/ask-user.js +113 -0
  60. package/dist/agent/mcp/tools/await-resume.js +77 -0
  61. package/dist/agent/mcp/tools/goal.js +144 -0
  62. package/dist/agent/mcp/tools/types.js +12 -0
  63. package/dist/agent/mcp/tools/workspace.js +212 -0
  64. package/dist/agent/npm.js +31 -0
  65. package/dist/agent/session.js +1206 -0
  66. package/dist/agent/skill-installer.js +160 -0
  67. package/dist/agent/skills.js +257 -0
  68. package/dist/agent/stream.js +743 -0
  69. package/dist/agent/types.js +13 -0
  70. package/dist/agent/utils.js +687 -0
  71. package/dist/bot/bot.js +2499 -0
  72. package/dist/bot/command-ui.js +633 -0
  73. package/dist/bot/commands.js +513 -0
  74. package/dist/bot/headless-bot.js +36 -0
  75. package/dist/bot/host.js +192 -0
  76. package/dist/bot/human-loop.js +168 -0
  77. package/dist/bot/menu.js +48 -0
  78. package/dist/bot/orchestration.js +79 -0
  79. package/dist/bot/render-shared.js +309 -0
  80. package/dist/bot/session-hub.js +361 -0
  81. package/dist/bot/session-status.js +55 -0
  82. package/dist/bot/streaming.js +309 -0
  83. package/dist/browser-profile.js +579 -0
  84. package/dist/browser-supervisor.js +249 -0
  85. package/dist/catalog/cli-tools.js +421 -0
  86. package/dist/catalog/index.js +21 -0
  87. package/dist/catalog/local-models.js +94 -0
  88. package/dist/catalog/mcp-servers.js +315 -0
  89. package/dist/catalog/skill-repos.js +173 -0
  90. package/dist/channels/base.js +55 -0
  91. package/dist/channels/dingtalk/bot.js +549 -0
  92. package/dist/channels/dingtalk/channel.js +268 -0
  93. package/dist/channels/discord/bot.js +552 -0
  94. package/dist/channels/discord/channel.js +245 -0
  95. package/dist/channels/feishu/bot.js +1275 -0
  96. package/dist/channels/feishu/channel.js +911 -0
  97. package/dist/channels/feishu/markdown.js +91 -0
  98. package/dist/channels/feishu/render.js +619 -0
  99. package/dist/channels/health.js +109 -0
  100. package/dist/channels/slack/bot.js +554 -0
  101. package/dist/channels/slack/channel.js +283 -0
  102. package/dist/channels/states.js +6 -0
  103. package/dist/channels/telegram/bot.js +1310 -0
  104. package/dist/channels/telegram/channel.js +820 -0
  105. package/dist/channels/telegram/directory.js +111 -0
  106. package/dist/channels/telegram/live-preview.js +220 -0
  107. package/dist/channels/telegram/render.js +384 -0
  108. package/dist/channels/wecom/bot.js +558 -0
  109. package/dist/channels/wecom/channel.js +479 -0
  110. package/dist/channels/weixin/api.js +520 -0
  111. package/dist/channels/weixin/bot.js +1000 -0
  112. package/dist/channels/weixin/channel.js +222 -0
  113. package/dist/cli/autostart.js +262 -0
  114. package/dist/cli/channel-supervisor.js +313 -0
  115. package/dist/cli/channels.js +54 -0
  116. package/dist/cli/main.js +726 -0
  117. package/dist/cli/onboarding.js +227 -0
  118. package/dist/cli/run.js +308 -0
  119. package/dist/cli/setup-wizard.js +235 -0
  120. package/dist/core/config/runtime-config.js +201 -0
  121. package/dist/core/config/user-config.js +510 -0
  122. package/dist/core/config/validation.js +521 -0
  123. package/dist/core/constants.js +400 -0
  124. package/dist/core/git.js +145 -0
  125. package/dist/core/legacy-compat.js +60 -0
  126. package/dist/core/logging.js +101 -0
  127. package/dist/core/platform.js +59 -0
  128. package/dist/core/process-control.js +315 -0
  129. package/dist/core/secrets/index.js +42 -0
  130. package/dist/core/secrets/inline-seal.js +60 -0
  131. package/dist/core/secrets/ref.js +33 -0
  132. package/dist/core/secrets/resolver.js +65 -0
  133. package/dist/core/secrets/store.js +63 -0
  134. package/dist/core/utils.js +233 -0
  135. package/dist/core/version.js +15 -0
  136. package/dist/dashboard/platform.js +219 -0
  137. package/dist/dashboard/routes/agents.js +450 -0
  138. package/dist/dashboard/routes/cli.js +174 -0
  139. package/dist/dashboard/routes/config.js +523 -0
  140. package/dist/dashboard/routes/extensions.js +745 -0
  141. package/dist/dashboard/routes/local-models.js +290 -0
  142. package/dist/dashboard/routes/models.js +324 -0
  143. package/dist/dashboard/routes/sessions.js +838 -0
  144. package/dist/dashboard/runtime.js +410 -0
  145. package/dist/dashboard/server.js +237 -0
  146. package/dist/dashboard/session-control.js +347 -0
  147. package/dist/model/catalog.js +104 -0
  148. package/dist/model/index.js +20 -0
  149. package/dist/model/injector.js +272 -0
  150. package/dist/model/provider-models.js +112 -0
  151. package/dist/model/store.js +212 -0
  152. package/dist/model/types.js +13 -0
  153. package/dist/model/validation.js +203 -0
  154. package/package.json +82 -0
@@ -0,0 +1,687 @@
1
+ /**
2
+ * Pure utility functions shared across the agent layer.
3
+ * No filesystem or session state side effects.
4
+ */
5
+ import fs from 'node:fs';
6
+ import { writeScopedLog } from '../core/logging.js';
7
+ export const Q = (a) => {
8
+ if (/[^a-zA-Z0-9_./:=@-]/.test(a)) {
9
+ return process.platform === 'win32'
10
+ ? `"${a.replace(/"/g, '""')}"`
11
+ : `'${a.replace(/'/g, "'\\''")}'`;
12
+ }
13
+ return a;
14
+ };
15
+ export function agentLog(msg, level = 'debug') {
16
+ writeScopedLog('agent', msg, { level });
17
+ }
18
+ export function agentWarn(msg) {
19
+ agentLog(msg, 'warn');
20
+ }
21
+ export function agentError(msg) {
22
+ agentLog(msg, 'error');
23
+ }
24
+ export function dedupeStrings(values) {
25
+ const seen = new Set();
26
+ const deduped = [];
27
+ for (const value of values) {
28
+ const item = String(value || '').trim();
29
+ if (!item || seen.has(item))
30
+ continue;
31
+ seen.add(item);
32
+ deduped.push(item);
33
+ }
34
+ return deduped;
35
+ }
36
+ export function numberOrNull(...values) {
37
+ for (const value of values) {
38
+ if (typeof value === 'number' && Number.isFinite(value))
39
+ return value;
40
+ }
41
+ return null;
42
+ }
43
+ export function normalizeStreamPreviewPlan(value) {
44
+ if (!value || typeof value !== 'object')
45
+ return null;
46
+ const record = value;
47
+ const rawSteps = Array.isArray(record.steps)
48
+ ? record.steps
49
+ : Array.isArray(record.plan)
50
+ ? record.plan
51
+ : [];
52
+ const steps = rawSteps
53
+ .map((entry) => {
54
+ if (!entry || typeof entry !== 'object')
55
+ return null;
56
+ const step = typeof entry.step === 'string' ? entry.step.trim() : '';
57
+ if (!step)
58
+ return null;
59
+ const rawStatus = typeof entry.status === 'string' ? entry.status : 'pending';
60
+ const status = rawStatus === 'completed' || rawStatus === 'inProgress' || rawStatus === 'pending'
61
+ ? rawStatus
62
+ : 'pending';
63
+ return { step, status };
64
+ })
65
+ .filter((entry) => !!entry);
66
+ if (!steps.length)
67
+ return null;
68
+ return {
69
+ explanation: typeof record.explanation === 'string' && record.explanation.trim() ? record.explanation.trim() : null,
70
+ steps,
71
+ };
72
+ }
73
+ /** Parse a TodoWrite tool input into a StreamPreviewPlan. */
74
+ export function parseTodoWriteAsPlan(input) {
75
+ if (!input || typeof input !== 'object')
76
+ return null;
77
+ const rawTodos = Array.isArray(input.todos) ? input.todos : [];
78
+ if (!rawTodos.length)
79
+ return null;
80
+ const steps = [];
81
+ for (const todo of rawTodos) {
82
+ if (!todo || typeof todo !== 'object')
83
+ continue;
84
+ const content = typeof todo.content === 'string' ? todo.content.trim() : '';
85
+ if (!content)
86
+ continue;
87
+ const rawStatus = typeof todo.status === 'string' ? todo.status : 'pending';
88
+ const status = rawStatus === 'completed' ? 'completed'
89
+ : rawStatus === 'in_progress' ? 'inProgress'
90
+ : 'pending';
91
+ steps.push({ step: content, status });
92
+ }
93
+ if (!steps.length)
94
+ return null;
95
+ return { explanation: null, steps };
96
+ }
97
+ export function normalizeActivityLine(text) { return text.replace(/\s+/g, ' ').trim(); }
98
+ // The activity feed is only ever rendered as a tail — downstream previews trim
99
+ // it to ~900 chars (trimActivityForPreview) and the final reply to ~1600 — yet
100
+ // every tool event rebuilds `s.activity = recentActivity.join('\n')`. A 500-line
101
+ // cap made that rejoin effectively O(n²) over a tool-heavy turn for history no
102
+ // view ever shows; 80 lines comfortably covers the largest consumer.
103
+ export function pushRecentActivity(lines, line, maxLines = 80) {
104
+ const cleaned = normalizeActivityLine(line);
105
+ if (!cleaned)
106
+ return;
107
+ if (lines[lines.length - 1] === cleaned)
108
+ return;
109
+ lines.push(cleaned);
110
+ if (lines.length > maxLines)
111
+ lines.splice(0, lines.length - maxLines);
112
+ }
113
+ export function firstNonEmptyLine(text) {
114
+ for (const line of String(text || '').split('\n')) {
115
+ const trimmed = line.trim();
116
+ if (trimmed)
117
+ return trimmed;
118
+ }
119
+ return '';
120
+ }
121
+ // MCP tool results carry structured content blocks (e.g. screenshot returns
122
+ // `[{type:'image',...}, {type:'text', text:'Saved as ...'}]`). Coerce that to
123
+ // plain text by keeping only the `type:'text'` blocks; otherwise `String([{…}])`
124
+ // silently becomes the literal "[object Object]" in activity summaries.
125
+ export function coerceToolResultText(value) {
126
+ if (typeof value === 'string')
127
+ return value;
128
+ if (Array.isArray(value)) {
129
+ return value
130
+ .filter((c) => c && c.type === 'text' && typeof c.text === 'string')
131
+ .map((c) => c.text)
132
+ .join('\n');
133
+ }
134
+ return '';
135
+ }
136
+ export function shortValue(value, max = 90) {
137
+ const text = typeof value === 'string' ? value.trim() : value == null ? '' : String(value).trim();
138
+ if (!text)
139
+ return '';
140
+ if (text.length <= max)
141
+ return text;
142
+ return `${text.slice(0, Math.max(0, max - 3)).trimEnd()}...`;
143
+ }
144
+ export function normalizeErrorMessage(value) {
145
+ if (typeof value === 'string')
146
+ return value.trim();
147
+ if (value instanceof Error)
148
+ return value.message.trim();
149
+ if (Array.isArray(value)) {
150
+ return value.map(item => normalizeErrorMessage(item)).filter(Boolean).join('; ').trim();
151
+ }
152
+ if (value && typeof value === 'object') {
153
+ const record = value;
154
+ const preferred = normalizeErrorMessage(record.message)
155
+ || normalizeErrorMessage(record.error)
156
+ || normalizeErrorMessage(record.detail)
157
+ || normalizeErrorMessage(record.type)
158
+ || normalizeErrorMessage(record.code)
159
+ || normalizeErrorMessage(record.status);
160
+ if (preferred)
161
+ return preferred;
162
+ try {
163
+ return JSON.stringify(value).trim();
164
+ }
165
+ catch { }
166
+ }
167
+ return value == null ? '' : String(value).trim();
168
+ }
169
+ export function joinErrorMessages(errors) {
170
+ if (!errors?.length)
171
+ return '';
172
+ return errors.map(error => normalizeErrorMessage(error)).filter(Boolean).join('; ').trim();
173
+ }
174
+ /**
175
+ * Detect Claude Code's synthetic "API Error: …" assistant message. When the
176
+ * upstream Anthropic API returns a transient error (529 Overloaded, 5xx, gateway
177
+ * timeouts, …), the Claude CLI swallows it and replaces the assistant turn with
178
+ * a single `text` block whose body is literally `API Error: <reason>`. The
179
+ * turn's stop_reason still claims `end_turn`, so the driver can't distinguish
180
+ * it from a normal short reply without inspecting the text.
181
+ *
182
+ * Heuristics — keep them tight so real prose mentioning "API Error" doesn't
183
+ * trip the detector:
184
+ * - exact prefix "API Error: "
185
+ * - total length ≤ 200 chars (the synthetic line is always short)
186
+ * - no newlines (legit prose containing "API Error" virtually always wraps)
187
+ *
188
+ * Returns the trimmed reason (e.g. "Overloaded", "Internal server error") when
189
+ * matched, otherwise null. Callers decide whether the reason is retryable —
190
+ * `looksRetryable` answers that.
191
+ */
192
+ export function detectClaudeApiError(text) {
193
+ if (!text)
194
+ return null;
195
+ const trimmed = text.trim();
196
+ if (trimmed.length > 200 || trimmed.includes('\n'))
197
+ return null;
198
+ const m = trimmed.match(/^API Error:\s*(.+)$/i);
199
+ return m ? m[1].trim() : null;
200
+ }
201
+ /**
202
+ * Retryable Claude Code API errors — transient upstream conditions that
203
+ * usually clear within seconds. Non-retryable conditions (auth, quota,
204
+ * context length) fall through and surface to the user immediately.
205
+ */
206
+ export function isRetryableClaudeApiError(reason) {
207
+ const r = reason.toLowerCase();
208
+ if (/rate limit|rate limited|quota|usage limit|session limit/i.test(r))
209
+ return false;
210
+ return /overloaded|overload|timeout|timed out|500|502|503|504|529|temporar|gateway|connection|network|internal (server )?error/i.test(r);
211
+ }
212
+ /**
213
+ * Detect Claude Code's "selected model is unavailable" notice — emitted when
214
+ * the requested `--model` id is disabled / not provisioned for the account (a
215
+ * 404 model_not_found). Its delivery differs by mode:
216
+ * - `-p`/stream-json: a `<synthetic>` assistant event carrying
217
+ * `error:"model_not_found"` plus the banner text, and a `result` event with
218
+ * `is_error` — both reach the parser.
219
+ * - TUI: the banner is *only* painted to the PTY screen. It is never written
220
+ * to the transcript JSONL and fires no Stop hook (verified 2026-06-13), so
221
+ * the screen scrape is the sole signal and the turn would otherwise hang
222
+ * until the stall watchdog (3–10 min).
223
+ *
224
+ * Matching is whitespace-insensitive on purpose: the TUI renders the banner
225
+ * character-by-character with cursor positioning, so after ANSI stripping the
226
+ * words lose their spaces and wrap arbitrarily ("issuewiththeselectedmodel").
227
+ * Collapsing whitespace on both sides makes the match survive that rendering.
228
+ * Callers compose the user-facing message via `claudeModelErrorMessage` with
229
+ * the concrete model id they hold.
230
+ */
231
+ export function detectClaudeModelError(text) {
232
+ if (!text)
233
+ return false;
234
+ const collapsed = text.replace(/\s+/g, '').toLowerCase();
235
+ return collapsed.includes('issuewiththeselectedmodel')
236
+ || collapsed.includes('maynotexistoryoumaynothaveaccess');
237
+ }
238
+ /** User-facing message for an unavailable / no-access model (see {@link detectClaudeModelError}). */
239
+ export function claudeModelErrorMessage(model) {
240
+ const id = (model || '').trim();
241
+ return `The selected model${id ? ` (${id})` : ''} is unavailable — it may not exist, or this account doesn't have access to it. Switch to a different model in pikiloop settings.`;
242
+ }
243
+ export function appendSystemPrompt(base, extra) {
244
+ const lhs = String(base || '').trim();
245
+ const rhs = String(extra || '').trim();
246
+ if (!lhs)
247
+ return rhs;
248
+ if (!rhs)
249
+ return lhs;
250
+ return `${lhs}\n\n${rhs}`;
251
+ }
252
+ export function mimeForExt(ext) {
253
+ switch (ext) {
254
+ case '.jpg':
255
+ case '.jpeg': return 'image/jpeg';
256
+ case '.png': return 'image/png';
257
+ case '.gif': return 'image/gif';
258
+ case '.webp': return 'image/webp';
259
+ default: return 'application/octet-stream';
260
+ }
261
+ }
262
+ export function computeContext(s) {
263
+ const fallbackTotal = (s.inputTokens ?? 0) + (s.cachedInputTokens ?? 0) + (s.cacheCreationInputTokens ?? 0);
264
+ const used = s.contextUsedTokens ?? (fallbackTotal > 0 ? fallbackTotal : null);
265
+ const pct = used != null && s.contextWindow
266
+ ? Math.min(99.9, Math.round(used / s.contextWindow * 1000) / 10)
267
+ : null;
268
+ return { contextUsedTokens: used, contextPercent: pct };
269
+ }
270
+ /** Max structured tool calls carried per preview emit (most recent win). */
271
+ const PREVIEW_TOOL_CALLS_MAX = 40;
272
+ export function buildStreamPreviewMeta(s) {
273
+ const ctx = computeContext(s);
274
+ const meta = {
275
+ inputTokens: s.inputTokens, outputTokens: s.outputTokens,
276
+ cachedInputTokens: s.cachedInputTokens,
277
+ contextUsedTokens: ctx.contextUsedTokens, contextPercent: ctx.contextPercent,
278
+ };
279
+ // Turn-cumulative output: finished calls' total + the in-flight call.
280
+ const turnOutput = (s.turnOutputTokensBase ?? 0) + (s.outputTokens ?? 0);
281
+ if (turnOutput > 0)
282
+ meta.turnOutputTokens = turnOutput;
283
+ if (s.byokProviderName)
284
+ meta.providerName = s.byokProviderName;
285
+ if (s.subAgents && s.subAgents.size > 0)
286
+ meta.subAgents = Array.from(s.subAgents.values());
287
+ if (s.generatingImages && s.generatingImages > 0)
288
+ meta.generatingImages = s.generatingImages;
289
+ if (s.claudeToolCallOrder?.length && s.claudeToolsById) {
290
+ const calls = [];
291
+ for (const id of s.claudeToolCallOrder.slice(-PREVIEW_TOOL_CALLS_MAX)) {
292
+ const tool = s.claudeToolsById.get(id);
293
+ if (!tool)
294
+ continue;
295
+ calls.push({
296
+ id,
297
+ name: tool.name,
298
+ summary: tool.summary,
299
+ input: tool.input ?? null,
300
+ result: tool.result ?? null,
301
+ status: tool.status ?? 'running',
302
+ });
303
+ }
304
+ if (calls.length)
305
+ meta.toolCalls = calls;
306
+ }
307
+ return meta;
308
+ }
309
+ /**
310
+ * Bounded, human-readable input detail for a live tool-call row. Bash shows
311
+ * the raw command (the summary already carries the description); everything
312
+ * else gets compact JSON. Returns null when there's nothing beyond the
313
+ * summary worth expanding.
314
+ */
315
+ export function previewToolCallInput(name, input, max = 500) {
316
+ if (input == null)
317
+ return null;
318
+ if (String(name) === 'Bash') {
319
+ const cmd = typeof input.command === 'string' ? input.command.trim() : '';
320
+ return cmd ? clipText(cmd, max) : null;
321
+ }
322
+ try {
323
+ const json = JSON.stringify(input, null, 1);
324
+ if (!json || json === '{}' || json === 'null')
325
+ return null;
326
+ return clipText(json, max);
327
+ }
328
+ catch {
329
+ return null;
330
+ }
331
+ }
332
+ /**
333
+ * Bounded text preview of a tool result. Accepts the JSONL tool_result
334
+ * `content` (string | block array) or a hook `tool_response` (string |
335
+ * object). Extracts text blocks where present, falls back to compact JSON.
336
+ */
337
+ export function previewToolCallResult(content, max = 500) {
338
+ if (content == null)
339
+ return null;
340
+ if (typeof content === 'string')
341
+ return clipText(content.trim(), max) || null;
342
+ if (Array.isArray(content)) {
343
+ const text = content
344
+ .filter((b) => b?.type === 'text' && typeof b.text === 'string')
345
+ .map((b) => b.text)
346
+ .join('\n')
347
+ .trim();
348
+ return text ? clipText(text, max) : null;
349
+ }
350
+ if (typeof content === 'object') {
351
+ // Hook tool_response commonly nests the payload under `content` / `result`.
352
+ if (content.content != null && content.content !== content) {
353
+ const nested = previewToolCallResult(content.content, max);
354
+ if (nested)
355
+ return nested;
356
+ }
357
+ if (typeof content.result === 'string')
358
+ return clipText(content.result.trim(), max) || null;
359
+ try {
360
+ const json = JSON.stringify(content, null, 1);
361
+ if (!json || json === '{}')
362
+ return null;
363
+ return clipText(json, max);
364
+ }
365
+ catch {
366
+ return null;
367
+ }
368
+ }
369
+ return null;
370
+ }
371
+ function clipText(text, max) {
372
+ if (text.length <= max)
373
+ return text;
374
+ return `${text.slice(0, Math.max(0, max - 1)).trimEnd()}…`;
375
+ }
376
+ // Claude tool use helpers (used by driver-claude.ts)
377
+ export function summarizeClaudeToolUse(name, input) {
378
+ const tool = String(name || '').trim() || 'Tool';
379
+ const description = shortValue(input?.description, 120);
380
+ switch (tool) {
381
+ case 'Read': {
382
+ const t = shortValue(input?.file_path || input?.path, 140);
383
+ return t ? `Read ${t}` : 'Read file';
384
+ }
385
+ case 'Edit': {
386
+ const t = shortValue(input?.file_path || input?.path, 140);
387
+ return t ? `Edit ${t}` : 'Edit file';
388
+ }
389
+ case 'Write': {
390
+ const t = shortValue(input?.file_path || input?.path, 140);
391
+ return t ? `Write ${t}` : 'Write file';
392
+ }
393
+ case 'Glob': {
394
+ const p = shortValue(input?.pattern || input?.glob, 120);
395
+ return p ? `List files: ${p}` : 'List files';
396
+ }
397
+ case 'Grep': {
398
+ const p = shortValue(input?.pattern || input?.query, 120);
399
+ return p ? `Search text: ${p}` : 'Search text';
400
+ }
401
+ case 'WebFetch': {
402
+ const u = shortValue(input?.url, 120);
403
+ return u ? `Fetch ${u}` : 'Fetch web page';
404
+ }
405
+ case 'WebSearch': {
406
+ const q = shortValue(input?.query, 120);
407
+ return q ? `Search web: ${q}` : 'Search web';
408
+ }
409
+ case 'TodoWrite': return 'Update plan';
410
+ case 'AskUserQuestion': {
411
+ // Claude's built-in clarify tool. The CLI in `-p` mode self-resolves it
412
+ // with an error and degrades to a plain-text question in the same turn —
413
+ // we just surface the question text in the activity panel so users see
414
+ // what was asked.
415
+ const qs = Array.isArray(input?.questions) ? input.questions : [];
416
+ const first = qs[0];
417
+ const q = shortValue(first?.question || input?.question, 120);
418
+ return q ? `Ask user: ${q}` : 'Ask user';
419
+ }
420
+ case 'Task': {
421
+ const p = shortValue(input?.description || input?.prompt, 120);
422
+ return p ? `Run task: ${p}` : 'Run task';
423
+ }
424
+ case 'Bash': {
425
+ if (description)
426
+ return `Run shell: ${description}`;
427
+ const c = shortValue(input?.command, 120);
428
+ return c ? `Run shell: ${c}` : 'Run shell command';
429
+ }
430
+ default: {
431
+ // MCP tools come through as `mcp__<server>__<tool>` — unwrap common pikiloop tools
432
+ const mcpMatch = tool.match(/^mcp__[^_]+__(.+)$/);
433
+ const bare = mcpMatch ? mcpMatch[1] : tool;
434
+ if (bare === 'im_send_file') {
435
+ const p = shortValue(input?.path, 120);
436
+ return p ? `Send file: ${p}` : 'Send file';
437
+ }
438
+ if (bare === 'im_list_files')
439
+ return 'List workspace files';
440
+ if (bare === 'im_ask_user') {
441
+ const q = shortValue(input?.question, 120);
442
+ return q ? `Ask user: ${q}` : 'Ask user';
443
+ }
444
+ if (description)
445
+ return `${tool}: ${description}`;
446
+ const d = shortValue(input?.file_path || input?.path || input?.command || input?.query || input?.pattern || input?.url, 120);
447
+ return d ? `${tool}: ${d}` : tool;
448
+ }
449
+ }
450
+ }
451
+ export function summarizeClaudeToolResult(tool, block, toolUseResult) {
452
+ const summary = tool?.summary || shortValue(tool?.name || 'Tool', 120) || 'Tool';
453
+ const isError = !!block?.is_error;
454
+ const contentText = coerceToolResultText(block?.content);
455
+ if (isError) {
456
+ const detail = firstNonEmptyLine(toolUseResult?.stderr || toolUseResult?.stdout || contentText);
457
+ return detail ? `${summary} failed: ${shortValue(detail, 120)}` : `${summary} failed`;
458
+ }
459
+ const toolName = tool?.name || '';
460
+ if (toolName === 'Read' || toolName === 'Edit' || toolName === 'Write' || toolName === 'TodoWrite')
461
+ return `${summary} done`;
462
+ const detail = firstNonEmptyLine(toolUseResult?.stdout || contentText || toolUseResult?.stderr || '');
463
+ if (!detail)
464
+ return `${summary} done`;
465
+ return `${summary} -> ${shortValue(detail, 120)}`;
466
+ }
467
+ // Usage helpers (used by drivers)
468
+ export function roundPercent(value) {
469
+ const n = Number(value);
470
+ if (!Number.isFinite(n))
471
+ return null;
472
+ return Math.max(0, Math.min(100, Math.round(n * 10) / 10));
473
+ }
474
+ export function toIsoFromEpochSeconds(value) {
475
+ const n = Number(value);
476
+ if (!Number.isFinite(n) || n <= 0)
477
+ return null;
478
+ return new Date(n * 1000).toISOString();
479
+ }
480
+ export function normalizeUsageStatus(value) {
481
+ const raw = typeof value === 'string' ? value.trim().toLowerCase() : '';
482
+ if (!raw)
483
+ return null;
484
+ const normalized = raw.replace(/[\s-]+/g, '_');
485
+ if (normalized === 'limit_reached' || normalized === 'warning' || normalized === 'allowed')
486
+ return normalized;
487
+ if (normalized.includes('limit') || normalized.includes('exceeded') || normalized.includes('denied'))
488
+ return 'limit_reached';
489
+ if (normalized.includes('warning') || normalized.includes('warn'))
490
+ return 'warning';
491
+ if (normalized.includes('allowed') || normalized === 'ok' || normalized === 'healthy' || normalized === 'ready')
492
+ return 'allowed';
493
+ return normalized;
494
+ }
495
+ export function labelFromWindowMinutes(value, fallback) {
496
+ const minutes = Number(value);
497
+ if (!Number.isFinite(minutes) || minutes <= 0)
498
+ return fallback;
499
+ const roundedMinutes = Math.round(minutes);
500
+ if (Math.abs(roundedMinutes - 300) <= 2)
501
+ return '5h';
502
+ if (Math.abs(roundedMinutes - 10080) <= 5)
503
+ return '7d';
504
+ const roundedDays = Math.round(roundedMinutes / 1440);
505
+ if (roundedDays >= 1 && Math.abs(roundedMinutes - roundedDays * 1440) <= 5)
506
+ return `${roundedDays}d`;
507
+ const roundedHours = Math.round(roundedMinutes / 60);
508
+ if (roundedHours >= 1 && Math.abs(roundedMinutes - roundedHours * 60) <= 2)
509
+ return `${roundedHours}h`;
510
+ return `${roundedMinutes}m`;
511
+ }
512
+ export function usageWindowFromRateLimit(fallback, limit) {
513
+ if (!limit || typeof limit !== 'object')
514
+ return null;
515
+ const usedPercent = roundPercent(limit.used_percent);
516
+ const remainingPercent = usedPercent == null ? null : Math.max(0, Math.round((100 - usedPercent) * 10) / 10);
517
+ const resetAt = toIsoFromEpochSeconds(limit.reset_at ?? limit.resets_at);
518
+ let resetAfterSeconds = null;
519
+ const directResetAfter = Number(limit.reset_after_seconds);
520
+ if (Number.isFinite(directResetAfter) && directResetAfter >= 0)
521
+ resetAfterSeconds = Math.round(directResetAfter);
522
+ else if (resetAt) {
523
+ const resetAtMs = Date.parse(resetAt);
524
+ if (Number.isFinite(resetAtMs))
525
+ resetAfterSeconds = Math.max(0, Math.round((resetAtMs - Date.now()) / 1000));
526
+ }
527
+ return {
528
+ label: labelFromWindowMinutes(limit.window_minutes, fallback),
529
+ usedPercent, remainingPercent, resetAt, resetAfterSeconds,
530
+ status: normalizeUsageStatus(limit.status),
531
+ };
532
+ }
533
+ export function parseJsonTail(raw) {
534
+ const start = raw.indexOf('{');
535
+ if (start < 0)
536
+ return null;
537
+ try {
538
+ return JSON.parse(raw.slice(start));
539
+ }
540
+ catch {
541
+ return null;
542
+ }
543
+ }
544
+ export function modelFamily(model) {
545
+ const lower = model?.toLowerCase() || '';
546
+ if (!lower)
547
+ return null;
548
+ if (lower.includes('fable'))
549
+ return 'fable';
550
+ if (lower.includes('opus'))
551
+ return 'opus';
552
+ if (lower.includes('sonnet'))
553
+ return 'sonnet';
554
+ return null;
555
+ }
556
+ export function normalizeClaudeModelId(model) {
557
+ return typeof model === 'string' ? model.trim() : '';
558
+ }
559
+ export function emptyUsage(agent, error) {
560
+ return { ok: false, agent, source: null, capturedAt: null, status: null, windows: [], error };
561
+ }
562
+ export function readTailLines(filePath, maxBytes = 256 * 1024) {
563
+ try {
564
+ const stat = fs.statSync(filePath);
565
+ const readSize = Math.min(maxBytes, stat.size);
566
+ const fd = fs.openSync(filePath, 'r');
567
+ const buf = Buffer.alloc(readSize);
568
+ fs.readSync(fd, buf, 0, readSize, stat.size - readSize);
569
+ fs.closeSync(fd);
570
+ return buf.toString('utf-8').split('\n').filter(l => l.trim());
571
+ }
572
+ catch {
573
+ return [];
574
+ }
575
+ }
576
+ export function stripInjectedPrompts(text) {
577
+ const markers = ['\n[Session Workspace]'];
578
+ for (const m of markers) {
579
+ const idx = text.indexOf(m);
580
+ if (idx >= 0)
581
+ text = text.slice(0, idx).trim();
582
+ }
583
+ // Strip Codex IDE context prefix ("# Context from my IDE setup: ...")
584
+ if (text.startsWith('# Context from')) {
585
+ const tag = '## My request for Codex:\n';
586
+ const idx = text.indexOf(tag);
587
+ if (idx >= 0)
588
+ return text.slice(idx + tag.length).trim();
589
+ return '';
590
+ }
591
+ return text;
592
+ }
593
+ export const SESSION_PREVIEW_IGNORED_USER_PATTERNS = [
594
+ /^\[Request interrupted by user(?: for tool use)?\]$/i,
595
+ ];
596
+ export const SESSION_PREVIEW_IMAGE_PLACEHOLDER_RE = /\[Image:[^\]]+\]/gi;
597
+ export const SESSION_PREVIEW_FILE_PLACEHOLDER_RE = /\[Attached file:[^\]]+\]/gi;
598
+ /**
599
+ * Claude TUI mode prepends `@/abs/path/file.ext` mentions to the prompt as
600
+ * positional argv (see `src/agent/drivers/claude-tui.ts`) — that's how the TUI
601
+ * ingests local image files. The mentions end up baked into the JSONL user
602
+ * `content` string verbatim. This regex captures them so:
603
+ * - the dashboard's user bubble (via `getClaudeSessionMessages`) can lift
604
+ * them into structured `image` blocks for thumbnail rendering;
605
+ * - session-list previews don't surface a long absolute path.
606
+ * Whitespace-free paths only — matches what `claude-tui.ts` emits.
607
+ */
608
+ export const CLAUDE_AT_MENTION_IMAGE_RE = /(^|\s)@(\/[^\s@\n]+\.(?:png|jpe?g|gif|webp|svg))(?=\s|$)/gi;
609
+ /** Pull the absolute paths out of every image-mention in `text`. */
610
+ export function extractClaudeAtMentionImagePaths(text) {
611
+ if (!text)
612
+ return [];
613
+ const out = [];
614
+ for (const m of text.matchAll(CLAUDE_AT_MENTION_IMAGE_RE))
615
+ out.push(m[2]);
616
+ return out;
617
+ }
618
+ /** Remove image @-mentions from `text` while preserving the leading boundary
619
+ * character (start-of-string or whitespace) so adjacent content stays joinable. */
620
+ export function stripClaudeAtMentionImages(text) {
621
+ if (!text)
622
+ return text;
623
+ return text.replace(CLAUDE_AT_MENTION_IMAGE_RE, (_full, leading) => leading || '');
624
+ }
625
+ export function sanitizeSessionUserPreviewText(text) {
626
+ const cleaned = stripInjectedPrompts(text)
627
+ .replace(SESSION_PREVIEW_IMAGE_PLACEHOLDER_RE, ' ')
628
+ .replace(SESSION_PREVIEW_FILE_PLACEHOLDER_RE, ' ')
629
+ .replace(CLAUDE_AT_MENTION_IMAGE_RE, ' ')
630
+ .replace(/\s+/g, ' ')
631
+ .trim();
632
+ if (!cleaned)
633
+ return '';
634
+ if (SESSION_PREVIEW_IGNORED_USER_PATTERNS.some(pattern => pattern.test(cleaned)))
635
+ return '';
636
+ return cleaned;
637
+ }
638
+ export function isPendingSessionId(sessionId) {
639
+ return typeof sessionId === 'string' && sessionId.startsWith('pending_');
640
+ }
641
+ /**
642
+ * Update a stream-state's session id and notify the caller in one step.
643
+ *
644
+ * Drivers used to assign `s.sessionId = ev.session_id ?? s.sessionId` at every
645
+ * place the CLI surfaced an id, then leave promotion until `finalizeStreamResult`
646
+ * at end-of-stream. That meant an early abort (before the result line) or a
647
+ * mid-stream rotation (Claude `--resume` rewriting the session id) was invisible
648
+ * to the bot runtime — leaving the runtime stuck on a pending id, or letting a
649
+ * later insertion land on a phantom session. Routing through this helper makes
650
+ * every observed id change propagate immediately to `opts.onSessionId`, which
651
+ * in bot.ts wires straight into `promoteSessionRuntime`.
652
+ */
653
+ export function emitSessionIdUpdate(s, rawId) {
654
+ if (typeof rawId !== 'string')
655
+ return;
656
+ const trimmed = rawId.trim();
657
+ if (!trimmed || trimmed === s.sessionId)
658
+ return;
659
+ s.sessionId = trimmed;
660
+ try {
661
+ s._emitSessionId?.(trimmed);
662
+ }
663
+ catch { /* listeners must not break the stream loop */ }
664
+ }
665
+ /**
666
+ * Canonical session-list display title used by *every* surface (IM channels
667
+ * + dashboard). The order is intentional:
668
+ *
669
+ * 1. `title` — set ONCE from the original user prompt that started the
670
+ * session. Stable; never overwritten by sub-agent or tool prompts.
671
+ * 2. `lastQuestion` — most recent user message. Fallback only, because for
672
+ * Claude this can be a Task-tool sub-agent prompt and we don't want
673
+ * sub-agent text leaking into the title.
674
+ * 3. `sessionId` — last-resort identifier.
675
+ *
676
+ * The dashboard frontend (`dashboard/src/utils.ts`) mirrors this order — keep
677
+ * the two in sync.
678
+ */
679
+ export function sessionListDisplayTitle(session) {
680
+ const title = sanitizeSessionUserPreviewText(String(session.title || ''));
681
+ if (title)
682
+ return title;
683
+ const question = sanitizeSessionUserPreviewText(String(session.lastQuestion || ''));
684
+ if (question)
685
+ return question;
686
+ return session.sessionId || '';
687
+ }