@makefinks/daemon 0.7.2 → 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.
@@ -4,8 +4,20 @@ import { getAppConfigDir } from "./preferences";
4
4
 
5
5
  const CONFIG_FILE = "config.json";
6
6
 
7
+ export type McpTransportType = "http" | "sse";
8
+
9
+ export interface McpServerConfig {
10
+ /** Optional stable id. If omitted, derived from URL. */
11
+ id?: string;
12
+ /** Transport type for the MCP server. */
13
+ type: McpTransportType;
14
+ /** MCP endpoint URL. */
15
+ url: string;
16
+ }
17
+
7
18
  export interface ManualConfig {
8
19
  memoryModel?: string;
20
+ mcpServers?: McpServerConfig[];
9
21
  }
10
22
 
11
23
  let cachedConfig: ManualConfig | null = null;
@@ -17,6 +29,10 @@ function getConfigPath(): string {
17
29
  return path.join(getAppConfigDir(), CONFIG_FILE);
18
30
  }
19
31
 
32
+ export function getManualConfigPath(): string {
33
+ return getConfigPath();
34
+ }
35
+
20
36
  export function loadManualConfig(): ManualConfig {
21
37
  const now = Date.now();
22
38
 
@@ -56,6 +72,23 @@ function parseManualConfig(raw: Record<string, unknown>): ManualConfig {
56
72
  config.memoryModel = raw.memoryModel.trim();
57
73
  }
58
74
 
75
+ if (Array.isArray(raw.mcpServers)) {
76
+ const servers: McpServerConfig[] = [];
77
+ for (const entry of raw.mcpServers) {
78
+ if (typeof entry !== "object" || entry === null) continue;
79
+ const obj = entry as Record<string, unknown>;
80
+ const type = obj.type;
81
+ const url = obj.url;
82
+ if (type !== "http" && type !== "sse") continue;
83
+ if (typeof url !== "string" || url.trim().length === 0) continue;
84
+ const id = typeof obj.id === "string" && obj.id.trim().length > 0 ? obj.id.trim() : undefined;
85
+ servers.push({ id, type, url: url.trim() });
86
+ }
87
+ if (servers.length > 0) {
88
+ config.mcpServers = servers;
89
+ }
90
+ }
91
+
59
92
  return config;
60
93
  }
61
94
 
@@ -10,6 +10,80 @@ function normalizeUrlKey(rawUrl: string): string {
10
10
  }
11
11
  }
12
12
 
13
+ function unescapeXmlAttribute(value: string): string {
14
+ return value
15
+ .replace(/&quot;/g, '"')
16
+ .replace(/&lt;/g, "<")
17
+ .replace(/&gt;/g, ">")
18
+ .replace(/&amp;/g, "&");
19
+ }
20
+
21
+ function parseFetchUrlsXml(result: string): {
22
+ items: Array<{
23
+ url: string;
24
+ lineOffset?: number;
25
+ lineLimit?: number;
26
+ totalLines?: number;
27
+ remainingLines?: number | null;
28
+ error?: string;
29
+ success: boolean;
30
+ }>;
31
+ error?: string;
32
+ } {
33
+ const items: Array<{
34
+ url: string;
35
+ lineOffset?: number;
36
+ lineLimit?: number;
37
+ totalLines?: number;
38
+ remainingLines?: number | null;
39
+ error?: string;
40
+ success: boolean;
41
+ }> = [];
42
+
43
+ let error: string | undefined;
44
+ const errorMatch = result.match(/<fetchUrls[^>]*\serror="([^"]+)"[^>]*\/?>/);
45
+ if (errorMatch?.[1]) {
46
+ error = unescapeXmlAttribute(errorMatch[1]);
47
+ }
48
+
49
+ const regex = /<url\s+([^>]*?)(\/?)>/g;
50
+ let match = regex.exec(result);
51
+ while (match) {
52
+ const attrText = match[1] ?? "";
53
+ const attrs: Record<string, string> = {};
54
+ for (const attr of attrText.matchAll(/(\w+)="([^"]*)"/g)) {
55
+ const key = attr[1];
56
+ const value = attr[2] ?? "";
57
+ attrs[key] = unescapeXmlAttribute(value);
58
+ }
59
+ const url = attrs.href;
60
+ if (!url) continue;
61
+ const lineOffset = attrs.lineOffset !== undefined ? Number(attrs.lineOffset) : undefined;
62
+ const lineLimit = attrs.lineLimit !== undefined ? Number(attrs.lineLimit) : undefined;
63
+ const totalLines = attrs.totalLines !== undefined ? Number(attrs.totalLines) : undefined;
64
+ let remainingLines: number | null | undefined = undefined;
65
+ if (attrs.remainingLines === "unknown") remainingLines = null;
66
+ else if (attrs.remainingLines !== undefined) remainingLines = Number(attrs.remainingLines);
67
+ const error = attrs.error;
68
+ const success = !error;
69
+ items.push({
70
+ url,
71
+ lineOffset: Number.isFinite(lineOffset) ? lineOffset : undefined,
72
+ lineLimit: Number.isFinite(lineLimit) ? lineLimit : undefined,
73
+ totalLines: Number.isFinite(totalLines) ? totalLines : undefined,
74
+ remainingLines:
75
+ remainingLines === null || (typeof remainingLines === "number" && Number.isFinite(remainingLines))
76
+ ? remainingLines
77
+ : undefined,
78
+ error,
79
+ success,
80
+ });
81
+ match = regex.exec(result);
82
+ }
83
+
84
+ return { items, error };
85
+ }
86
+
13
87
  function computeCoveragePercent(intervals: Array<[number, number]>, totalLines: number): number | undefined {
14
88
  if (!Number.isFinite(totalLines) || totalLines <= 0) return undefined;
15
89
  if (intervals.length === 0) return undefined;
@@ -43,7 +117,6 @@ export function deriveUrlMenuItems(params: {
43
117
 
44
118
  const intervalsByUrl = new Map<string, Array<[number, number]>>();
45
119
  const totalLinesByUrl = new Map<string, number>();
46
- const highlightsCountByUrl = new Map<string, number>();
47
120
  const lastSeenIndexByUrl = new Map<string, number>();
48
121
  const statusByUrl = new Map<string, "ok" | "error">();
49
122
  const errorByUrl = new Map<string, string>();
@@ -57,58 +130,147 @@ export function deriveUrlMenuItems(params: {
57
130
  if (block.type !== "tool") continue;
58
131
  if (block.call.name !== "fetchUrls" && block.call.name !== "renderUrl") continue;
59
132
 
60
- const input = block.call.input as { url?: string } | undefined;
61
- const url = input?.url;
62
- if (!url) continue;
133
+ const input = block.call.input as { url?: string; requests?: Array<{ url?: string }> } | undefined;
134
+ const urls: string[] = [];
135
+ if (input?.url && typeof input.url === "string") {
136
+ urls.push(input.url);
137
+ }
138
+ if (Array.isArray(input?.requests)) {
139
+ for (const request of input.requests) {
140
+ if (request?.url && typeof request.url === "string") {
141
+ urls.push(request.url);
142
+ }
143
+ }
144
+ }
145
+ if (urls.length === 0) continue;
63
146
 
64
- lastSeenIndexByUrl.set(url, blockIndex);
147
+ for (const url of urls) {
148
+ lastSeenIndexByUrl.set(url, blockIndex);
149
+ }
65
150
 
66
151
  const result = block.result as
67
152
  | {
153
+ success?: boolean;
154
+ error?: string;
155
+ results?: Array<{
156
+ success?: boolean;
157
+ url?: string;
158
+ lineOffset?: number;
159
+ lineLimit?: number;
160
+ totalLines?: number;
161
+ error?: string;
162
+ }>;
68
163
  lineOffset?: number;
69
164
  lineLimit?: number;
70
165
  totalLines?: number;
71
- highlights?: unknown[];
72
- success?: boolean;
73
- error?: string;
74
166
  }
75
167
  | undefined;
76
168
 
77
- if (!result || typeof result !== "object") continue;
169
+ if (!result) continue;
78
170
 
79
- if (result.success === false && typeof result.error === "string" && result.error.trim().length > 0) {
80
- statusByUrl.set(url, "error");
81
- errorByUrl.set(url, result.error.trim());
82
- } else if (result.success === true) {
83
- statusByUrl.set(url, "ok");
84
- }
171
+ if (typeof result === "string") {
172
+ const parsed = parseFetchUrlsXml(result);
173
+ for (const item of parsed.items) {
174
+ const itemUrl = item.url;
85
175
 
86
- if (Array.isArray(result.highlights)) {
87
- highlightsCountByUrl.set(url, result.highlights.length);
176
+ if (!item.success && item.error && item.error.trim().length > 0) {
177
+ statusByUrl.set(itemUrl, "error");
178
+ errorByUrl.set(itemUrl, item.error.trim());
179
+ } else if (item.success) {
180
+ statusByUrl.set(itemUrl, "ok");
181
+ }
182
+
183
+ if (typeof item.totalLines === "number" && item.totalLines > 0) {
184
+ const prev = totalLinesByUrl.get(itemUrl) ?? 0;
185
+ totalLinesByUrl.set(itemUrl, Math.max(prev, item.totalLines));
186
+ }
187
+
188
+ if (
189
+ typeof item.lineOffset === "number" &&
190
+ typeof item.lineLimit === "number" &&
191
+ item.lineLimit > 0 &&
192
+ item.lineOffset >= 0
193
+ ) {
194
+ const start = item.lineOffset;
195
+ const end = item.lineOffset + item.lineLimit;
196
+ const list = intervalsByUrl.get(itemUrl) ?? [];
197
+ list.push([start, end]);
198
+ intervalsByUrl.set(itemUrl, list);
199
+ }
200
+ }
201
+ if (parsed.items.length === 0 && parsed.error) {
202
+ for (const url of urls) {
203
+ statusByUrl.set(url, "error");
204
+ errorByUrl.set(url, parsed.error);
205
+ }
206
+ }
207
+ continue;
88
208
  }
89
209
 
90
- if (
210
+ if (typeof result !== "object") continue;
211
+
212
+ if (Array.isArray(result.results)) {
213
+ for (const item of result.results) {
214
+ if (!item || typeof item !== "object") continue;
215
+ const itemUrl = typeof item.url === "string" ? item.url : null;
216
+ if (!itemUrl) continue;
217
+
218
+ if (item.success === false && typeof item.error === "string" && item.error.trim().length > 0) {
219
+ statusByUrl.set(itemUrl, "error");
220
+ errorByUrl.set(itemUrl, item.error.trim());
221
+ } else if (item.success === true) {
222
+ statusByUrl.set(itemUrl, "ok");
223
+ }
224
+
225
+ if (typeof item.totalLines === "number" && Number.isFinite(item.totalLines) && item.totalLines > 0) {
226
+ const prev = totalLinesByUrl.get(itemUrl) ?? 0;
227
+ totalLinesByUrl.set(itemUrl, Math.max(prev, item.totalLines));
228
+ }
229
+
230
+ if (
231
+ typeof item.lineOffset === "number" &&
232
+ typeof item.lineLimit === "number" &&
233
+ Number.isFinite(item.lineOffset) &&
234
+ Number.isFinite(item.lineLimit) &&
235
+ item.lineLimit > 0 &&
236
+ item.lineOffset >= 0
237
+ ) {
238
+ const start = item.lineOffset;
239
+ const end = item.lineOffset + item.lineLimit;
240
+ const list = intervalsByUrl.get(itemUrl) ?? [];
241
+ list.push([start, end]);
242
+ intervalsByUrl.set(itemUrl, list);
243
+ }
244
+ }
245
+ } else if (
246
+ result.success === false &&
247
+ typeof result.error === "string" &&
248
+ result.error.trim().length > 0
249
+ ) {
250
+ for (const url of urls) {
251
+ statusByUrl.set(url, "error");
252
+ errorByUrl.set(url, result.error.trim());
253
+ }
254
+ } else if (result.success === true) {
255
+ for (const url of urls) {
256
+ statusByUrl.set(url, "ok");
257
+ }
258
+ } else if (
91
259
  typeof result.totalLines === "number" &&
92
260
  Number.isFinite(result.totalLines) &&
93
- result.totalLines > 0
94
- ) {
95
- const prev = totalLinesByUrl.get(url) ?? 0;
96
- totalLinesByUrl.set(url, Math.max(prev, result.totalLines));
97
- }
98
-
99
- if (
261
+ result.totalLines > 0 &&
100
262
  typeof result.lineOffset === "number" &&
101
- typeof result.lineLimit === "number" &&
102
- Number.isFinite(result.lineOffset) &&
103
- Number.isFinite(result.lineLimit) &&
104
- result.lineLimit > 0 &&
105
- result.lineOffset >= 0
263
+ typeof result.lineLimit === "number"
106
264
  ) {
107
- const start = result.lineOffset;
108
- const end = result.lineOffset + result.lineLimit;
109
- const list = intervalsByUrl.get(url) ?? [];
110
- list.push([start, end]);
111
- intervalsByUrl.set(url, list);
265
+ for (const url of urls) {
266
+ const prev = totalLinesByUrl.get(url) ?? 0;
267
+ totalLinesByUrl.set(url, Math.max(prev, result.totalLines));
268
+ const start = result.lineOffset;
269
+ const end = result.lineOffset + result.lineLimit;
270
+ const list = intervalsByUrl.get(url) ?? [];
271
+ list.push([start, end]);
272
+ intervalsByUrl.set(url, list);
273
+ }
112
274
  }
113
275
  }
114
276
 
@@ -134,7 +296,6 @@ export function deriveUrlMenuItems(params: {
134
296
  const urls = [...lastSeenIndexByUrl.keys()];
135
297
  return urls.map((url) => {
136
298
  const groundedCount = lookupGroundedCount(url);
137
- const highlightsCount = highlightsCountByUrl.get(url);
138
299
  const totalLines = totalLinesByUrl.get(url);
139
300
  const intervals = intervalsByUrl.get(url) ?? [];
140
301
  const readPercent = totalLines !== undefined ? computeCoveragePercent(intervals, totalLines) : undefined;
@@ -146,7 +307,6 @@ export function deriveUrlMenuItems(params: {
146
307
  url,
147
308
  groundedCount,
148
309
  readPercent,
149
- highlightsCount,
150
310
  status,
151
311
  error,
152
312
  lastSeenIndex,
@@ -12,6 +12,7 @@ const PREFERENCES_VERSION = 1;
12
12
  const APP_DIR_NAME = "daemon";
13
13
  const PREFERENCES_FILE = "preferences.json";
14
14
  const CREDENTIALS_FILE = "credentials.json";
15
+ const CONFIG_DIR_ENV = "DAEMON_CONFIG_DIR";
15
16
 
16
17
  /** Keys that belong in credentials.json (secrets) vs preferences.json (settings) */
17
18
  const CREDENTIAL_KEYS = ["openRouterApiKey", "openAiApiKey", "exaApiKey"] as const;
@@ -32,6 +33,8 @@ function getBaseConfigDir(): string {
32
33
  }
33
34
 
34
35
  export function getAppConfigDir(): string {
36
+ const override = process.env[CONFIG_DIR_ENV]?.trim();
37
+ if (override) return override;
35
38
  return path.join(getBaseConfigDir(), APP_DIR_NAME);
36
39
  }
37
40
 
@@ -109,6 +112,9 @@ export function parsePreferences(raw: unknown): AppPreferences | null {
109
112
  if (typeof raw.showToolOutput === "boolean") {
110
113
  prefs.showToolOutput = raw.showToolOutput;
111
114
  }
115
+ if (typeof raw.memoryEnabled === "boolean") {
116
+ prefs.memoryEnabled = raw.memoryEnabled;
117
+ }
112
118
  if (isRecord(raw.toolToggles)) {
113
119
  const record = raw.toolToggles;
114
120
  const next: Record<string, boolean> = {};
@@ -8,6 +8,10 @@ function normalizeWhitespace(text: string): string {
8
8
  return text.replace(/\r\n/g, "\n").replace(/\t/g, " ");
9
9
  }
10
10
 
11
+ function escapeXmlAttribute(value: string): string {
12
+ return value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
13
+ }
14
+
11
15
  function truncateText(text: string, maxChars: number): { text: string; truncated: boolean } {
12
16
  if (text.length <= maxChars) return { text, truncated: false };
13
17
  if (maxChars <= 1) return { text: "…", truncated: true };
@@ -105,43 +109,123 @@ function formatExaSearchResult(result: unknown): string | null {
105
109
  }
106
110
 
107
111
  function formatExaFetchResult(result: unknown): string | null {
112
+ if (typeof result === "string") {
113
+ const trimmed = result.trim();
114
+ if (!trimmed) return null;
115
+ if (trimmed.startsWith("<fetchUrls") && trimmed.includes("error=") && !trimmed.includes("</fetchUrls>")) {
116
+ return trimmed;
117
+ }
118
+
119
+ const rawLines = trimmed
120
+ .split("\n")
121
+ .map((line) => line.trimEnd())
122
+ .filter((line) => line.trim().length > 0);
123
+ const hasFetchHeader = rawLines.some((line) => line.includes("<fetchUrls"));
124
+ if (!hasFetchHeader) return trimmed;
125
+
126
+ const headerLine = rawLines.find((line) => line.includes("<fetchUrls"));
127
+ const closingLine = rawLines.find((line) => line.includes("</fetchUrls>"));
128
+ const urlLines = rawLines.filter((line) => line.includes("<url"));
129
+ const errorLines = urlLines.filter((line) => line.includes("error="));
130
+ const normalLines = urlLines.filter((line) => !line.includes("error="));
131
+
132
+ let contentLine: string | undefined;
133
+ if (normalLines.length === 1) {
134
+ const firstUrlIdx = rawLines.findIndex((line) => line.includes("<url") && line.includes("href="));
135
+ if (firstUrlIdx >= 0) {
136
+ contentLine = rawLines
137
+ .slice(firstUrlIdx + 1)
138
+ .find(
139
+ (line) => !line.includes("</url>") && !line.includes("<url") && !line.includes("</fetchUrls>")
140
+ )
141
+ ?.trim();
142
+ }
143
+ }
144
+
145
+ const lines: string[] = [];
146
+ if (headerLine) lines.push(headerLine);
147
+ if (normalLines[0]) lines.push(normalLines[0]);
148
+
149
+ if (errorLines.length > 0) {
150
+ lines.push(errorLines[0]);
151
+ } else if (contentLine) {
152
+ lines.push(contentLine);
153
+ }
154
+
155
+ if (contentLine && errorLines.length > 0 && lines.length < 4) {
156
+ lines.push(contentLine);
157
+ }
158
+
159
+ if (closingLine && lines.length < 4) {
160
+ lines.push(closingLine);
161
+ }
162
+
163
+ return lines.length > 0 ? lines.join("\n") : trimmed;
164
+ }
108
165
  if (!isRecord(result)) return null;
109
166
  if (result.success === false && typeof result.error === "string") {
110
167
  return `error: ${result.error}`;
111
168
  }
112
169
  if (result.success !== true) return null;
113
170
  const data = extractToolDataContainer(result);
114
- const candidate = isRecord(data) ? (data as ExaLikeItem & { remainingLines?: unknown }) : {};
115
- const label = formatExaItemLabel(candidate);
116
- const url = typeof candidate.url === "string" ? candidate.url : "";
117
- const title = typeof candidate.title === "string" ? candidate.title : "";
118
- const lineOffset = typeof candidate.lineOffset === "number" ? candidate.lineOffset : undefined;
119
- const lineLimit = typeof candidate.lineLimit === "number" ? candidate.lineLimit : undefined;
120
- const remainingLines =
121
- typeof (candidate as { remainingLines?: unknown }).remainingLines === "number" ||
122
- (candidate as { remainingLines?: unknown }).remainingLines === null
123
- ? (candidate as { remainingLines: number | null }).remainingLines
124
- : undefined;
125
- const rangeParts: string[] = [];
126
- if (lineOffset !== undefined) rangeParts.push(`lineOffset=${lineOffset}`);
127
- if (lineLimit !== undefined) rangeParts.push(`lineLimit=${lineLimit}`);
128
- if (remainingLines !== undefined) {
129
- rangeParts.push(remainingLines === null ? "remainingLines=unknown" : `remainingLines=${remainingLines}`);
130
- }
131
- const remainingSuffix = rangeParts.length > 0 ? ` (${rangeParts.join(", ")})` : "";
171
+ const container = isRecord(data) ? data : {};
172
+ const results = Array.isArray(container.results) ? container.results : null;
173
+ if (!results) return null;
174
+
175
+ const lines: string[] = ["<fetchUrls>"];
176
+ const maxItems = 3;
177
+ const MAX_LINES = 2;
178
+ const MAX_CHARS = 160;
179
+
180
+ for (const item of results.slice(0, maxItems)) {
181
+ if (!isRecord(item)) continue;
182
+
183
+ const candidate = item as ExaLikeItem & {
184
+ remainingLines?: unknown;
185
+ totalLines?: unknown;
186
+ error?: unknown;
187
+ };
188
+ const url = typeof candidate.url === "string" ? candidate.url : "";
189
+ if (!url) continue;
190
+
191
+ const attributes: string[] = [`href="${escapeXmlAttribute(url)}"`];
192
+ if (typeof candidate.lineOffset === "number") attributes.push(`lineOffset="${candidate.lineOffset}"`);
193
+ if (typeof candidate.lineLimit === "number") attributes.push(`lineLimit="${candidate.lineLimit}"`);
194
+ if (typeof candidate.totalLines === "number") attributes.push(`totalLines="${candidate.totalLines}"`);
195
+ if (typeof candidate.remainingLines === "number") {
196
+ attributes.push(`remainingLines="${candidate.remainingLines}"`);
197
+ } else if (candidate.remainingLines === null) {
198
+ attributes.push(`remainingLines="unknown"`);
199
+ }
132
200
 
133
- const headerBase = url && title ? `${label} ${url}` : label;
134
- const header = `${headerBase}${remainingSuffix}`;
201
+ if (candidate.success === false && typeof candidate.error === "string") {
202
+ attributes.push(`error="${escapeXmlAttribute(candidate.error)}"`);
203
+ lines.push(` <url ${attributes.join(" ")} />`);
204
+ continue;
205
+ }
135
206
 
136
- const text = typeof candidate.text === "string" ? candidate.text : "";
137
- if (!text.trim()) return header;
207
+ const text = typeof candidate.text === "string" ? candidate.text : "";
208
+ if (!text.trim()) {
209
+ lines.push(` <url ${attributes.join(" ")} />`);
210
+ continue;
211
+ }
138
212
 
139
- // Provide a small snippet; downstream truncation enforces the hard caps.
140
- const snippet = normalizeWhitespace(text)
141
- .replace(/\n{3,}/g, "\n\n")
142
- .trim();
213
+ const snippetLines = normalizeWhitespace(text)
214
+ .replace(/\n{3,}/g, "\n\n")
215
+ .trim()
216
+ .split("\n")
217
+ .slice(0, MAX_LINES)
218
+ .map((l) => (l.length > MAX_CHARS ? `${l.slice(0, MAX_CHARS - 1)}…` : l));
143
219
 
144
- return `${header}\n${snippet}`;
220
+ lines.push(` <url ${attributes.join(" ")}>`);
221
+ for (const line of snippetLines) {
222
+ lines.push(` ${escapeXmlAttribute(line)}`);
223
+ }
224
+ lines.push(" </url>");
225
+ }
226
+
227
+ lines.push("</fetchUrls>");
228
+ return lines.length > 2 ? lines.join("\n") : null;
145
229
  }
146
230
 
147
231
  function formatRenderUrlResult(result: unknown): string | null {
@@ -194,6 +278,66 @@ function formatReadFileResult(result: unknown): string | null {
194
278
  return `${path}${range}:\n${content}`;
195
279
  }
196
280
 
281
+ function tryStringify(value: unknown): string {
282
+ if (typeof value === "string") return value;
283
+ if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint")
284
+ return String(value);
285
+ if (value === null) return "null";
286
+ if (value === undefined) return "undefined";
287
+ try {
288
+ return JSON.stringify(value, null, 2);
289
+ } catch {
290
+ return String(value);
291
+ }
292
+ }
293
+
294
+ type McpContentLike = {
295
+ type?: unknown;
296
+ text?: unknown;
297
+ content?: unknown;
298
+ data?: unknown;
299
+ };
300
+
301
+ function extractMcpContentText(item: unknown): string {
302
+ if (!isRecord(item)) return "";
303
+ const it = item as McpContentLike;
304
+ if (typeof it.text === "string") return it.text;
305
+ if (typeof it.content === "string") return it.content;
306
+ if (typeof it.type === "string" && it.type === "text" && typeof it.data === "string") return it.data;
307
+ return "";
308
+ }
309
+
310
+ function formatMcpLikeResult(result: unknown): string | null {
311
+ if (!isRecord(result)) return null;
312
+
313
+ const structuredContent =
314
+ "structuredContent" in result ? (result as UnknownRecord).structuredContent : undefined;
315
+ if (structuredContent !== undefined) {
316
+ const raw = tryStringify(structuredContent);
317
+ return raw.trim().length > 0 ? raw : null;
318
+ }
319
+
320
+ const content = "content" in result ? (result as UnknownRecord).content : undefined;
321
+ if (Array.isArray(content)) {
322
+ const pieces = content
323
+ .map(extractMcpContentText)
324
+ .map((t) => t.trim())
325
+ .filter((t) => t.length > 0);
326
+ const joined = pieces.join("\n");
327
+ if (joined.trim().length > 0) {
328
+ const isError = (result as UnknownRecord).isError === true;
329
+ if (isError && !joined.toLowerCase().startsWith("error:")) {
330
+ return `error: ${joined}`;
331
+ }
332
+ return joined;
333
+ }
334
+ }
335
+
336
+ // Fallback: show the raw container (truncated downstream)
337
+ const fallback = tryStringify(result);
338
+ return fallback.trim().length > 0 ? fallback : null;
339
+ }
340
+
197
341
  /**
198
342
  * Format a very small, terminal-safe preview of a tool result.
199
343
  * Intended for the tool call UI box (not full logs).
@@ -248,3 +392,47 @@ export function formatToolOutputPreview(toolName: string, result: unknown): stri
248
392
 
249
393
  return outputLines.length > 0 ? outputLines : null;
250
394
  }
395
+
396
+ /**
397
+ * Generic preview formatter for dynamic tools (e.g. MCP).
398
+ */
399
+ export function formatGenericToolOutputPreview(result: unknown): string[] | null {
400
+ if (result === undefined) return null;
401
+
402
+ const raw = formatMcpLikeResult(result) ?? tryStringify(result);
403
+ if (!raw.trim()) return ["(no output)"];
404
+
405
+ const MAX_LINES = 4;
406
+ const MAX_CHARS_PER_LINE = 160;
407
+ const MAX_TOTAL_CHARS = 260;
408
+
409
+ const { lines, truncated: lineTruncated } = splitPreviewLines(raw, MAX_LINES);
410
+
411
+ let usedChars = 0;
412
+ const outputLines: string[] = [];
413
+ let anyTruncated = lineTruncated;
414
+
415
+ for (const line of lines) {
416
+ const remaining = Math.max(0, MAX_TOTAL_CHARS - usedChars);
417
+ if (remaining <= 0) {
418
+ anyTruncated = true;
419
+ break;
420
+ }
421
+ const { text: truncatedLine, truncated } = truncateText(line, Math.min(MAX_CHARS_PER_LINE, remaining));
422
+ anyTruncated = anyTruncated || truncated;
423
+ outputLines.push(truncatedLine);
424
+ usedChars += truncatedLine.length;
425
+ }
426
+
427
+ if (outputLines.length === 0) return ["(no output)"];
428
+
429
+ if (anyTruncated && outputLines.length > 0) {
430
+ const last = outputLines[outputLines.length - 1] ?? "";
431
+ if (!last.endsWith("…")) {
432
+ const { text } = truncateText(last, Math.max(1, last.length - 1));
433
+ outputLines[outputLines.length - 1] = `${text}…`.replace(/……$/, "…");
434
+ }
435
+ }
436
+
437
+ return outputLines;
438
+ }