@duckmind/dm-windows-x64 0.63.0 → 0.63.4

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 (71) hide show
  1. package/dm.exe +0 -0
  2. package/extensions/.dm-extensions.json +138 -2
  3. package/extensions/dm-web-access/SECURITY.md +5 -0
  4. package/extensions/dm-web-access/activity.js +65 -0
  5. package/extensions/dm-web-access/anysearch.js +158 -0
  6. package/extensions/dm-web-access/auth-fetch.js +131 -0
  7. package/extensions/dm-web-access/bocha.js +214 -0
  8. package/extensions/dm-web-access/brave.js +196 -0
  9. package/extensions/dm-web-access/brightdata-unlocker.js +202 -0
  10. package/extensions/dm-web-access/brightdata.js +334 -0
  11. package/extensions/dm-web-access/chrome-cookies.js +627 -0
  12. package/extensions/dm-web-access/content-find.js +114 -0
  13. package/extensions/dm-web-access/credential-source.js +150 -0
  14. package/extensions/dm-web-access/curator-page.js +3559 -0
  15. package/extensions/dm-web-access/curator-server.js +691 -0
  16. package/extensions/dm-web-access/data-uri-sanitize.js +312 -0
  17. package/extensions/dm-web-access/datalab-pdf-extract.js +346 -0
  18. package/extensions/dm-web-access/declared-web-links.js +167 -0
  19. package/extensions/dm-web-access/dm-web-fetch-demo.mp4 +0 -0
  20. package/extensions/dm-web-access/duckduckgo.js +118 -0
  21. package/extensions/dm-web-access/exa.js +401 -0
  22. package/extensions/dm-web-access/extract.js +1220 -0
  23. package/extensions/dm-web-access/feature-config.js +24 -0
  24. package/extensions/dm-web-access/fetch-params.js +81 -0
  25. package/extensions/dm-web-access/firecrawl.js +378 -0
  26. package/extensions/dm-web-access/gemini-adc.js +241 -0
  27. package/extensions/dm-web-access/gemini-api.js +258 -0
  28. package/extensions/dm-web-access/gemini-pdf-extract.js +74 -0
  29. package/extensions/dm-web-access/gemini-search.js +889 -0
  30. package/extensions/dm-web-access/gemini-url-context.js +97 -0
  31. package/extensions/dm-web-access/gemini-web-config.js +84 -0
  32. package/extensions/dm-web-access/gemini-web.js +351 -0
  33. package/extensions/dm-web-access/github-api.js +166 -0
  34. package/extensions/dm-web-access/github-extract.js +991 -0
  35. package/extensions/dm-web-access/github-issue-pr.js +750 -0
  36. package/extensions/dm-web-access/index.js +3117 -0
  37. package/extensions/dm-web-access/jina-search.js +242 -0
  38. package/extensions/dm-web-access/kagi.js +255 -0
  39. package/extensions/dm-web-access/kimi-search.js +214 -0
  40. package/extensions/dm-web-access/ollama.js +209 -0
  41. package/extensions/dm-web-access/openai-search.js +510 -0
  42. package/extensions/dm-web-access/package.json +34 -0
  43. package/extensions/dm-web-access/page-query.js +124 -0
  44. package/extensions/dm-web-access/parallel-mcp.js +223 -0
  45. package/extensions/dm-web-access/parallel.js +345 -0
  46. package/extensions/dm-web-access/pdf-extract.js +257 -0
  47. package/extensions/dm-web-access/perplexity.js +151 -0
  48. package/extensions/dm-web-access/querit.js +327 -0
  49. package/extensions/dm-web-access/query-rewrite.js +40 -0
  50. package/extensions/dm-web-access/render-search-error.js +80 -0
  51. package/extensions/dm-web-access/rsc-extract.js +347 -0
  52. package/extensions/dm-web-access/search1api.js +245 -0
  53. package/extensions/dm-web-access/searchinfinity.js +221 -0
  54. package/extensions/dm-web-access/searxng.js +223 -0
  55. package/extensions/dm-web-access/serpbase.js +205 -0
  56. package/extensions/dm-web-access/serpdive.js +238 -0
  57. package/extensions/dm-web-access/serper.js +200 -0
  58. package/extensions/dm-web-access/source-check.js +198 -0
  59. package/extensions/dm-web-access/ssrf-protection.js +436 -0
  60. package/extensions/dm-web-access/storage.js +451 -0
  61. package/extensions/dm-web-access/summary-model-scope.js +83 -0
  62. package/extensions/dm-web-access/summary-review.js +364 -0
  63. package/extensions/dm-web-access/tavily.js +199 -0
  64. package/extensions/dm-web-access/tinyfish.js +325 -0
  65. package/extensions/dm-web-access/utils.js +476 -0
  66. package/extensions/dm-web-access/valyu.js +189 -0
  67. package/extensions/dm-web-access/video-extract.js +336 -0
  68. package/extensions/dm-web-access/xai-search.js +285 -0
  69. package/extensions/dm-web-access/xcrawl.js +221 -0
  70. package/extensions/dm-web-access/youtube-extract.js +279 -0
  71. package/package.json +9 -1
@@ -0,0 +1,327 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { activityMonitor } from "./activity.js";
3
+ import { hasCredentialSource, redactCredential, resolveCredential } from "./credential-source.js";
4
+ import { getWebSearchConfigPath } from "./utils.js";
5
+ const QUERIT_SEARCH_URL = "https://api.querit.ai/v1/search";
6
+ const QUERIT_CONTENTS_URL = "https://api.querit.ai/v1/contents";
7
+ const CONFIG_PATH = getWebSearchConfigPath();
8
+ const SEARCH_TIMEOUT_MS = 60000;
9
+ const CONTENTS_TIMEOUT_MS = 60000;
10
+ const MAX_CONTENT_URLS = 10;
11
+ let cachedConfig = null;
12
+ function loadConfig() {
13
+ if (cachedConfig)
14
+ return cachedConfig;
15
+ if (!existsSync(CONFIG_PATH)) {
16
+ cachedConfig = {};
17
+ return cachedConfig;
18
+ }
19
+ const raw = readFileSync(CONFIG_PATH, "utf-8");
20
+ let parsed;
21
+ try {
22
+ parsed = JSON.parse(raw);
23
+ } catch (err) {
24
+ const message = err instanceof Error ? err.message : String(err);
25
+ throw new Error(`Failed to parse ${CONFIG_PATH}: ${message}`);
26
+ }
27
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
28
+ throw new Error(`Invalid config in ${CONFIG_PATH}: expected a JSON object`);
29
+ }
30
+ cachedConfig = parsed;
31
+ return cachedConfig;
32
+ }
33
+ async function getApiKey(signal) {
34
+ const key = await resolveCredential({
35
+ provider: "Querit",
36
+ configuredValue: loadConfig().queritApiKey,
37
+ environmentValue: process.env.QUERIT_API_KEY,
38
+ signal
39
+ });
40
+ if (!key) {
41
+ throw new Error(`Querit API key not found. Either:
42
+ ` + ` 1. Create ${CONFIG_PATH} with { "queritApiKey": "your-key" }
43
+ ` + ` 2. Set QUERIT_API_KEY environment variable
44
+ ` + "Create a key at https://www.querit.ai/en/dashboard/api-keys");
45
+ }
46
+ return key;
47
+ }
48
+ export function isQueritAvailable() {
49
+ return hasCredentialSource({
50
+ provider: "Querit",
51
+ configuredValue: loadConfig().queritApiKey,
52
+ environmentValue: process.env.QUERIT_API_KEY
53
+ });
54
+ }
55
+ function errorMessage(err) {
56
+ return err instanceof Error ? err.message : String(err);
57
+ }
58
+ function requestSignal(signal, timeoutMs) {
59
+ const timeout = AbortSignal.timeout(timeoutMs);
60
+ return signal ? AbortSignal.any([signal, timeout]) : timeout;
61
+ }
62
+ function normalizeNumResults(value) {
63
+ if (typeof value !== "number" || !Number.isFinite(value))
64
+ return 5;
65
+ return Math.max(1, Math.min(Math.floor(value), 20));
66
+ }
67
+ function normalizeDomain(value) {
68
+ let input = value.trim().toLowerCase();
69
+ if (!input)
70
+ return null;
71
+ if (input.startsWith("-"))
72
+ input = input.slice(1).trim();
73
+ if (!input)
74
+ return null;
75
+ try {
76
+ const parsed = input.includes("://") ? new URL(input) : new URL(`https://${input}`);
77
+ input = parsed.hostname;
78
+ } catch {
79
+ input = input.split("/")[0]?.split(":")[0] ?? "";
80
+ }
81
+ input = input.replace(/^\.+|\.+$/g, "");
82
+ return /^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}$/i.test(input) ? input : null;
83
+ }
84
+ function mapDomainFilter(domainFilter) {
85
+ const include = [];
86
+ const exclude = [];
87
+ for (const raw of domainFilter ?? []) {
88
+ const domain = normalizeDomain(raw);
89
+ if (!domain)
90
+ continue;
91
+ const target = raw.trim().startsWith("-") ? exclude : include;
92
+ if (!target.includes(domain))
93
+ target.push(domain);
94
+ }
95
+ return { include, exclude };
96
+ }
97
+ function mapRecencyFilter(value) {
98
+ if (value === "day")
99
+ return "d1";
100
+ if (value === "week")
101
+ return "w1";
102
+ if (value === "month")
103
+ return "m1";
104
+ if (value === "year")
105
+ return "y1";
106
+ return;
107
+ }
108
+ function buildSearchBody(query, options) {
109
+ const { include, exclude } = mapDomainFilter(options.domainFilter);
110
+ const date = mapRecencyFilter(options.recencyFilter);
111
+ const filters = {};
112
+ if (include.length > 0 || exclude.length > 0) {
113
+ filters.sites = {
114
+ ...include.length > 0 ? { include } : {},
115
+ ...exclude.length > 0 ? { exclude } : {}
116
+ };
117
+ }
118
+ if (date)
119
+ filters.timeRange = { date };
120
+ return {
121
+ query,
122
+ count: normalizeNumResults(options.numResults),
123
+ ...Object.keys(filters).length > 0 ? { filters } : {}
124
+ };
125
+ }
126
+ function normalizeTimeoutMs(value) {
127
+ if (typeof value !== "number" || !Number.isFinite(value))
128
+ return CONTENTS_TIMEOUT_MS;
129
+ return Math.max(1, Math.floor(value));
130
+ }
131
+ function crawlTimeoutSeconds(value) {
132
+ if (typeof value !== "number" || !Number.isFinite(value))
133
+ return 10;
134
+ return Math.max(1, Math.min(Math.ceil(value / 1000), 60));
135
+ }
136
+ function buildContentsBody(urls, options = {}) {
137
+ return {
138
+ urls,
139
+ format: "markdown",
140
+ crawlTimeout: crawlTimeoutSeconds(options.timeoutMs),
141
+ extrasMeta: true
142
+ };
143
+ }
144
+ async function queritJsonRequest(label, url, apiKey, body, timeoutMs, signal) {
145
+ let response;
146
+ try {
147
+ response = await fetch(url, {
148
+ method: "POST",
149
+ headers: {
150
+ Authorization: `Bearer ${apiKey}`,
151
+ Accept: "application/json",
152
+ "Content-Type": "application/json"
153
+ },
154
+ body: JSON.stringify(body),
155
+ signal: requestSignal(signal, timeoutMs)
156
+ });
157
+ } catch (err) {
158
+ const message = errorMessage(err);
159
+ const isRequestAbort = err instanceof Error ? err.name === "AbortError" || err.name === "TimeoutError" || /abort|timeout/i.test(message) : /abort|timeout/i.test(message);
160
+ if (!signal?.aborted && isRequestAbort) {
161
+ const timeoutError = new Error(`Querit ${label} API request timed out after ${Math.ceil(timeoutMs / 1000)} seconds`);
162
+ timeoutError.name = "TimeoutError";
163
+ throw timeoutError;
164
+ }
165
+ const redactedMessage = redactCredential(message, apiKey);
166
+ if (redactedMessage === message)
167
+ throw err;
168
+ const redactedError = new Error(redactedMessage);
169
+ if (err instanceof Error)
170
+ redactedError.name = err.name;
171
+ throw redactedError;
172
+ }
173
+ const raw = await response.text();
174
+ if (!response.ok) {
175
+ throw new Error(`Querit ${label} API error ${response.status}: ${redactCredential(raw, apiKey).slice(0, 300)}`);
176
+ }
177
+ try {
178
+ return JSON.parse(raw);
179
+ } catch (err) {
180
+ throw new Error(`Querit ${label} API returned invalid JSON: ${errorMessage(err)}`);
181
+ }
182
+ }
183
+ function assertApiSuccess(label, data) {
184
+ const code = Number(data.error_code);
185
+ if (!Number.isFinite(code) || code !== 200) {
186
+ const renderedCode = data.error_code === undefined ? "unknown" : String(data.error_code);
187
+ const message = typeof data.error_msg === "string" && data.error_msg.trim() ? `: ${data.error_msg.trim()}` : "";
188
+ throw new Error(`Querit ${label} API returned error ${renderedCode}${message}`);
189
+ }
190
+ }
191
+ function mapSearchResults(data) {
192
+ const items = data.results?.result;
193
+ if (!Array.isArray(items)) {
194
+ throw new Error("Querit Search API returned an unexpected response shape");
195
+ }
196
+ return items.flatMap((item) => {
197
+ if (!item || typeof item.url !== "string" || item.url.trim().length === 0)
198
+ return [];
199
+ const url = item.url.trim();
200
+ return [{
201
+ title: typeof item.title === "string" && item.title.trim() ? item.title.trim() : url,
202
+ url,
203
+ snippet: typeof item.snippet === "string" ? item.snippet.replace(/\s+/g, " ").trim() : ""
204
+ }];
205
+ });
206
+ }
207
+ function buildAnswer(results) {
208
+ return results.map((result) => {
209
+ if (result.snippet)
210
+ return `${result.snippet}
211
+ Source: ${result.title} (${result.url})`;
212
+ return `Source: ${result.title} (${result.url})`;
213
+ }).join(`
214
+
215
+ `);
216
+ }
217
+ function mapContentResult(result, requestedUrl) {
218
+ if (!result || typeof result.content !== "string" || result.content.trim().length === 0)
219
+ return null;
220
+ const metadata = result.extrasMeta;
221
+ return {
222
+ url: requestedUrl,
223
+ title: metadata && typeof metadata.title === "string" ? metadata.title.trim() : "",
224
+ content: result.content.trim(),
225
+ error: null
226
+ };
227
+ }
228
+ function findContentResult(data, requestedUrl, index, requestedCount) {
229
+ if (!Array.isArray(data.results))
230
+ return;
231
+ const exact = data.results.find((item) => item?.url === requestedUrl || item?.extrasMeta?.url === requestedUrl);
232
+ if (exact)
233
+ return exact;
234
+ if (requestedCount === 1)
235
+ return data.results[0];
236
+ return data.results.length === requestedCount ? data.results[index] : undefined;
237
+ }
238
+ function failedContentStatus(data, result, index) {
239
+ if (!Array.isArray(data.statuses))
240
+ return false;
241
+ const status = result?.id ? data.statuses.find((item) => item?.id === result.id) : data.statuses[index];
242
+ return status?.status === "failed";
243
+ }
244
+ async function fetchContentsBatch(urls, apiKey, signal, options = {}) {
245
+ const data = await queritJsonRequest("Contents", QUERIT_CONTENTS_URL, apiKey, buildContentsBody(urls, options), normalizeTimeoutMs(options.timeoutMs), signal);
246
+ assertApiSuccess("Contents", data);
247
+ if (!Array.isArray(data.results) || !Array.isArray(data.statuses)) {
248
+ throw new Error("Querit Contents API returned an unexpected response shape");
249
+ }
250
+ return data;
251
+ }
252
+ async function fetchInlineContent(urls, apiKey, signal) {
253
+ const content = [];
254
+ for (let offset = 0;offset < urls.length; offset += MAX_CONTENT_URLS) {
255
+ const batch = urls.slice(offset, offset + MAX_CONTENT_URLS);
256
+ const data = await fetchContentsBatch(batch, apiKey, signal);
257
+ for (let index = 0;index < batch.length; index++) {
258
+ const requestedUrl = batch[index];
259
+ const result = findContentResult(data, requestedUrl, index, batch.length);
260
+ const mapped = mapContentResult(result, requestedUrl);
261
+ if (mapped)
262
+ content.push(mapped);
263
+ }
264
+ }
265
+ return content;
266
+ }
267
+ export async function searchWithQuerit(query, options = {}) {
268
+ const apiKey = await getApiKey(options.signal);
269
+ const activityId = activityMonitor.logStart({ type: "api", query });
270
+ try {
271
+ const data = await queritJsonRequest("Search", QUERIT_SEARCH_URL, apiKey, buildSearchBody(query, options), SEARCH_TIMEOUT_MS, options.signal);
272
+ assertApiSuccess("Search", data);
273
+ const results = mapSearchResults(data);
274
+ const response = { answer: buildAnswer(results), results };
275
+ if (options.includeContent && results.length > 0) {
276
+ const inlineContent = await fetchInlineContent(results.map((result) => result.url), apiKey, options.signal);
277
+ if (inlineContent.length > 0)
278
+ response.inlineContent = inlineContent;
279
+ }
280
+ activityMonitor.logComplete(activityId, 200);
281
+ return response;
282
+ } catch (err) {
283
+ const message = errorMessage(err);
284
+ const redactedMessage = redactCredential(message, apiKey);
285
+ if (options.signal?.aborted)
286
+ activityMonitor.logComplete(activityId, 0);
287
+ else
288
+ activityMonitor.logError(activityId, redactedMessage);
289
+ if (redactedMessage === message)
290
+ throw err;
291
+ const redactedError = new Error(redactedMessage);
292
+ if (err instanceof Error)
293
+ redactedError.name = err.name;
294
+ throw redactedError;
295
+ }
296
+ }
297
+ export async function extractWithQuerit(url, signal, options = {}) {
298
+ const apiKey = await getApiKey(signal);
299
+ const activityId = activityMonitor.logStart({ type: "fetch", url });
300
+ try {
301
+ const data = await fetchContentsBatch([url], apiKey, signal, options);
302
+ const result = findContentResult(data, url, 0, 1);
303
+ const mapped = mapContentResult(result, url);
304
+ if (mapped) {
305
+ activityMonitor.logComplete(activityId, 200);
306
+ return mapped;
307
+ }
308
+ if (failedContentStatus(data, result, 0)) {
309
+ throw new Error(`Querit Contents API failed to crawl ${url}`);
310
+ }
311
+ activityMonitor.logComplete(activityId, 200);
312
+ return null;
313
+ } catch (err) {
314
+ const message = errorMessage(err);
315
+ const redactedMessage = redactCredential(message, apiKey);
316
+ if (signal?.aborted)
317
+ activityMonitor.logComplete(activityId, 0);
318
+ else
319
+ activityMonitor.logError(activityId, redactedMessage);
320
+ if (redactedMessage === message)
321
+ throw err;
322
+ const redactedError = new Error(redactedMessage);
323
+ if (err instanceof Error)
324
+ redactedError.name = err.name;
325
+ throw redactedError;
326
+ }
327
+ }
@@ -0,0 +1,40 @@
1
+ import { complete } from "@duckmind/dm-ai/compat";
2
+ import { findModelWithProviderRouting, loadEnabledModelPatterns, modelMatchesEnabledPatterns } from "./summary-model-scope.js";
3
+ async function resolveFirstAvailableModel(ctx, candidates) {
4
+ const enabledModelPatterns = loadEnabledModelPatterns(ctx);
5
+ for (const { provider, id } of candidates) {
6
+ const model = findModelWithProviderRouting(ctx.modelRegistry, provider, id);
7
+ if (!model || !modelMatchesEnabledPatterns(model, enabledModelPatterns))
8
+ continue;
9
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
10
+ if (auth.ok && auth.apiKey)
11
+ return { model, apiKey: auth.apiKey, headers: auth.headers };
12
+ }
13
+ throw new Error(`No enabled model available: ${candidates.map((candidate) => `${candidate.provider}/${candidate.id}`).join(", ")}`);
14
+ }
15
+ export async function rewriteSearchQuery(query, ctx, signal) {
16
+ const { model, apiKey, headers } = await resolveFirstAvailableModel(ctx, [
17
+ { provider: "anthropic", id: "claude-haiku-4-5" },
18
+ { provider: "google", id: "gemini-3.6-flash" },
19
+ { provider: "openai", id: "gpt-5-mini" }
20
+ ]);
21
+ const registry = ctx.modelRegistry;
22
+ const usesRegistryComplete = typeof registry.complete === "function";
23
+ const completeFn = usesRegistryComplete ? registry.complete.bind(registry) : complete;
24
+ const response = await completeFn(model, {
25
+ messages: [{
26
+ role: "user",
27
+ content: [{ type: "text", text: `Rewrite this web search query to get better, more specific results. Add relevant year qualifiers, precise technical terms, and specificity. Return ONLY the improved query text, nothing else.
28
+
29
+ Query: ${query}` }],
30
+ timestamp: Date.now()
31
+ }]
32
+ }, usesRegistryComplete ? { signal } : { apiKey, headers, signal });
33
+ if (response.stopReason === "aborted")
34
+ throw new Error("Aborted");
35
+ const contentParts = Array.isArray(response.content) ? response.content : [];
36
+ const text = contentParts.map((part) => part && typeof part === "object" && part.type === "text" && typeof part.text === "string" ? part.text : "").join("").trim();
37
+ if (!text)
38
+ throw new Error("Rewrite returned empty response");
39
+ return text;
40
+ }
@@ -0,0 +1,80 @@
1
+ function truncate(text, max) {
2
+ return text.length > max ? text.slice(0, max - 1) + "…" : text;
3
+ }
4
+ export function buildSearchErrorPlan(details) {
5
+ if (!details || !details.error && !details.cancelled) {
6
+ return null;
7
+ }
8
+ const headline = details.error ?? "Search cancelled.";
9
+ const queries = details.cancelledQueries ?? [];
10
+ const queryCount = typeof details.queryCount === "number" && details.queryCount > 0 ? details.queryCount : queries.length;
11
+ const done = queries.length;
12
+ const errored = queries.filter((q) => q.error).length;
13
+ const extras = details.extraLines ?? [];
14
+ const rich = details.cancelled === true || queries.length > 0 || extras.length > 0;
15
+ if (!rich) {
16
+ return { expanded: [headline], collapsed: [], expandHint: null };
17
+ }
18
+ const expanded = [headline, ""];
19
+ if (details.cancelled === true || queries.length > 0) {
20
+ const diag = [];
21
+ if (details.cancelled) {
22
+ diag.push(`cancel reason : ${details.cancelReason ?? "unknown"}`);
23
+ }
24
+ const browserLabel = details.browserConnected === undefined ? "unknown" : details.browserConnected ? "connected" : "never connected";
25
+ diag.push(`browser : ${browserLabel}`);
26
+ if (typeof details.lastHeartbeatAgeMs === "number" && Number.isFinite(details.lastHeartbeatAgeMs)) {
27
+ diag.push(`last heartbeat : ${Math.round(details.lastHeartbeatAgeMs / 1000)}s ago`);
28
+ }
29
+ if (queryCount > 0) {
30
+ diag.push(`queries started : ${queryCount}`);
31
+ diag.push(`queries done : ${done}`);
32
+ if (errored > 0)
33
+ diag.push(`queries errored : ${errored}`);
34
+ }
35
+ expanded.push("Diagnostics:");
36
+ for (const line of diag)
37
+ expanded.push(` ${line}`);
38
+ }
39
+ if (queries.length > 0) {
40
+ expanded.push("");
41
+ expanded.push("Per-query results (gathered before cancel):");
42
+ for (const q of queries) {
43
+ const dq = truncate(q.query, 52);
44
+ const tag = q.error ? "[err] " : "[ok] ";
45
+ const provider = q.provider ? ` (${q.provider})` : "";
46
+ const tail = q.error ? `— ${truncate(q.error, 60)}` : `— ${q.resultCount} source${q.resultCount === 1 ? "" : "s"}`;
47
+ expanded.push(` ${tag}"${dq}"${provider} ${tail}`);
48
+ }
49
+ }
50
+ if (extras.length > 0) {
51
+ expanded.push("");
52
+ expanded.push("Details:");
53
+ for (const e of extras)
54
+ expanded.push(` ${e}`);
55
+ }
56
+ const collapsed = [];
57
+ const parts = [];
58
+ if (queryCount > 0) {
59
+ parts.push(`${done}/${queryCount} queries completed`);
60
+ }
61
+ if (errored > 0) {
62
+ parts.push(`${errored} errored`);
63
+ }
64
+ if (details.browserConnected === false) {
65
+ parts.push("browser never connected");
66
+ } else if (details.cancelReason) {
67
+ parts.push(`reason: ${details.cancelReason}`);
68
+ }
69
+ if (parts.length > 0) {
70
+ collapsed.push(parts.join("; ") + ".");
71
+ }
72
+ if (collapsed.length === 0 && extras.length > 0) {
73
+ for (const e of extras.slice(0, 2)) {
74
+ collapsed.push(truncate(e, 100));
75
+ }
76
+ }
77
+ const hiddenLines = Math.max(0, expanded.length - (1 + collapsed.length));
78
+ const expandHint = hiddenLines > 0 ? `... (${hiddenLines} more lines, ${expanded.length} total, ctrl+o to expand)` : null;
79
+ return { expanded, collapsed, expandHint };
80
+ }