kiku-agent 1.3.0 → 1.3.1

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 (38) hide show
  1. package/README.md +7 -7
  2. package/package.json +6 -1
  3. package/server/agent/agentCore.js +2 -2
  4. package/server/agent/catalogEntityResolver.js +8 -0
  5. package/server/agent/cli.js +4 -0
  6. package/server/agent/ink/App.jsx +348 -0
  7. package/server/agent/ink/Panel.jsx +54 -0
  8. package/server/agent/ink/PromptInput.jsx +321 -0
  9. package/server/agent/ink/TaskPlanPanel.jsx +88 -0
  10. package/server/agent/ink/ThinkingPanel.jsx +68 -0
  11. package/server/agent/ink/ToolResult.jsx +133 -0
  12. package/server/agent/ink/main.jsx +195 -0
  13. package/server/agent/ink/useEventBus.js +218 -0
  14. package/server/agent/memory/contextAllocator.js +214 -0
  15. package/server/agent/memory/hierarchicalStore.js +523 -0
  16. package/server/agent/memory/reflectionWorker.js +110 -0
  17. package/server/agent/prompts.js +1 -1
  18. package/server/agent/telemetry/hardwareTiers.js +332 -0
  19. package/server/agent/telemetry/runtimeWatchdog.js +66 -0
  20. package/server/agent/telemetry/techPowerUpClient.js +682 -0
  21. package/server/agent/telemetry/techpowerup-mcp.json +22 -0
  22. package/server/agent/websearch/adapters/duckduckgo.js +97 -0
  23. package/server/agent/websearch/adapters/jina.js +77 -0
  24. package/server/agent/websearch/adapters/searxng.js +57 -0
  25. package/server/agent/websearch/core/canonical.js +89 -0
  26. package/server/agent/websearch/core/fusion.js +81 -0
  27. package/server/agent/websearch/core/types.js +29 -0
  28. package/server/agent/websearch/extract.js +605 -0
  29. package/server/agent/websearch/index.js +43 -0
  30. package/server/agent/websearch/orchestrator.js +266 -0
  31. package/server/agent/websearch/resilience/breaker.js +97 -0
  32. package/server/agent/websearch/resilience/cache.js +59 -0
  33. package/server/agent/websearch/resilience/limiter.js +93 -0
  34. package/server/agent/websearch/security/filter.js +36 -0
  35. package/server/agent/websearch/security/ssrf.js +289 -0
  36. package/server/agent/websearch/telemetry.js +71 -0
  37. package/server/apiRouter.js +7 -11
  38. package/server/routes/agent.js +55 -0
@@ -0,0 +1,321 @@
1
+ import React, { useState, useCallback, useEffect, useRef } from 'react';
2
+ import { Box, Text, useInput, useApp } from 'ink';
3
+ import { toolsSchema } from '../tools.js';
4
+ import { INPUT_PLACEHOLDER, previousGraphemeBoundary, nextGraphemeBoundary } from '../ui.js';
5
+ import { COMMAND_DEFINITIONS, SUB_COMMANDS } from '../commandRegistry.js';
6
+ import { getInkPalette } from '../theme.js';
7
+
8
+ const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
9
+
10
+ function getSuggestions(line) {
11
+ const trimmed = line.trimStart();
12
+ if (!trimmed.startsWith('/')) return [];
13
+
14
+ const parts = trimmed.split(/\s+/);
15
+ const baseCmd = parts[0].toLowerCase();
16
+
17
+ if (baseCmd === '/tool' && (parts.length > 1 || line.includes(' '))) {
18
+ const q = parts.slice(1).join(' ').toLowerCase();
19
+ if (!q) {
20
+ return toolsSchema.slice(0, 8).map(t => ({
21
+ name: `/tool ${t.function.name}`,
22
+ desc: t.function.description.slice(0, 50),
23
+ insert: `/tool ${t.function.name} `,
24
+ }));
25
+ }
26
+ return toolsSchema
27
+ .filter(t => t.function.name.toLowerCase().includes(q))
28
+ .slice(0, 8)
29
+ .map(t => ({
30
+ name: `/tool ${t.function.name}`,
31
+ desc: t.function.description.slice(0, 50),
32
+ insert: `/tool ${t.function.name} `,
33
+ }));
34
+ }
35
+
36
+ if (parts.length > 1 || (parts.length === 1 && line.includes(' '))) {
37
+ const subs = SUB_COMMANDS[baseCmd] || [];
38
+ const query = trimmed.toLowerCase();
39
+ return subs
40
+ .filter(s => s.toLowerCase().includes(query) || `${baseCmd} ${s}`.includes(query))
41
+ .map(s => ({
42
+ name: `${baseCmd} ${s}`,
43
+ desc: '',
44
+ insert: `${baseCmd} ${s} `,
45
+ }));
46
+ }
47
+
48
+ return COMMAND_DEFINITIONS
49
+ .map(c => ({
50
+ ...c,
51
+ score: c.name === baseCmd ? 200 : c.name.startsWith(baseCmd) ? 100 : c.name.includes(baseCmd) ? 70 : 0,
52
+ }))
53
+ .filter(c => c.score > 0)
54
+ .sort((a, b) => b.score - a.score);
55
+ }
56
+
57
+ function getGhostText(line, suggestions) {
58
+ if (!suggestions.length || !line.startsWith('/')) return '';
59
+ const top = suggestions[0];
60
+ const target = top.insert || top.name;
61
+ if (target.toLowerCase().startsWith(line.toLowerCase()) && target.length > line.length) {
62
+ return target.slice(line.length);
63
+ }
64
+ return '';
65
+ }
66
+
67
+ export function PromptInput({ onSubmit, musicStatus = '', isProcessing = false, agentStateLabel = '' }) {
68
+ const theme = getInkPalette();
69
+ const [value, setValue] = useState('');
70
+ const [cursorPos, setCursorPos] = useState(0);
71
+ const [suggestions, setSuggestions] = useState([]);
72
+ const [suggestionIdx, setSuggestionIdx] = useState(0);
73
+ const [showSuggestions, setShowSuggestions] = useState(false);
74
+ const [history, setHistory] = useState([]);
75
+ const [historyIdx, setHistoryIdx] = useState(-1);
76
+ const [spinFrame, setSpinFrame] = useState(0);
77
+ const { exit } = useApp();
78
+
79
+ useEffect(() => {
80
+ if (!isProcessing) return;
81
+ const id = setInterval(() => {
82
+ setSpinFrame(prev => (prev + 1) % SPINNER_FRAMES.length);
83
+ }, 80);
84
+ return () => clearInterval(id);
85
+ }, [isProcessing]);
86
+
87
+ useEffect(() => {
88
+ if (value.startsWith('/')) {
89
+ const s = getSuggestions(value);
90
+ setSuggestions(s);
91
+ setShowSuggestions(s.length > 0);
92
+ setSuggestionIdx(0);
93
+ } else {
94
+ setSuggestions([]);
95
+ setShowSuggestions(false);
96
+ }
97
+ }, [value]);
98
+
99
+ const ghostText = getGhostText(value, suggestions);
100
+ const maxVisibleSuggestions = 6;
101
+ const visibleSuggestionStart = Math.max(
102
+ 0,
103
+ Math.min(suggestionIdx - 2, suggestions.length - maxVisibleSuggestions),
104
+ );
105
+ const visibleSuggestions = suggestions.slice(
106
+ visibleSuggestionStart,
107
+ visibleSuggestionStart + maxVisibleSuggestions,
108
+ );
109
+
110
+ const statusText = isProcessing
111
+ ? `${SPINNER_FRAMES[spinFrame]} ${agentStateLabel || 'Processing…'}`
112
+ : musicStatus;
113
+
114
+ useInput((input, key) => {
115
+ if (key.ctrl && input === 'c') {
116
+ if (value.length > 0) {
117
+ setValue('');
118
+ setCursorPos(0);
119
+ return;
120
+ }
121
+ exit();
122
+ return;
123
+ }
124
+
125
+ if (key.ctrl && input === 'l') {
126
+ return;
127
+ }
128
+
129
+ if (key.escape) {
130
+ if (showSuggestions) {
131
+ setShowSuggestions(false);
132
+ return;
133
+ }
134
+ return;
135
+ }
136
+
137
+ if (key.tab) {
138
+ if (showSuggestions && suggestions.length > 0) {
139
+ const item = suggestions[suggestionIdx] || suggestions[0];
140
+ const insertVal = item.insert || item.name;
141
+ setValue(insertVal);
142
+ setCursorPos(insertVal.length);
143
+ if (!insertVal.endsWith(' ')) {
144
+ onSubmit(insertVal);
145
+ setHistory(prev => [...prev, insertVal]);
146
+ setValue('');
147
+ setCursorPos(0);
148
+ }
149
+ return;
150
+ }
151
+ if (ghostText) {
152
+ const newVal = value + ghostText;
153
+ setValue(newVal);
154
+ setCursorPos(newVal.length);
155
+ return;
156
+ }
157
+ return;
158
+ }
159
+
160
+ if (key.return) {
161
+ if (showSuggestions && suggestions.length > 0) {
162
+ const item = suggestions[suggestionIdx] || suggestions[0];
163
+ const insertVal = item.insert || item.name;
164
+ if (insertVal.endsWith(' ')) {
165
+ setValue(insertVal);
166
+ setCursorPos(insertVal.length);
167
+ setShowSuggestions(false);
168
+ return;
169
+ }
170
+ onSubmit(insertVal);
171
+ setHistory(prev => [...prev, insertVal]);
172
+ setValue('');
173
+ setCursorPos(0);
174
+ setShowSuggestions(false);
175
+ return;
176
+ }
177
+ if (value.trim()) {
178
+ onSubmit(value);
179
+ setHistory(prev => [...prev, value]);
180
+ }
181
+ setValue('');
182
+ setCursorPos(0);
183
+ return;
184
+ }
185
+
186
+ if (key.up) {
187
+ if (showSuggestions && suggestions.length > 0) {
188
+ setSuggestionIdx(prev => (prev - 1 + suggestions.length) % suggestions.length);
189
+ return;
190
+ }
191
+ if (history.length > 0) {
192
+ const newIdx = historyIdx === -1 ? history.length - 1 : Math.max(0, historyIdx - 1);
193
+ setHistoryIdx(newIdx);
194
+ const cmd = history[newIdx] || '';
195
+ setValue(cmd);
196
+ setCursorPos(cmd.length);
197
+ }
198
+ return;
199
+ }
200
+
201
+ if (key.down) {
202
+ if (showSuggestions && suggestions.length > 0) {
203
+ setSuggestionIdx(prev => (prev + 1) % suggestions.length);
204
+ return;
205
+ }
206
+ if (historyIdx !== -1) {
207
+ const newIdx = historyIdx + 1;
208
+ if (newIdx >= history.length) {
209
+ setHistoryIdx(-1);
210
+ setValue('');
211
+ setCursorPos(0);
212
+ } else {
213
+ setHistoryIdx(newIdx);
214
+ const cmd = history[newIdx] || '';
215
+ setValue(cmd);
216
+ setCursorPos(cmd.length);
217
+ }
218
+ }
219
+ return;
220
+ }
221
+
222
+ if (key.backspace) {
223
+ if (cursorPos > 0) {
224
+ const previous = previousGraphemeBoundary(value, cursorPos);
225
+ setValue(value.slice(0, previous) + value.slice(cursorPos));
226
+ setCursorPos(previous);
227
+ }
228
+ return;
229
+ }
230
+
231
+ if (key.delete) {
232
+ if (cursorPos < value.length) {
233
+ const next = nextGraphemeBoundary(value, cursorPos);
234
+ setValue(value.slice(0, cursorPos) + value.slice(next));
235
+ }
236
+ return;
237
+ }
238
+
239
+ if (key.home || (key.ctrl && input === 'a')) {
240
+ setCursorPos(0);
241
+ return;
242
+ }
243
+
244
+ if (key.end || (key.ctrl && input === 'e')) {
245
+ setCursorPos(value.length);
246
+ return;
247
+ }
248
+
249
+ if (key.left) {
250
+ setCursorPos(previousGraphemeBoundary(value, cursorPos));
251
+ return;
252
+ }
253
+
254
+ if (key.right) {
255
+ setCursorPos(nextGraphemeBoundary(value, cursorPos));
256
+ return;
257
+ }
258
+
259
+ if (input && !key.ctrl && !key.meta) {
260
+ if (input.startsWith('\x1b') || /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(input)) return;
261
+ const newVal = value.slice(0, cursorPos) + input + value.slice(cursorPos);
262
+ setValue(newVal);
263
+ setCursorPos(prev => prev + input.length);
264
+ }
265
+ });
266
+
267
+ return (
268
+ <Box flexDirection="column">
269
+ <Box
270
+ backgroundColor={isProcessing ? theme.panelHeader : theme.panel}
271
+ width="100%"
272
+ paddingLeft={1}
273
+ paddingRight={1}
274
+ >
275
+ <Text color={isProcessing ? theme.info : theme.muted}>{statusText || ' '}</Text>
276
+ </Box>
277
+
278
+ {showSuggestions && suggestions.length > 0 && (
279
+ <Box
280
+ flexDirection="column"
281
+ width="100%"
282
+ backgroundColor={theme.panel}
283
+ paddingLeft={1}
284
+ paddingRight={1}
285
+ >
286
+ {visibleSuggestions.map((item, idx) => {
287
+ const actualIdx = visibleSuggestionStart + idx;
288
+ const isSelected = actualIdx === suggestionIdx;
289
+ return (
290
+ <Box key={item.name}>
291
+ <Text backgroundColor={theme.panel} color={isSelected ? theme.highlight : theme.text}>{isSelected ? '❯ ' : ' '}</Text>
292
+ <Text
293
+ bold={isSelected}
294
+ color={isSelected ? theme.text : theme.info}
295
+ backgroundColor={theme.panel}
296
+ >
297
+ {item.name.padEnd(22)}
298
+ </Text>
299
+ <Text backgroundColor={theme.panel} color={isSelected ? theme.text : theme.muted} dimColor={!isSelected}>
300
+ {item.desc}{' '}
301
+ </Text>
302
+ </Box>
303
+ );
304
+ })}
305
+ </Box>
306
+ )}
307
+
308
+ <Box backgroundColor={theme.panel} width="100%" paddingY={1}>
309
+ <Text bold color={theme.primary}>Kiku ❯ </Text>
310
+ <Text color={theme.text}>{value}</Text>
311
+ {ghostText && (
312
+ <Text color={theme.subdued} dimColor>{ghostText}</Text>
313
+ )}
314
+ {!value && (
315
+ <Text color={theme.muted} dimColor>{INPUT_PLACEHOLDER}</Text>
316
+ )}
317
+ <Text> </Text>
318
+ </Box>
319
+ </Box>
320
+ );
321
+ }
@@ -0,0 +1,88 @@
1
+ import React, { useState, useEffect } from 'react';
2
+ import { Box, Text } from 'ink';
3
+ import { getInkPalette } from '../theme.js';
4
+
5
+ const RUNNING_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
6
+
7
+ export function TaskPlanPanel({ plan }) {
8
+ const [frameIdx, setFrameIdx] = useState(0);
9
+ const theme = getInkPalette();
10
+
11
+ useEffect(() => {
12
+ if (!plan?.isActive) return;
13
+ const id = setInterval(() => {
14
+ setFrameIdx(prev => (prev + 1) % RUNNING_FRAMES.length);
15
+ }, 70);
16
+ return () => clearInterval(id);
17
+ }, [plan?.isActive]);
18
+
19
+ if (!plan || !plan.steps || plan.steps.length === 0) return null;
20
+
21
+ const total = plan.steps.length;
22
+ const completed = plan.steps.filter(s => s.state === 'complete').length;
23
+ const pct = Math.round((completed / total) * 100);
24
+ const barFilled = Math.round((completed / total) * 10);
25
+ const barEmpty = 10 - barFilled;
26
+ const progressBar = `[${'█'.repeat(barFilled)}${'░'.repeat(barEmpty)}] ${pct}%`;
27
+
28
+ return (
29
+ <Box flexDirection="column" marginY={1} paddingX={1} borderStyle="single" borderColor={theme.subdued}>
30
+ <Box justifyContent="space-between">
31
+ <Text>
32
+ <Text bold color={theme.primary}>◆ Task Plan </Text>
33
+ <Text color={theme.highlight}>{progressBar} </Text>
34
+ <Text color={theme.muted}>({completed}/{total} steps)</Text>
35
+ </Text>
36
+ {plan.isActive && (
37
+ <Text color={theme.info}>
38
+ {RUNNING_FRAMES[frameIdx]} running
39
+ </Text>
40
+ )}
41
+ </Box>
42
+
43
+ {plan.question && (
44
+ <Box>
45
+ <Text color={theme.muted}>
46
+ │ Goal: "{plan.question.length > 56 ? plan.question.slice(0, 56) + '…' : plan.question}"
47
+ </Text>
48
+ </Box>
49
+ )}
50
+
51
+ {plan.steps.map((step, idx) => {
52
+ const isLast = idx === total - 1;
53
+ const branch = isLast ? '└─ ' : '├─ ';
54
+
55
+ let icon = '○';
56
+ let color = theme.muted;
57
+ let stateLabel = 'pending';
58
+
59
+ if (step.state === 'complete') {
60
+ icon = '✓';
61
+ color = theme.success;
62
+ stateLabel = 'done';
63
+ } else if (step.state === 'running') {
64
+ icon = '●';
65
+ color = theme.info;
66
+ stateLabel = 'active';
67
+ } else if (step.state === 'failed') {
68
+ icon = '✕';
69
+ color = theme.error;
70
+ stateLabel = 'failed';
71
+ }
72
+
73
+ return (
74
+ <Box key={step.id || idx}>
75
+ <Text color={theme.muted}>{branch}</Text>
76
+ <Text color={color} bold>{icon} </Text>
77
+ <Text color={theme.muted}>Step {idx + 1}: </Text>
78
+ <Text bold color={theme.text}>{step.label || step.id}</Text>
79
+ {step.objective && (
80
+ <Text color={theme.muted} dimColor> — {step.objective.length > 40 ? step.objective.slice(0, 40) + '…' : step.objective}</Text>
81
+ )}
82
+ <Text color={color}> [{stateLabel}]</Text>
83
+ </Box>
84
+ );
85
+ })}
86
+ </Box>
87
+ );
88
+ }
@@ -0,0 +1,68 @@
1
+ import React, { useState, useEffect } from 'react';
2
+ import { Box, Text } from 'ink';
3
+ import { getInkPalette } from '../theme.js';
4
+
5
+ const DOTS_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
6
+
7
+ export function ThinkingPanel({ text, isActive, duration }) {
8
+ const [frameIdx, setFrameIdx] = useState(0);
9
+ const theme = getInkPalette();
10
+
11
+ useEffect(() => {
12
+ if (!isActive) return;
13
+ const id = setInterval(() => {
14
+ setFrameIdx(prev => (prev + 1) % DOTS_FRAMES.length);
15
+ }, 80);
16
+ return () => clearInterval(id);
17
+ }, [isActive]);
18
+
19
+ if (!isActive && (!text || text.length === 0)) return null;
20
+
21
+ const icon = isActive
22
+ ? DOTS_FRAMES[frameIdx]
23
+ : '[DONE]';
24
+
25
+ const statusColor = isActive ? theme.tertiary : theme.success;
26
+
27
+ const cleanText = (text || '')
28
+ .replace(/\*\*\*?([^*]+)\*\*\*/g, '$1')
29
+ .replace(/\*([^\*\n]+)\*/g, '$1')
30
+ .replace(/^(\s*)[-*]\s+/gm, '$1• ')
31
+ .replace(/\*/g, '')
32
+ .trim();
33
+
34
+ const lines = cleanText.split('\n').filter(l => l.trim());
35
+
36
+ return (
37
+ <Box flexDirection="column">
38
+ <Box>
39
+ <Text> </Text>
40
+ <Text color={statusColor} bold>{icon} </Text>
41
+ <Text color={statusColor} bold>
42
+ {isActive ? 'Thinking…' : `Thought for ${duration}s`}
43
+ </Text>
44
+ </Box>
45
+
46
+ {isActive && lines.length > 0 && (
47
+ <Box flexDirection="column" paddingLeft={1}>
48
+ {lines.slice(0, 15).map((line, i) => (
49
+ <Box key={i}>
50
+ <Text color={theme.muted} dimColor> {line}</Text>
51
+ </Box>
52
+ ))}
53
+ {lines.length > 15 && (
54
+ <Box>
55
+ <Text color={theme.muted} dimColor> ... +{lines.length - 15} more lines</Text>
56
+ </Box>
57
+ )}
58
+ </Box>
59
+ )}
60
+
61
+ {!isActive && duration && (
62
+ <Box>
63
+ <Text> </Text>
64
+ </Box>
65
+ )}
66
+ </Box>
67
+ );
68
+ }
@@ -0,0 +1,133 @@
1
+ import React, { useState, useEffect } from 'react';
2
+ import { Box, Text } from 'ink';
3
+ import { getInkPalette } from '../theme.js';
4
+
5
+ const RUNNING_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
6
+
7
+ export function ToolRunning({ name, args }) {
8
+ const [frameIdx, setFrameIdx] = useState(0);
9
+ const theme = getInkPalette();
10
+
11
+ useEffect(() => {
12
+ const id = setInterval(() => {
13
+ setFrameIdx(prev => (prev + 1) % RUNNING_FRAMES.length);
14
+ }, 70);
15
+ return () => clearInterval(id);
16
+ }, []);
17
+
18
+ const queryVal = args?.query || args?.url || args?.endpoint || '';
19
+ const displayQuery = typeof queryVal === 'string' && queryVal.length > 35
20
+ ? queryVal.slice(0, 35) + '…'
21
+ : queryVal;
22
+
23
+ return (
24
+ <Box>
25
+ <Text> </Text>
26
+ <Text color={theme.info}>{RUNNING_FRAMES[frameIdx]} </Text>
27
+ <Text bold color={theme.text}>{name}</Text>
28
+ {displayQuery && (
29
+ <Text color={theme.muted}> "{displayQuery}"</Text>
30
+ )}
31
+ </Box>
32
+ );
33
+ }
34
+
35
+ export function ToolCompleted({ name, result, duration }) {
36
+ const theme = getInkPalette();
37
+ const isError = result?.status === 'error';
38
+ const isNotFound = result?.status === 'not_found';
39
+
40
+ const statusLabel = isError ? '[ERROR]' : isNotFound ? '[NOT FOUND]' : '[OK]';
41
+ const iconColor = isError
42
+ ? theme.error
43
+ : isNotFound
44
+ ? theme.warning
45
+ : theme.success;
46
+
47
+ const statusText = isError ? 'Failed' : isNotFound ? 'Not found' : 'OK';
48
+ const count = result?.count ?? result?.returnedCount;
49
+ const countTag = count !== null && !isError ? ` • ${count} results` : '';
50
+ const durationTag = duration ? ` • ${(duration / 1000).toFixed(2)}s` : '';
51
+
52
+ const preview = extractPreview(result);
53
+
54
+ return (
55
+ <Box flexDirection="column">
56
+ <Box>
57
+ <Text> </Text>
58
+ <Text color={theme.muted}>▸ </Text>
59
+ <Text bold color={theme.info}>{name}</Text>
60
+ <Text> </Text>
61
+ <Text color={iconColor} bold>{statusLabel}</Text>
62
+ <Text color={iconColor}> {statusText}</Text>
63
+ {countTag && <Text color={theme.info}>{countTag}</Text>}
64
+ {durationTag && <Text color={theme.muted} dimColor>{durationTag}</Text>}
65
+ </Box>
66
+ {preview && (
67
+ <Box paddingLeft={1}>
68
+ <Text color={theme.muted} dimColor>• {preview}</Text>
69
+ </Box>
70
+ )}
71
+ </Box>
72
+ );
73
+ }
74
+
75
+ export function ToolSummary({ tools }) {
76
+ if (!tools || tools.length === 0) return null;
77
+ const theme = getInkPalette();
78
+
79
+ return (
80
+ <Box flexDirection="column">
81
+ <Box>
82
+ <Text> </Text>
83
+ <Text color={theme.info} bold>▾ Tool Invocations ({tools.length})</Text>
84
+ </Box>
85
+ {tools.map((t, idx) => {
86
+ const statusLabel = t.status === 'error' ? '[ERROR]' : t.status === 'running' ? '[RUNNING]' : '[OK]';
87
+ const iconColor = t.status === 'error'
88
+ ? theme.error
89
+ : t.status === 'running'
90
+ ? theme.warning
91
+ : theme.success;
92
+ return (
93
+ <Box key={idx}>
94
+ <Text> </Text>
95
+ <Text color={theme.highlight}>[{idx + 1}]</Text>
96
+ <Text> </Text>
97
+ <Text color={theme.info} bold>{t.name}</Text>
98
+ <Text> </Text>
99
+ <Text color={iconColor}>{statusLabel}</Text>
100
+ {t.duration > 0 && (
101
+ <Text color={theme.muted} dimColor> {(t.duration / 1000).toFixed(2)}s</Text>
102
+ )}
103
+ </Box>
104
+ );
105
+ })}
106
+ </Box>
107
+ );
108
+ }
109
+
110
+ function extractPreview(result) {
111
+ if (!result) return '';
112
+ const MAX = 80;
113
+ let raw = '';
114
+
115
+ if (result.content) raw = result.content;
116
+ else if (result.verdict) raw = `[Verdict: ${result.verdict}] ${result.summary || ''}`;
117
+ else if (result.subagentReport) raw = `[Subagent] ${result.subagentReport}`;
118
+ else if (result.researchReport) raw = `[Research: ${result.sourcesCount || 0} sources] ${result.researchReport}`;
119
+ else if (result.answer) raw = String(result.answer);
120
+ else if (result.results && Array.isArray(result.results) && result.results.length > 0) {
121
+ raw = result.results[0].title || result.results[0].snippet || '';
122
+ } else if (result.works) raw = result.works.map(w => w.titleTh || w.titleCn).filter(Boolean).join(', ');
123
+ else if (result.songs) raw = result.songs.map(s => s.title).filter(Boolean).join(', ');
124
+ else if (result.brands) raw = result.brands.map(b => b.brand).filter(Boolean).join(', ');
125
+ else if (result.profile) raw = `${result.profile.nameTh || ''} ${result.profile.nameCn || ''}`.trim();
126
+ else if (result.stats) raw = `${result.stats.totalWorks || 0} works, ${result.stats.totalUniqueSongs || 0} songs`;
127
+ else if (result.skills) raw = `Skills (${result.skills.length}) enabled: ${result.skillsEnabled}`;
128
+ else if (result.message) raw = result.message;
129
+
130
+ if (!raw) return '';
131
+ raw = raw.replace(/\s+/g, ' ').trim();
132
+ return raw.length > MAX ? raw.slice(0, MAX) + '…' : raw;
133
+ }