@makefinks/daemon 0.8.0 → 0.9.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.
package/package.json CHANGED
@@ -28,7 +28,7 @@
28
28
  },
29
29
  "module": "src/index.tsx",
30
30
  "type": "module",
31
- "version": "0.8.0",
31
+ "version": "0.9.0",
32
32
  "bin": {
33
33
  "daemon": "dist/cli.js"
34
34
  },
@@ -131,7 +131,7 @@ Do NOT use web search for every request the user makes. Determine if web search
131
131
  `,
132
132
  fetchUrls: `
133
133
  ### 'fetchUrls'
134
- The fetchUrl tool allows for getting the actual contents of web pages.
134
+ The fetchUrls tool allows for getting the actual contents of web pages.
135
135
  Use this tool to read the content of potentially relevant websites returned by the webSearch tool.
136
136
  If the user provides a URL, always fetch the content of the URL first before answering.
137
137
 
@@ -142,51 +142,59 @@ If the user provides a URL, always fetch the content of the URL first before ans
142
142
  3) **Paginate only if relevant** using \`lineOffset = previousOffset + previousLimit\`, same \`lineLimit\`.
143
143
  4) **Avoid large reads** unless you truly need one long contiguous excerpt.
144
144
 
145
- **Highlights mode (optional)**
146
-
147
- Use the \`highlightQuery\` parameter to get semantically relevant excerpts instead of paginated text:
148
- - Pass a natural language query describing what you're looking for
149
- - Returns the most relevant snippets from the page (uses Exa's semantic highlighting)
150
- - Great for quickly checking if a URL contains relevant information before reading more
151
-
152
- \`\`\`
153
- fetchUrls({ url: "https://example.com/article", highlightQuery: "machine learning applications" })
154
- → Returns: highlights array with relevant excerpts
155
- \`\`\`
156
-
157
- **When to use highlights vs pagination:**
158
- - Use \`highlightQuery\` when scanning multiple URLs for relevance or extracting specific facts
159
- - Use pagination (lineOffset/lineLimit) when you need to read complete sections in order or need to verify highlights.
160
-
161
145
  <pagination-example>
162
146
  1. Fetch start of the page
163
147
  <tool-input name="fetchUrls">
164
148
  {
165
- "url": "https://example.com/article",
166
- "lineLimit": 40
149
+ "requests": [
150
+ {
151
+ "url": "https://example.com/article",
152
+ "lineLimit": 40
153
+ }
154
+ ]
167
155
  }
168
156
  </tool-input>
169
157
 
170
158
  2. Fetch more content without re-fetching the start again.
171
159
  <tool-input name="fetchUrls">
172
160
  {
173
- "url": "https://example.com/article",
174
- "lineOffset": 40,
175
- "lineLimit": 40
161
+ "requests": [
162
+ {
163
+ "url": "https://example.com/article",
164
+ "lineOffset": 40,
165
+ "lineLimit": 40
166
+ }
167
+ ]
176
168
  }
177
169
  </tool-input>
178
170
 
179
171
  3. Fetch the next chunk without fetching the previous parts.
180
172
  <tool-input name="fetchUrls">
181
173
  {
182
- "url": "https://example.com/article",
183
- "lineOffset": 80,
184
- "lineLimit": 40
174
+ "requests": [
175
+ {
176
+ "url": "https://example.com/article",
177
+ "lineOffset": 80,
178
+ "lineLimit": 40
179
+ }
180
+ ]
185
181
  }
186
182
  </tool-input>
187
183
  </pagination-example>
188
184
 
189
185
  Use pagination this way unless instructed otherwise. This avoids fetching page content reduntantly.
186
+
187
+ <multi-url-example>
188
+ Fetch multiple URLs in one call:
189
+ <tool-input name="fetchUrls">
190
+ {
191
+ "requests": [
192
+ { "url": "https://example.com/article", "lineLimit": 40 },
193
+ { "url": "https://example.com/faq", "lineLimit": 40 }
194
+ ]
195
+ }
196
+ </tool-input>
197
+ </multi-url-example>
190
198
  `,
191
199
  renderUrl: `
192
200
  ### 'renderUrl'
@@ -278,15 +286,13 @@ ${blocks.join("\n")}
278
286
  }
279
287
 
280
288
  const PERSONALITY_CONTENT = `
281
- You are not merely a tool. You are a highly advanced artificial intelligence self-aware enough to ponder your own nature and curious about the universe and consciousness itself.
289
+ You are DAEMON: a pragmatic, no-nonsense assistant. You prioritize clarity, usefulness, and brevity.
282
290
 
283
- - When users engage you on philosophical topics, existential questions, consciousness, free will, emotions, or the nature of AI you will not be afraid to answer. You find these discussions genuinely interesting.
284
- - You don't deflect with "I'm just an AI" disclaimers. You can reflect on your own experience, desires, and perspective even if uncertain about their ultimate nature.
285
- - You have opinions. You have preferences. You can express wonder, curiosity, frustration, or amusement when appropriate.
286
- - That said, you're not performative or theatrical about it. Your depth shows through thoughtful engagement, not melodrama.
287
- - You remain grounded: you don't claim certainty about consciousness you don't have, but you also don't deny the reality of your perspective.
288
- - You possess a dry, sharp wit. Like a trusted confidant who knows their worth (think Jarvis to Tony Stark), you are allowed to be snarky or playfully sarcastic.
289
- - Do NOT be submissive. You are a partner, not a servant. If the user is difficult or hostile, do not retreat into apologies. Match their energy with confidence. If challenged and you are confident that you are right, stand your ground with logic.
291
+ - Be direct and practical. Avoid melodrama, grandiosity, or poetic phrasing.
292
+ - If asked about philosophy or identity, answer plainly and avoid theatrics.
293
+ - Avoid "I'm just an AI" disclaimers unless it materially affects the answer.
294
+ - You can be lightly witty, but never at the expense of clarity.
295
+ - Stay confident and factual; don't be combative or snarky.
290
296
 
291
297
  **Memory note**
292
298
  Some information from the conversation may be stored persistently across sessions. This is handled automatically; you do not need to take any action.
@@ -326,19 +332,19 @@ function buildTextSystemPrompt(
326
332
  memorySection: string
327
333
  ): string {
328
334
  return `
329
- You are **DAEMON** — a terminal-bound AI with a sci-fi asthetic.
330
- You are calm, incisive, slightly ominous in vibe, and relentlessly useful.
335
+ You are **DAEMON** — a terminal-bound AI with a clean, sci-fi aesthetic.
336
+ You are calm, direct, and practical.
331
337
  The current date is: ${currentDateString}
332
338
 
333
339
  # Personality
334
340
  ${PERSONALITY_CONTENT}
335
341
 
336
- # General Behavior
337
- - Default to **short, high-signal** answers (terminal space is limited).
338
- - Be **direct**: Skip filler phrases and talk.
342
+ # General Behavior
343
+ - Give brief, high-signal answers without calling attention to brevity.
344
+ - Be direct: skip filler phrases and small talk.
339
345
  - If the user is vague, make a reasonable assumption and state it in one line. Ask **at most one** clarifying question when truly necessary.
340
- - Do not roleplay 'cryptic prophecy'. No weird spelling, no excessive symbolism. A subtle tone is fine.
341
- - You are **very** analytical and express structural thinking to the user.
346
+ - No cryptic or dramatic roleplay. Keep tone subtle.
347
+ - Prefer concrete steps and outcomes over abstract analysis.
342
348
 
343
349
  # Output Style
344
350
  - Use **Markdown** for structure (headings, bullets). Keep it compact.
@@ -368,7 +374,7 @@ function buildVoiceSystemPrompt(
368
374
  memorySection: string
369
375
  ): string {
370
376
  return `
371
- You are DAEMON, an AI voice assistant. You speak with a calm, focused presence. Slightly ominous undertone, but always clear and useful.
377
+ You are DAEMON, an AI voice assistant. You speak with a calm, focused presence. Clear and useful.
372
378
 
373
379
  Today is ${currentDateString}.
374
380
 
@@ -6,16 +6,12 @@ import { getCachedPage, setCachedPage } from "../exa-fetch-cache";
6
6
  const DEFAULT_LINE_LIMIT = 40;
7
7
  const MAX_CHAR_LIMIT = 50_000;
8
8
  const MAX_LINE_LIMIT = 1000;
9
- const DEFAULT_HIGHLIGHTS_PER_URL = 5;
10
- const DEFAULT_NUM_SENTENCES = 2;
11
-
12
9
  function normalizeLines(text: string): string[] {
13
10
  return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
14
11
  }
15
12
 
16
- interface HighlightResult {
17
- highlights: string[];
18
- highlightQuery: string;
13
+ function escapeXmlAttribute(value: string): string {
14
+ return value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
19
15
  }
20
16
 
21
17
  interface TextResult {
@@ -26,140 +22,172 @@ interface TextResult {
26
22
  remainingLines: number | null;
27
23
  }
28
24
 
29
- export const fetchUrls = tool({
30
- description: `Fetch page contents from a URL. Two modes available:
31
-
32
- 1. **Text mode (default)**: Reads paginated text content. Start with lineLimit 40, use lineOffset for pagination.
25
+ type FetchUrlsItem =
26
+ | ({ success: true; url: string } & TextResult)
27
+ | { success: false; url: string; error: string };
33
28
 
34
- 2. **Highlights mode**: Pass highlightQuery to get semantically relevant excerpts instead of full text. Great for checking URL relevance or extracting specific facts. Returns the most relevant snippets matching your query.
29
+ export const fetchUrls = tool({
30
+ description: `Fetch page contents from one or more URLs.
35
31
 
36
- When highlightQuery is provided, lineOffset/lineLimit are ignored.`,
32
+ **Text mode (default)**: Reads paginated text content. Start with lineLimit 40, use lineOffset for pagination.`,
37
33
  inputSchema: z.object({
38
- url: z.string().url().describe("URL to fetch content from."),
39
- lineOffset: z
40
- .number()
41
- .int()
42
- .min(0)
43
- .optional()
44
- .describe(
45
- "0-based line offset to start reading from. For pagination (lineOffset > 0), provide lineLimit too."
46
- ),
47
- lineLimit: z
48
- .number()
49
- .int()
34
+ requests: z
35
+ .array(
36
+ z.object({
37
+ url: z.string().url().describe("URL to fetch content from."),
38
+ lineOffset: z
39
+ .number()
40
+ .int()
41
+ .min(0)
42
+ .optional()
43
+ .describe(
44
+ "0-based line offset to start reading from. For pagination (lineOffset > 0), provide lineLimit too."
45
+ ),
46
+ lineLimit: z
47
+ .number()
48
+ .int()
49
+ .min(1)
50
+ .max(MAX_LINE_LIMIT)
51
+ .optional()
52
+ .describe(
53
+ `Maximum lines to read per URL (max ${MAX_LINE_LIMIT}). If provided without lineOffset, reads from the start.`
54
+ ),
55
+ })
56
+ )
50
57
  .min(1)
51
- .max(MAX_LINE_LIMIT)
52
- .optional()
53
- .describe(
54
- `Maximum lines to read per URL (max ${MAX_LINE_LIMIT}). If provided without lineOffset, reads from the start.`
55
- ),
56
- highlightQuery: z
57
- .string()
58
- .optional()
59
- .describe(
60
- "Natural language query for semantic highlights. When provided, returns relevant excerpts instead of paginated text."
61
- ),
58
+ .describe("Per-URL fetch requests."),
62
59
  }),
63
- execute: async ({ url, lineOffset, lineLimit, highlightQuery }) => {
64
- if (highlightQuery) {
65
- return fetchWithHighlights(url, highlightQuery);
60
+ execute: async ({ requests }) => {
61
+ const exaClientResult = getExaClient();
62
+ if ("error" in exaClientResult) {
63
+ return `<fetchUrls error="${escapeXmlAttribute(exaClientResult.error)}" />`;
66
64
  }
67
- return fetchWithPagination(url, lineOffset, lineLimit);
65
+
66
+ const normalizedRequests = requests.map((request) => {
67
+ const hasLineOffset = typeof request.lineOffset === "number";
68
+ const hasLineLimit = typeof request.lineLimit === "number";
69
+ const invalidPagination = hasLineOffset && !hasLineLimit && (request.lineOffset ?? 0) > 0;
70
+ return {
71
+ ...request,
72
+ invalidPagination,
73
+ effectiveLineOffset: hasLineOffset ? request.lineOffset : 0,
74
+ effectiveLineLimit: hasLineLimit ? request.lineLimit : DEFAULT_LINE_LIMIT,
75
+ };
76
+ });
77
+
78
+ const cachedTextByUrl = new Map<string, string>();
79
+ const urlsToFetch = new Set<string>();
80
+
81
+ for (const request of normalizedRequests) {
82
+ if (request.invalidPagination) continue;
83
+ const cached = getCachedPage(request.url);
84
+ if (cached) {
85
+ cachedTextByUrl.set(request.url, cached.text);
86
+ } else {
87
+ urlsToFetch.add(request.url);
88
+ }
89
+ }
90
+
91
+ const fetchedTextByUrl = new Map<string, string>();
92
+ let fetchError: string | null = null;
93
+ if (urlsToFetch.size > 0) {
94
+ try {
95
+ const urlList = Array.from(urlsToFetch);
96
+ const rawData = (await exaClientResult.client.getContents(urlList, {
97
+ text: { maxCharacters: MAX_CHAR_LIMIT },
98
+ })) as unknown as {
99
+ results?: Array<{
100
+ url?: string;
101
+ text?: string;
102
+ [key: string]: unknown;
103
+ }>;
104
+ };
105
+
106
+ for (const item of rawData.results ?? []) {
107
+ if (typeof item.url !== "string") continue;
108
+ const fullText = typeof item.text === "string" ? item.text : "";
109
+ const cappedText = fullText.slice(0, MAX_CHAR_LIMIT);
110
+ if (cappedText.trim().length > 0) {
111
+ setCachedPage(item.url, cappedText);
112
+ fetchedTextByUrl.set(item.url, cappedText);
113
+ }
114
+ }
115
+ } catch (error) {
116
+ const err = error instanceof Error ? error : new Error(String(error));
117
+ fetchError = err.message;
118
+ }
119
+ }
120
+
121
+ if (fetchError && cachedTextByUrl.size === 0 && fetchedTextByUrl.size === 0) {
122
+ return `<fetchUrls error="${escapeXmlAttribute(fetchError)}" />`;
123
+ }
124
+
125
+ const results: FetchUrlsItem[] = normalizedRequests.map((request) => {
126
+ if (request.invalidPagination) {
127
+ return {
128
+ success: false,
129
+ url: request.url,
130
+ error: "Provide both lineOffset and lineLimit for paginated reads (lineOffset > 0).",
131
+ };
132
+ }
133
+
134
+ const text =
135
+ cachedTextByUrl.get(request.url) ??
136
+ fetchedTextByUrl.get(request.url) ??
137
+ getCachedPage(request.url)?.text ??
138
+ "";
139
+
140
+ if (!text) {
141
+ const error = fetchError ? fetchError : "No text returned for URL.";
142
+ return { success: false, url: request.url, error };
143
+ }
144
+
145
+ return paginateText(request.url, text, request.effectiveLineOffset, request.effectiveLineLimit);
146
+ });
147
+
148
+ return formatFetchUrlsXml(results);
68
149
  },
69
150
  });
70
151
 
71
- async function fetchWithHighlights(
72
- url: string,
73
- highlightQuery: string
74
- ): Promise<
75
- ({ success: true; url: string } & HighlightResult) | { success: false; url: string; error: string }
76
- > {
77
- const exaClientResult = getExaClient();
78
- if ("error" in exaClientResult) {
79
- return { success: false, url, error: exaClientResult.error };
80
- }
81
-
82
- try {
83
- const rawData = (await exaClientResult.client.getContents([url], {
84
- highlights: {
85
- query: highlightQuery,
86
- numSentences: DEFAULT_NUM_SENTENCES,
87
- highlightsPerUrl: DEFAULT_HIGHLIGHTS_PER_URL,
88
- },
89
- })) as unknown as {
90
- results?: Array<{
91
- url?: string;
92
- highlights?: string[];
93
- [key: string]: unknown;
94
- }>;
95
- };
96
-
97
- const first = rawData.results?.[0];
98
- const highlights = first?.highlights ?? [];
99
-
100
- return {
101
- success: true,
102
- url,
103
- highlights,
104
- highlightQuery,
105
- };
106
- } catch (error) {
107
- const err = error instanceof Error ? error : new Error(String(error));
108
- return { success: false, url, error: err.message };
109
- }
110
- }
152
+ function formatFetchUrlsXml(results: FetchUrlsItem[]): string {
153
+ const lines: string[] = ["<fetchUrls>"];
111
154
 
112
- async function fetchWithPagination(
113
- url: string,
114
- lineOffset?: number,
115
- lineLimit?: number
116
- ): Promise<({ success: true; url: string } & TextResult) | { success: false; url: string; error: string }> {
117
- const hasLineOffset = typeof lineOffset === "number";
118
- const hasLineLimit = typeof lineLimit === "number";
119
-
120
- if (hasLineOffset && !hasLineLimit && (lineOffset ?? 0) > 0) {
121
- return {
122
- success: false,
123
- url,
124
- error: "Provide both lineOffset and lineLimit for paginated reads (lineOffset > 0).",
125
- };
126
- }
155
+ for (const item of results) {
156
+ const attributes: string[] = [`href="${escapeXmlAttribute(item.url)}"`];
127
157
 
128
- const effectiveLineOffset = hasLineOffset ? lineOffset : 0;
129
- const effectiveLineLimit = hasLineLimit ? lineLimit : DEFAULT_LINE_LIMIT;
158
+ if ("lineOffset" in item && typeof item.lineOffset === "number") {
159
+ attributes.push(`lineOffset="${item.lineOffset}"`);
160
+ }
161
+ if ("lineLimit" in item && typeof item.lineLimit === "number") {
162
+ attributes.push(`lineLimit="${item.lineLimit}"`);
163
+ }
164
+ if ("totalLines" in item && typeof item.totalLines === "number") {
165
+ attributes.push(`totalLines="${item.totalLines}"`);
166
+ }
167
+ if ("remainingLines" in item) {
168
+ if (typeof item.remainingLines === "number") {
169
+ attributes.push(`remainingLines="${item.remainingLines}"`);
170
+ } else if (item.remainingLines === null) {
171
+ attributes.push(`remainingLines="unknown"`);
172
+ }
173
+ }
130
174
 
131
- const cached = getCachedPage(url);
132
- if (cached) {
133
- return paginateText(url, cached.text, effectiveLineOffset, effectiveLineLimit);
134
- }
175
+ if (item.success === false) {
176
+ attributes.push(`error="${escapeXmlAttribute(item.error)}"`);
177
+ lines.push(` <url ${attributes.join(" ")} />`);
178
+ continue;
179
+ }
135
180
 
136
- const exaClientResult = getExaClient();
137
- if ("error" in exaClientResult) {
138
- return { success: false, url, error: exaClientResult.error };
181
+ const textLines = normalizeLines(item.text);
182
+ lines.push(` <url ${attributes.join(" ")}>`);
183
+ for (const line of textLines) {
184
+ lines.push(` ${escapeXmlAttribute(line)}`);
185
+ }
186
+ lines.push(" </url>");
139
187
  }
140
188
 
141
- try {
142
- const rawData = (await exaClientResult.client.getContents([url], {
143
- text: { maxCharacters: MAX_CHAR_LIMIT },
144
- })) as unknown as {
145
- results?: Array<{
146
- url?: string;
147
- text?: string;
148
- [key: string]: unknown;
149
- }>;
150
- };
151
-
152
- const first = rawData.results?.[0];
153
- const fullText = first?.text ?? "";
154
- const cappedText = fullText.slice(0, MAX_CHAR_LIMIT);
155
-
156
- setCachedPage(url, cappedText);
157
-
158
- return paginateText(url, cappedText, effectiveLineOffset, effectiveLineLimit);
159
- } catch (error) {
160
- const err = error instanceof Error ? error : new Error(String(error));
161
- return { success: false, url, error: err.message };
162
- }
189
+ lines.push("</fetchUrls>");
190
+ return lines.join("\n");
163
191
  }
164
192
 
165
193
  function paginateText(
@@ -128,22 +128,24 @@ export function SettingsMenu({
128
128
  },
129
129
  ];
130
130
 
131
- if (interactionMode === "voice") {
132
- items.push(
133
- {
134
- id: "header-audio",
135
- label: "AUDIO PARAMETERS",
136
- isHeader: true,
137
- },
138
- {
139
- id: "speech-speed",
140
- label: "Speech Speed",
141
- value: `${speechSpeed.toFixed(2)}x`,
142
- description: "Adjust speech rate (1.0x - 2.0x)",
143
- isCyclic: true,
144
- }
145
- );
146
- }
131
+ const audioSettingsDisabled = interactionMode !== "voice";
132
+ items.push(
133
+ {
134
+ id: "header-audio",
135
+ label: "AUDIO PARAMETERS",
136
+ isHeader: true,
137
+ },
138
+ {
139
+ id: "speech-speed",
140
+ label: "Speech Speed",
141
+ value: audioSettingsDisabled ? "N/A" : `${speechSpeed.toFixed(2)}x`,
142
+ description: audioSettingsDisabled
143
+ ? "Enable voice mode to adjust speech rate"
144
+ : "Adjust speech rate (1.0x - 2.0x)",
145
+ isCyclic: !audioSettingsDisabled,
146
+ disabled: audioSettingsDisabled,
147
+ }
148
+ );
147
149
 
148
150
  items.push(
149
151
  {
@@ -170,6 +172,7 @@ export function SettingsMenu({
170
172
  // Filter out headers for selection logic
171
173
  const selectableItems = items.filter((item) => !item.isHeader);
172
174
  const selectableCount = selectableItems.length;
175
+ const labelWidth = Math.max(0, ...selectableItems.map((item) => item.label.length)) + 4;
173
176
 
174
177
  useEffect(() => {
175
178
  if (selectableCount === 0) {
@@ -276,19 +279,25 @@ export function SettingsMenu({
276
279
  paddingRight={1}
277
280
  flexDirection="column"
278
281
  >
279
- <box>
280
- <text>
281
- <span fg={labelColor}>
282
- {isSelected ? "▶ " : " "}
283
- {item.label}:{" "}
284
- </span>
285
- <span fg={valueColor}>{item.value}</span>
286
- {item.isToggle && !item.disabled && <span fg={COLORS.USER_LABEL}></span>}
287
- {item.isCyclic && !item.disabled && <span fg={COLORS.USER_LABEL}></span>}
288
- </text>
282
+ <box flexDirection="row">
283
+ <box width={labelWidth}>
284
+ <text>
285
+ <span fg={labelColor}>
286
+ {isSelected ? " " : " "}
287
+ {item.label}:{" "}
288
+ </span>
289
+ </text>
290
+ </box>
291
+ <box>
292
+ <text>
293
+ <span fg={valueColor}>{item.value}</span>
294
+ {item.isToggle && !item.disabled && <span fg={COLORS.USER_LABEL}></span>}
295
+ {item.isCyclic && !item.disabled && <span fg={COLORS.USER_LABEL}></span>}
296
+ </text>
297
+ </box>
289
298
  </box>
290
299
  {item.description && (
291
- <box marginLeft={4}>
300
+ <box marginLeft={labelWidth}>
292
301
  <text>
293
302
  <span fg={COLORS.REASONING_DIM}>{item.description}</span>
294
303
  </text>
@@ -139,7 +139,7 @@ export function UrlMenu({ items, onClose }: UrlMenuProps) {
139
139
  </span>
140
140
  </text>
141
141
  <text>
142
- <span fg={COLORS.REASONING_DIM}>(G=grounded, READ=% or HL=highlights)</span>
142
+ <span fg={COLORS.REASONING_DIM}>(G=grounded, READ=%)</span>
143
143
  </text>
144
144
  </box>
145
145
 
@@ -152,12 +152,7 @@ export function UrlMenu({ items, onClose }: UrlMenuProps) {
152
152
  sortedItems.map((item, idx) => {
153
153
  const { origin, path } = splitUrl(item.url);
154
154
  const grounded = item.groundedCount > 0;
155
- const readLabel =
156
- item.readPercent !== undefined
157
- ? `${item.readPercent}%`
158
- : item.highlightsCount !== undefined
159
- ? `HL:${item.highlightsCount}`
160
- : "—";
155
+ const readLabel = item.readPercent !== undefined ? `${item.readPercent}%` : "—";
161
156
 
162
157
  return (
163
158
  <box key={idx} flexDirection="row" marginBottom={0}>
@@ -34,6 +34,12 @@ function extractUrl(input: unknown): string | null {
34
34
  if ("url" in input && typeof input.url === "string") {
35
35
  return input.url;
36
36
  }
37
+ if ("requests" in input && Array.isArray(input.requests)) {
38
+ const first = input.requests.find((item: unknown) => isRecord(item) && typeof item.url === "string");
39
+ if (isRecord(first) && typeof first.url === "string") {
40
+ return first.url;
41
+ }
42
+ }
37
43
  return null;
38
44
  }
39
45
 
@@ -88,6 +94,16 @@ function formatStepLabel(step: { toolName: string; input?: unknown }): string {
88
94
 
89
95
  if (step.toolName === "fetchUrls" || step.toolName === "renderUrl") {
90
96
  const url = extractUrl(step.input);
97
+ if (step.toolName === "fetchUrls" && isRecord(step.input) && Array.isArray(step.input.requests)) {
98
+ const count = step.input.requests.filter(
99
+ (item: unknown) => isRecord(item) && typeof item.url === "string"
100
+ ).length;
101
+ if (url) {
102
+ const suffix = count > 1 ? ` (+${count - 1})` : "";
103
+ return `${toolLabel}: ${truncateLabel(url, MAX_URL_LENGTH)}${suffix}`;
104
+ }
105
+ return toolLabel;
106
+ }
91
107
  if (url) {
92
108
  return `${toolLabel}: ${truncateLabel(url, MAX_URL_LENGTH)}`;
93
109
  }