@ashx-j/lunr 0.2.2 → 0.2.3
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/dist/builtin-extensions/pi-web-access/index.d.ts.map +1 -1
- package/dist/builtin-extensions/pi-web-access/index.js +18 -13
- package/dist/builtin-extensions/pi-web-access/index.js.map +1 -1
- package/dist/builtin-extensions/pi-web-access/render-search-chrome.d.ts +2 -0
- package/dist/builtin-extensions/pi-web-access/render-search-chrome.d.ts.map +1 -1
- package/dist/builtin-extensions/pi-web-access/render-search-chrome.js +12 -0
- package/dist/builtin-extensions/pi-web-access/render-search-chrome.js.map +1 -1
- package/dist/core/extensions/types.d.ts +4 -0
- package/dist/core/extensions/types.d.ts.map +1 -1
- package/dist/core/extensions/types.js.map +1 -1
- package/dist/core/tools/bash.d.ts.map +1 -1
- package/dist/core/tools/bash.js +14 -5
- package/dist/core/tools/bash.js.map +1 -1
- package/dist/core/tools/edit.d.ts.map +1 -1
- package/dist/core/tools/edit.js +13 -7
- package/dist/core/tools/edit.js.map +1 -1
- package/dist/core/tools/find.d.ts.map +1 -1
- package/dist/core/tools/find.js +10 -5
- package/dist/core/tools/find.js.map +1 -1
- package/dist/core/tools/grep.d.ts.map +1 -1
- package/dist/core/tools/grep.js +10 -5
- package/dist/core/tools/grep.js.map +1 -1
- package/dist/core/tools/ls.d.ts.map +1 -1
- package/dist/core/tools/ls.js +10 -3
- package/dist/core/tools/ls.js.map +1 -1
- package/dist/core/tools/read.d.ts.map +1 -1
- package/dist/core/tools/read.js +24 -15
- package/dist/core/tools/read.js.map +1 -1
- package/dist/core/tools/render-utils.d.ts +21 -0
- package/dist/core/tools/render-utils.d.ts.map +1 -1
- package/dist/core/tools/render-utils.js +32 -0
- package/dist/core/tools/render-utils.js.map +1 -1
- package/dist/core/tools/write.d.ts.map +1 -1
- package/dist/core/tools/write.js +19 -4
- package/dist/core/tools/write.js.map +1 -1
- package/dist/modes/interactive/components/tool-execution.d.ts.map +1 -1
- package/dist/modes/interactive/components/tool-execution.js +16 -4
- package/dist/modes/interactive/components/tool-execution.js.map +1 -1
- package/examples/extensions/custom-provider-anthropic/package-lock.json +2 -2
- package/examples/extensions/custom-provider-anthropic/package.json +1 -1
- package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
- package/examples/extensions/gondolin/package-lock.json +2 -2
- package/examples/extensions/gondolin/package.json +1 -1
- package/examples/extensions/sandbox/package-lock.json +2 -2
- package/examples/extensions/sandbox/package.json +1 -1
- package/examples/extensions/with-deps/package-lock.json +2 -2
- package/examples/extensions/with-deps/package.json +1 -1
- package/package.json +4 -4
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/builtin-extensions/pi-web-access/index.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,YAAY,EAAoB,MAAM,iCAAiC,CAAC;AA4iBtF,MAAM,CAAC,OAAO,WAAW,EAAE,EAAE,YAAY,QA60DxC","sourcesContent":["// @ts-nocheck\nimport type { ExtensionAPI, ExtensionContext } from \"@ashx-j/lunr\";\nimport { Box, Text, truncateToWidth } from \"@ashx-j/lunr-tui\";\nimport { Type } from \"typebox\";\nimport { StringEnum, complete, type Model } from \"@ashx-j/lunr-ai/compat\";\nimport { fetchAllContent, type ExtractedContent } from \"./extract.ts\";\nimport { normalizeFetchContentParams } from \"./fetch-params.ts\";\nimport { clearCloneCache } from \"./github-extract.ts\";\nimport { search, type SearchProvider, type ResolvedSearchProvider } from \"./gemini-search.ts\";\nimport type { SearchResult } from \"./perplexity.ts\";\nimport { getWebSearchConfigDir, getWebSearchConfigPath } from \"./utils.ts\";\nimport {\n\tclearResults,\n\tdeleteResult,\n\tgenerateId,\n\tgetAllResults,\n\tgetResult,\n\trestoreFromSession,\n\tstoreResult,\n\ttype QueryResultData,\n\ttype StoredSearchData,\n} from \"./storage.ts\";\nimport { activityMonitor, type ActivityEntry } from \"./activity.ts\";\nimport { startCuratorServer, type CuratorServerHandle } from \"./curator-server.ts\";\nimport {\n\tbuildDeterministicSummary,\n\tgenerateSummaryDraft,\n\ttype SummaryGenerationContext,\n\ttype SummaryMeta,\n} from \"./summary-review.ts\";\nimport { randomUUID } from \"node:crypto\";\nimport { execFileSync } from \"node:child_process\";\nimport { createRequire } from \"node:module\";\nimport { platform } from \"node:os\";\nimport { existsSync, readFileSync, writeFileSync, mkdirSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport { isPerplexityAvailable } from \"./perplexity.ts\";\nimport { isExaAvailable } from \"./exa.ts\";\nimport { isGeminiApiAvailable } from \"./gemini-api.ts\";\nimport { getActiveGoogleEmail, isGeminiWebAvailable } from \"./gemini-web.ts\";\nimport { isBrowserCookieAccessAllowed } from \"./gemini-web-config.ts\";\nimport { isBraveAvailable } from \"./brave.ts\";\nimport { isOpenAISearchAvailable } from \"./openai-search.ts\";\nimport { isParallelAvailable } from \"./parallel.ts\";\nimport { isTavilyAvailable } from \"./tavily.ts\";\nimport { buildSearchErrorPlan, type SearchErrorDetails, type SearchErrorPlan } from \"./render-search-error.ts\";\nimport {\n\tcollectFetchUrls,\n\tcollectSearchQueries,\n\tformatFetchCallTitle,\n\tformatFetchChrome,\n\tformatSearchCallTitle,\n\tformatSearchChrome,\n} from \"./render-search-chrome.ts\";\nimport { loadEnabledModelPatterns, modelMatchesEnabledPatterns } from \"./summary-model-scope.ts\";\n\nconst WEB_SEARCH_CONFIG_PATH = getWebSearchConfigPath();\n\n/** Shared collapsed/expanded renderer for an error/cancel plan produced by\n * buildSearchErrorPlan(). Used by every tool renderResult's error branch so\n * Ctrl+O (app.tools.expand) reveals diagnostics instead of a dead-end single line. */\nfunction renderSearchErrorPlan(plan: SearchErrorPlan, expanded: boolean, theme: { fg: (key: string, s: string) => string; bg: (key: string, s: string) => string }) {\n\tif (expanded) {\n\t\treturn new Text(plan.expanded.map((l, i) => i === 0 ? theme.fg(\"error\", l) : theme.fg(\"toolOutput\", l)).join(\"\\n\"), 0, 0);\n\t}\n\tconst box = new Box(1, 0, (t) => theme.bg(\"toolErrorBg\", t));\n\tbox.addChild(new Text(theme.fg(\"error\", plan.expanded[0]), 0, 0));\n\tfor (const line of plan.collapsed) {\n\t\tbox.addChild(new Text(theme.fg(\"dim\", line), 0, 0));\n\t}\n\tif (plan.expandHint) {\n\t\tbox.addChild(new Text(theme.fg(\"muted\", plan.expandHint), 0, 0));\n\t}\n\treturn box;\n}\n\ninterface WebSearchConfig {\n\tprovider?: string;\n\tworkflow?: string;\n\tcuratorTimeoutSeconds?: unknown;\n\tsummaryModel?: string;\n\twebSearch?: {\n\t\tenabled?: boolean;\n\t};\n\tshortcuts?: {\n\t\tcurate?: string;\n\t\tactivity?: string;\n\t};\n\tssrf?: {\n\t\t/** CIDR ranges exempted from the SSRF guard (e.g. fake-IP proxy ranges). */\n\t\tallowRanges?: string[];\n\t};\n}\n\ninterface ProviderAvailability {\n\topenai: boolean;\n\tbrave: boolean;\n\tparallel: boolean;\n\ttavily: boolean;\n\tperplexity: boolean;\n\texa: boolean;\n\tgemini: boolean;\n}\n\ntype WebSearchWorkflow = \"none\" | \"summary-review\" | \"auto-summary\";\ntype CuratorWorkflow = \"summary-review\";\ntype SummaryWorkflow = \"summary-review\" | \"auto-summary\";\n\ninterface CuratorBootstrap {\n\tavailableProviders: ProviderAvailability;\n\tdefaultProvider: ResolvedSearchProvider;\n\ttimeoutSeconds: number;\n}\n\nfunction loadConfig(): WebSearchConfig {\n\tif (!existsSync(WEB_SEARCH_CONFIG_PATH)) return {};\n\tconst raw = readFileSync(WEB_SEARCH_CONFIG_PATH, \"utf-8\");\n\ttry {\n\t\treturn JSON.parse(raw) as WebSearchConfig;\n\t} catch (err) {\n\t\tconst message = err instanceof Error ? err.message : String(err);\n\t\tthrow new Error(`Failed to parse ${WEB_SEARCH_CONFIG_PATH}: ${message}`);\n\t}\n}\n\nfunction saveConfig(updates: Partial<WebSearchConfig>): void {\n\tlet config: Record<string, unknown> = {};\n\tif (existsSync(WEB_SEARCH_CONFIG_PATH)) {\n\t\tconst raw = readFileSync(WEB_SEARCH_CONFIG_PATH, \"utf-8\");\n\t\ttry {\n\t\t\tconfig = JSON.parse(raw) as Record<string, unknown>;\n\t\t} catch (err) {\n\t\t\tconst message = err instanceof Error ? err.message : String(err);\n\t\t\tthrow new Error(`Failed to parse ${WEB_SEARCH_CONFIG_PATH}: ${message}`);\n\t\t}\n\t}\n\n\tObject.assign(config, updates);\n\tconst dir = getWebSearchConfigDir();\n\tif (!existsSync(dir)) mkdirSync(dir, { recursive: true });\n\twriteFileSync(WEB_SEARCH_CONFIG_PATH, JSON.stringify(config, null, 2) + \"\\n\");\n}\n\nconst DEFAULT_SHORTCUTS = { curate: \"ctrl+shift+s\", activity: \"ctrl+shift+w\" };\nconst DEFAULT_CURATOR_TIMEOUT_SECONDS = 20;\nconst MAX_CURATOR_TIMEOUT_SECONDS = 600;\n\nfunction loadConfigForExtensionInit(): WebSearchConfig {\n\ttry {\n\t\treturn loadConfig();\n\t} catch (err) {\n\t\tconst message = err instanceof Error ? err.message : String(err);\n\t\tconsole.error(`[pi-web-access] ${message}`);\n\t\treturn {};\n\t}\n}\n\nfunction normalizeProviderInput(value: unknown): SearchProvider | undefined {\n\tif (value === undefined) return undefined;\n\tif (typeof value !== \"string\") return \"auto\";\n\tconst normalized = value.trim().toLowerCase();\n\tconst valid: SearchProvider[] = [\"auto\", \"openai\", \"brave\", \"parallel\", \"tavily\", \"exa\", \"perplexity\", \"gemini\"];\n\treturn valid.includes(normalized as SearchProvider) ? normalized as SearchProvider : \"auto\";\n}\n\nfunction normalizeCuratorTimeoutSeconds(value: unknown): number | undefined {\n\tif (typeof value !== \"number\" || !Number.isFinite(value)) return undefined;\n\tconst normalized = Math.floor(value);\n\tif (normalized < 1) return undefined;\n\treturn Math.min(normalized, MAX_CURATOR_TIMEOUT_SECONDS);\n}\n\nfunction resolveWorkflow(input: unknown, hasUI: boolean): WebSearchWorkflow {\n\tconst normalized = typeof input === \"string\" ? input.trim().toLowerCase() : \"\";\n\tif (normalized === \"auto-summary\") return \"auto-summary\";\n\tif (!hasUI) return \"none\";\n\tif (normalized === \"none\") return \"none\";\n\treturn \"summary-review\";\n}\n\nfunction normalizeQueryList(queryList: unknown[]): string[] {\n\treturn collectSearchQueries({ queries: queryList });\n}\n\nfunction getCuratorTimeoutSeconds(): number {\n\tconst source = loadConfig();\n\treturn normalizeCuratorTimeoutSeconds(source.curatorTimeoutSeconds) ?? DEFAULT_CURATOR_TIMEOUT_SECONDS;\n}\n\nasync function getProviderAvailability(ctx: ExtensionContext): Promise<ProviderAvailability> {\n\tconst geminiWebAvail = await isGeminiWebAvailable();\n\treturn {\n\t\topenai: await isOpenAISearchAvailable(ctx),\n\t\tbrave: isBraveAvailable(),\n\t\tparallel: isParallelAvailable(),\n\t\ttavily: isTavilyAvailable(),\n\t\tperplexity: isPerplexityAvailable(),\n\t\texa: isExaAvailable(),\n\t\tgemini: isGeminiApiAvailable() || !!geminiWebAvail,\n\t};\n}\n\nfunction shouldPreferOpenAI(options?: Pick<PendingCurate, \"numResults\" | \"recencyFilter\">): boolean {\n\tif (!options) return true;\n\tif (options.recencyFilter) return false;\n\tif (typeof options.numResults === \"number\" && Number.isFinite(options.numResults) && Math.floor(options.numResults) !== 5) {\n\t\treturn false;\n\t}\n\treturn true;\n}\n\nasync function loadCuratorBootstrap(\n\trequestedProvider: unknown,\n\tctx: ExtensionContext,\n\toptions?: Pick<PendingCurate, \"numResults\" | \"recencyFilter\">,\n): Promise<CuratorBootstrap> {\n\tconst availableProviders = await getProviderAvailability(ctx);\n\treturn {\n\t\tavailableProviders,\n\t\tdefaultProvider: resolveProvider(requestedProvider, availableProviders, options),\n\t\ttimeoutSeconds: getCuratorTimeoutSeconds(),\n\t};\n}\n\nfunction firstAvailableProvider(available: ProviderAvailability, preferOpenAI: boolean, fallback: ResolvedSearchProvider): ResolvedSearchProvider {\n\tif (preferOpenAI && available.openai) return \"openai\";\n\tif (available.exa) return \"exa\";\n\tif (available.brave) return \"brave\";\n\tif (available.parallel) return \"parallel\";\n\tif (available.tavily) return \"tavily\";\n\tif (available.perplexity) return \"perplexity\";\n\tif (available.gemini) return \"gemini\";\n\treturn fallback;\n}\n\nfunction resolveProvider(\n\trequested: unknown,\n\tavailable: ProviderAvailability,\n\toptions?: Pick<PendingCurate, \"numResults\" | \"recencyFilter\">,\n): ResolvedSearchProvider {\n\tconst provider = normalizeProviderInput(requested ?? loadConfig().provider ?? \"auto\") ?? \"auto\";\n\tconst preferOpenAI = shouldPreferOpenAI(options);\n\n\tif (provider === \"auto\") {\n\t\treturn firstAvailableProvider(available, preferOpenAI, \"exa\");\n\t}\n\tif (provider === \"openai\" && !available.openai) {\n\t\treturn firstAvailableProvider(available, false, \"openai\");\n\t}\n\tif (provider === \"brave\" && !available.brave) {\n\t\treturn firstAvailableProvider(available, preferOpenAI, \"brave\");\n\t}\n\tif (provider === \"parallel\" && !available.parallel) {\n\t\treturn firstAvailableProvider(available, preferOpenAI, \"parallel\");\n\t}\n\tif (provider === \"tavily\" && !available.tavily) {\n\t\treturn firstAvailableProvider(available, preferOpenAI, \"tavily\");\n\t}\n\tif (provider === \"exa\" && !available.exa) {\n\t\treturn firstAvailableProvider(available, preferOpenAI, \"exa\");\n\t}\n\tif (provider === \"perplexity\" && !available.perplexity) {\n\t\treturn firstAvailableProvider(available, preferOpenAI, \"perplexity\");\n\t}\n\tif (provider === \"gemini\" && !available.gemini) {\n\t\treturn firstAvailableProvider(available, preferOpenAI, \"gemini\");\n\t}\n\treturn provider;\n}\n\nconst pendingFetches = new Map<string, AbortController>();\nlet sessionActive = false;\nlet widgetVisible = false;\nlet widgetUnsubscribe: (() => void) | null = null;\nconst pendingCurates = new Map<string, PendingCurate>();\nconst activeCurators = new Map<string, CuratorServerHandle>();\nconst glimpseWins = new Map<string, GlimpseWindow>();\n\ninterface PendingCurate {\n\tphase: \"searching\" | \"curating\";\n\tworkflow: CuratorWorkflow;\n\tsummaryContext: SummaryGenerationContext;\n\tsearchResults: Map<number, QueryResultData>;\n\tallInlineContent: ExtractedContent[];\n\tqueryList: string[];\n\tincludeContent: boolean;\n\tnumResults?: number;\n\trecencyFilter?: \"day\" | \"week\" | \"month\" | \"year\";\n\tdomainFilter?: string[];\n\tavailableProviders: ProviderAvailability;\n\tdefaultProvider: ResolvedSearchProvider;\n\tsearchProvider: SearchProvider;\n\tsummaryModels: Array<{ value: string; label: string }>;\n\tdefaultSummaryModel: string | null;\n\ttimeoutSeconds: number;\n\tcuratorUrl?: string;\n\tonUpdate: ((update: { content: Array<{ type: string; text: string }>; details?: Record<string, unknown> }) => void) | undefined;\n\tsignal: AbortSignal | undefined;\n\tabortSearches: () => void;\n\tfinish: (value: unknown) => void;\n\tcancel: (reason?: \"user\" | \"stale\") => void;\n\tbrowserPromise?: Promise<void>;\n\tbrowserOpenError?: string;\n}\n\n\nconst MAX_INLINE_CONTENT = 30000; // Content returned directly to agent\n\nfunction stripThumbnails(results: ExtractedContent[]): ExtractedContent[] {\n\treturn results.map(({ thumbnail, frames, ...rest }) => rest);\n}\n\nfunction formatSearchSummary(results: SearchResult[], answer: string): string {\n\tlet output = answer ? `${answer}\\n\\n---\\n\\n**Sources:**\\n` : \"\";\n\toutput += results.map((r, i) => `${i + 1}. ${r.title}\\n ${r.url}`).join(\"\\n\\n\");\n\treturn output;\n}\n\nfunction duplicateQuerySet(results: QueryResultData[]): Set<string> {\n\tconst counts = new Map<string, number>();\n\tfor (const result of results) {\n\t\tcounts.set(result.query, (counts.get(result.query) ?? 0) + 1);\n\t}\n\tconst duplicates = new Set<string>();\n\tfor (const [query, count] of counts) {\n\t\tif (count > 1) duplicates.add(query);\n\t}\n\treturn duplicates;\n}\n\nfunction formatQueryHeader(query: string, provider: string | undefined, duplicateQueries: Set<string>): string {\n\tconst suffix = duplicateQueries.has(query) && provider ? ` (${provider})` : \"\";\n\treturn `## Query: \"${query}\"${suffix}\\n\\n`;\n}\n\nfunction hasFullInlineCoverage(urls: string[], inlineContent: ExtractedContent[] | undefined): boolean {\n\tif (!inlineContent || inlineContent.length === 0) return false;\n\tconst coveredUrls = new Set(inlineContent.map(c => c.url));\n\treturn urls.every(url => coveredUrls.has(url));\n}\n\nfunction formatFullResults(queryData: QueryResultData): string {\n\tlet output = `## Results for: \"${queryData.query}\"\\n\\n`;\n\tif (queryData.answer) {\n\t\toutput += `${queryData.answer}\\n\\n---\\n\\n`;\n\t}\n\tfor (const r of queryData.results) {\n\t\toutput += `### ${r.title}\\n${r.url}\\n\\n`;\n\t}\n\treturn output;\n}\n\nfunction abortPendingFetches(): void {\n\tfor (const controller of pendingFetches.values()) {\n\t\tcontroller.abort();\n\t}\n\tpendingFetches.clear();\n}\n\nfunction closeCurator(callId?: string): void {\n\tif (callId !== undefined) {\n\t\tconst win = glimpseWins.get(callId);\n\t\tglimpseWins.delete(callId);\n\t\ttry { win?.close(); } catch {}\n\t\tpendingCurates.get(callId)?.cancel(\"stale\");\n\t\tpendingCurates.delete(callId);\n\t\tconst curator = activeCurators.get(callId);\n\t\tactiveCurators.delete(callId);\n\t\ttry { curator?.close(); } catch {}\n\t\treturn;\n\t}\n\n\tfor (const win of glimpseWins.values()) {\n\t\ttry { win.close(); } catch {}\n\t}\n\tglimpseWins.clear();\n\tfor (const pc of pendingCurates.values()) {\n\t\ttry { pc.cancel(\"stale\"); } catch {}\n\t}\n\tpendingCurates.clear();\n\tfor (const curator of activeCurators.values()) {\n\t\ttry { curator.close(); } catch {}\n\t}\n\tactiveCurators.clear();\n}\n\nasync function openInBrowser(pi: ExtensionAPI, url: string): Promise<void> {\n\tconst plat = platform();\n\tconst result = plat === \"darwin\"\n\t\t? await pi.exec(\"open\", [url])\n\t\t: plat === \"win32\"\n\t\t\t? await pi.exec(\"cmd\", [\"/c\", \"start\", \"\", url])\n\t\t\t: await pi.exec(\"xdg-open\", [url]);\n\tif (result.code !== 0) {\n\t\tthrow new Error(result.stderr || `Failed to open browser (exit code ${result.code})`);\n\t}\n}\n\ninterface GlimpseWindow {\n\ton(event: \"closed\", handler: () => void): void;\n\ton(event: \"message\", handler: (data: unknown) => void): void;\n\ton(event: \"ready\", handler: (info: { screen?: { visibleHeight?: number } }) => void): void;\n\tclose(): void;\n\t_write(obj: Record<string, unknown>): void;\n}\n\nlet glimpseOpen: ((html: string, opts: Record<string, unknown>) => GlimpseWindow) | null | undefined;\n\nfunction findGlimpseMjs(): string | null {\n\ttry {\n\t\tconst req = createRequire(import.meta.url);\n\t\treturn req.resolve(\"glimpseui\");\n\t} catch {\n\t\t// Optional dependency.\n\t}\n\ttry {\n\t\tconst globalRoot = execFileSync(\"npm\", [\"root\", \"-g\"], { encoding: \"utf-8\" }).trim();\n\t\tconst entry = join(globalRoot, \"glimpseui\", \"src\", \"glimpse.mjs\");\n\t\tif (existsSync(entry)) return entry;\n\t} catch {\n\t\t// npm may be unavailable.\n\t}\n\treturn null;\n}\n\nasync function getGlimpseOpen() {\n\tif (glimpseOpen !== undefined) return glimpseOpen;\n\tconst resolved = findGlimpseMjs();\n\tif (resolved) {\n\t\ttry {\n\t\t\tglimpseOpen = (await import(resolved)).open;\n\t\t\treturn glimpseOpen;\n\t\t} catch {}\n\t}\n\tglimpseOpen = null;\n\treturn glimpseOpen;\n}\n\nfunction openInGlimpse(\n\topen: (html: string, opts: Record<string, unknown>) => GlimpseWindow,\n\turl: string,\n\ttitle: string,\n): GlimpseWindow {\n\tconst shellHTML = `<!DOCTYPE html>\n<html>\n<head><meta charset=\"UTF-8\"><title>${title}</title></head>\n<body style=\"margin:0; background:#1a1a2e;\">\n <script>window.location.replace(${JSON.stringify(url)});</script>\n</body>\n</html>`;\n\tconst win = open(shellHTML, {\n\t\twidth: 800,\n\t\theight: 900,\n\t\ttitle,\n\t});\n\n\tlet maxHeight = 1200;\n\twin.on(\"ready\", (info) => {\n\t\tconst visibleHeight = info?.screen?.visibleHeight;\n\t\tif (typeof visibleHeight === \"number\" && visibleHeight > 0) {\n\t\t\tmaxHeight = Math.floor(visibleHeight * 0.85);\n\t\t}\n\t});\n\twin.on(\"message\", (data) => {\n\t\tif (!data || typeof data !== \"object\") return;\n\t\tconst msg = data as Record<string, unknown>;\n\t\tif (msg.type !== \"resize\" || typeof msg.height !== \"number\") return;\n\t\tconst clamped = Math.max(400, Math.min(Math.round(msg.height), maxHeight));\n\t\twin._write({ type: \"resize\", width: 800, height: clamped });\n\t});\n\n\treturn win;\n}\n\nfunction extractDomain(url: string): string {\n\ttry { return new URL(url).hostname; }\n\tcatch { return url; }\n}\n\nfunction updateWidget(ctx: ExtensionContext): void {\n\tconst theme = ctx.ui.theme;\n\tconst entries = activityMonitor.getEntries();\n\tconst lines: string[] = [];\n\n\tlines.push(theme.fg(\"accent\", \"─── Web Search Activity \" + \"─\".repeat(36)));\n\n\tif (entries.length === 0) {\n\t\tlines.push(theme.fg(\"muted\", \" No activity yet\"));\n\t} else {\n\t\tfor (const e of entries) {\n\t\t\tlines.push(\" \" + formatEntryLine(e, theme));\n\t\t}\n\t}\n\n\tlines.push(theme.fg(\"accent\", \"─\".repeat(60)));\n\n\tconst rateInfo = activityMonitor.getRateLimitInfo();\n\tconst resetMs = rateInfo.oldestTimestamp ? Math.max(0, rateInfo.oldestTimestamp + rateInfo.windowMs - Date.now()) : 0;\n\tconst resetSec = Math.ceil(resetMs / 1000);\n\tlines.push(\n\t\ttheme.fg(\"muted\", `Rate: ${rateInfo.used}/${rateInfo.max}`) +\n\t\t\t(resetMs > 0 ? theme.fg(\"dim\", ` (resets in ${resetSec}s)`) : \"\"),\n\t);\n\n\tctx.ui.setWidget(\"web-activity\", new Text(lines.join(\"\\n\"), 0, 0));\n}\n\nfunction formatEntryLine(\n\tentry: ActivityEntry,\n\ttheme: { fg: (color: string, text: string) => string },\n): string {\n\tconst typeStr = entry.type === \"api\" ? \"API\" : \"GET\";\n\tconst target =\n\t\tentry.type === \"api\"\n\t\t\t? `\"${truncateToWidth(entry.query || \"\", 28, \"\")}\"`\n\t\t\t: truncateToWidth(entry.url?.replace(/^https?:\\/\\//, \"\") || \"\", 30, \"\");\n\n\tconst duration = entry.endTime\n\t\t? `${((entry.endTime - entry.startTime) / 1000).toFixed(1)}s`\n\t\t: `${((Date.now() - entry.startTime) / 1000).toFixed(1)}s`;\n\n\tlet statusStr: string;\n\tlet indicator: string;\n\tif (entry.error) {\n\t\tstatusStr = \"err\";\n\t\tindicator = theme.fg(\"error\", \"✗\");\n\t} else if (entry.status === null) {\n\t\tstatusStr = \"...\";\n\t\tindicator = theme.fg(\"warning\", \"⋯\");\n\t} else if (entry.status === 0) {\n\t\tstatusStr = \"abort\";\n\t\tindicator = theme.fg(\"muted\", \"○\");\n\t} else {\n\t\tstatusStr = String(entry.status);\n\t\tindicator = entry.status >= 200 && entry.status < 300 ? theme.fg(\"success\", \"✓\") : theme.fg(\"error\", \"✗\");\n\t}\n\n\treturn `${typeStr.padEnd(4)} ${target.padEnd(32)} ${statusStr.padStart(5)} ${duration.padStart(5)} ${indicator}`;\n}\n\nfunction handleSessionChange(ctx: ExtensionContext): void {\n\tabortPendingFetches();\n\tcloseCurator();\n\tclearCloneCache();\n\tsessionActive = true;\n\trestoreFromSession(ctx);\n\t// Unsubscribe before clear() to avoid callback with stale ctx\n\twidgetUnsubscribe?.();\n\twidgetUnsubscribe = null;\n\tactivityMonitor.clear();\n\tif (widgetVisible) {\n\t\t// Re-subscribe with new ctx\n\t\twidgetUnsubscribe = activityMonitor.onUpdate(() => updateWidget(ctx));\n\t\tupdateWidget(ctx);\n\t}\n}\n\nexport default function (pi: ExtensionAPI) {\n\tconst initConfig = loadConfigForExtensionInit();\n\tconst curateKey = initConfig.shortcuts?.curate || DEFAULT_SHORTCUTS.curate;\n\tconst activityKey = initConfig.shortcuts?.activity || DEFAULT_SHORTCUTS.activity;\n\n\t// lunr: expose curator workflow state to core (/settings \"Extensions\" submenu)\n\t// so the /settings toggle writes through the same web-search.json saveConfig\n\t// path as /curator — single source of truth, no forked state.\n\t(globalThis as Record<symbol, unknown>)[Symbol.for(\"@lunr/search-curator\")] = {\n\t\tgetWorkflow(): WebSearchWorkflow {\n\t\t\treturn resolveWorkflow(loadConfigForExtensionInit().workflow, true);\n\t\t},\n\t\tsetWorkflow(workflow: WebSearchWorkflow): void {\n\t\t\tsaveConfig({ workflow });\n\t\t},\n\t};\n\n\tfunction startBackgroundFetch(urls: string[]): string | null {\n\t\tif (urls.length === 0) return null;\n\t\tconst fetchId = generateId();\n\t\tconst controller = new AbortController();\n\t\tpendingFetches.set(fetchId, controller);\n\t\tfetchAllContent(urls, controller.signal)\n\t\t\t.then((fetched) => {\n\t\t\t\tif (!sessionActive || !pendingFetches.has(fetchId)) return;\n\t\t\t\tconst data: StoredSearchData = {\n\t\t\t\t\tid: fetchId,\n\t\t\t\t\ttype: \"fetch\",\n\t\t\t\t\ttimestamp: Date.now(),\n\t\t\t\t\turls: stripThumbnails(fetched),\n\t\t\t\t};\n\t\t\t\tstoreResult(fetchId, data);\n\t\t\t\tpi.appendEntry(\"web-search-results\", data);\n\t\t\t\tconst ok = fetched.filter(f => !f.error).length;\n\t\t\t\tpi.sendMessage(\n\t\t\t\t\t{\n\t\t\t\t\t\tcustomType: \"web-search-content-ready\",\n\t\t\t\t\t\tcontent: `Content fetched for ${ok}/${fetched.length} URLs [${fetchId}]. Full page content now available.`,\n\t\t\t\t\t\tdisplay: true,\n\t\t\t\t\t},\n\t\t\t\t\t{ triggerTurn: true },\n\t\t\t\t);\n\t\t\t})\n\t\t\t.catch((err) => {\n\t\t\t\tif (!sessionActive || !pendingFetches.has(fetchId)) return;\n\t\t\t\tconst message = err instanceof Error ? err.message : String(err);\n\t\t\t\tconst isAbort = (err instanceof Error && err.name === \"AbortError\") || message.toLowerCase().includes(\"abort\");\n\t\t\t\tif (!isAbort) {\n\t\t\t\t\tpi.sendMessage(\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tcustomType: \"web-search-error\",\n\t\t\t\t\t\t\tcontent: `Content fetch failed [${fetchId}]: ${message}`,\n\t\t\t\t\t\t\tdisplay: true,\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{ triggerTurn: false },\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t})\n\t\t\t.finally(() => { pendingFetches.delete(fetchId); });\n\t\treturn fetchId;\n\t}\n\n\tfunction storeAndPublishSearch(results: QueryResultData[]): string {\n\t\tconst id = generateId();\n\t\tconst data: StoredSearchData = {\n\t\t\tid, type: \"search\", timestamp: Date.now(), queries: results,\n\t\t};\n\t\tstoreResult(id, data);\n\t\tpi.appendEntry(\"web-search-results\", data);\n\t\treturn id;\n\t}\n\n\tinterface SearchReturnOptions {\n\t\tqueryList: string[];\n\t\tresults: QueryResultData[];\n\t\turls: string[];\n\t\tincludeContent: boolean;\n\t\tinlineContent?: ExtractedContent[];\n\t\tcurated?: boolean;\n\t\tcuratedFrom?: number;\n\t\tworkflow?: SummaryWorkflow;\n\t\tapprovedSummary?: string;\n\t\tsummaryMeta?: SummaryMeta;\n\t}\n\n\tfunction normalizeSummaryMeta(meta: SummaryMeta | undefined, summaryText: string): SummaryMeta {\n\t\tconst normalizedText = summaryText.trim();\n\t\tif (!meta) {\n\t\t\treturn {\n\t\t\t\tmodel: null,\n\t\t\t\tdurationMs: 0,\n\t\t\t\ttokenEstimate: normalizedText.length > 0 ? Math.max(1, Math.ceil(normalizedText.length / 4)) : 0,\n\t\t\t\tfallbackUsed: false,\n\t\t\t\tedited: false,\n\t\t\t};\n\t\t}\n\n\t\treturn {\n\t\t\tmodel: meta.model,\n\t\t\tdurationMs: Number.isFinite(meta.durationMs) && meta.durationMs >= 0 ? meta.durationMs : 0,\n\t\t\ttokenEstimate: Number.isFinite(meta.tokenEstimate) && meta.tokenEstimate >= 0\n\t\t\t\t? meta.tokenEstimate\n\t\t\t\t: (normalizedText.length > 0 ? Math.max(1, Math.ceil(normalizedText.length / 4)) : 0),\n\t\t\tfallbackUsed: meta.fallbackUsed === true,\n\t\t\tfallbackReason: meta.fallbackReason,\n\t\t\tedited: meta.edited === true,\n\t\t};\n\t}\n\n\tfunction buildCurationCancelledReturn(\n\t\treason: \"user\" | \"stale\",\n\t\tpartial?: {\n\t\t\tqueries?: QueryResultData[];\n\t\t\tqueryCount?: number;\n\t\t\tbrowserConnected?: boolean;\n\t\t\tlastHeartbeatAgeMs?: number | null;\n\t\t\tcuratorUrl?: string;\n\t\t\tbrowserOpenError?: string;\n\t\t},\n\t) {\n\t\tconst message = `Search curation cancelled (${reason}).`;\n\t\tconst cancelledQueries = partial?.queries?.length\n\t\t\t? partial.queries.map(q => ({\n\t\t\t\tquery: q.query,\n\t\t\t\tprovider: q.provider ?? null,\n\t\t\t\terror: q.error,\n\t\t\t\tresultCount: q.results?.length ?? 0,\n\t\t\t}))\n\t\t\t: undefined;\n\t\tconst extraLines: string[] = [];\n\t\tif (partial?.curatorUrl) extraLines.push(`curator: ${partial.curatorUrl}`);\n\t\tif (partial?.browserOpenError) extraLines.push(`browser open error: ${partial.browserOpenError}`);\n\t\treturn {\n\t\t\tcontent: [{ type: \"text\", text: message }],\n\t\t\tdetails: {\n\t\t\t\terror: message,\n\t\t\t\tcancelled: true,\n\t\t\t\tcancelReason: reason,\n\t\t\t\tbrowserConnected: partial?.browserConnected,\n\t\t\t\tlastHeartbeatAgeMs: partial?.lastHeartbeatAgeMs,\n\t\t\t\tqueryCount: partial?.queryCount,\n\t\t\t\tcancelledQueries,\n\t\t\t\textraLines: extraLines.length > 0 ? extraLines : undefined,\n\t\t\t},\n\t\t};\n\t}\n\n\tasync function resolveFirstAvailableModel(\n\t\tctx: SummaryGenerationContext,\n\t\tcandidates: Array<{ provider: string; id: string }>,\n\t): Promise<{ model: Model; apiKey: string; headers?: Record<string, string> }> {\n\t\tconst enabledModelPatterns = loadEnabledModelPatterns(ctx);\n\t\tfor (const { provider, id } of candidates) {\n\t\t\tconst model = ctx.modelRegistry.find(provider, id);\n\t\t\tif (!model || !modelMatchesEnabledPatterns(model, enabledModelPatterns)) continue;\n\t\t\tconst auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);\n\t\t\tif (auth.ok && auth.apiKey) return { model, apiKey: auth.apiKey, headers: auth.headers };\n\t\t}\n\t\tthrow new Error(`No enabled model available: ${candidates.map(c => `${c.provider}/${c.id}`).join(\", \")}`);\n\t}\n\n\tasync function rewriteSearchQuery(query: string, ctx: SummaryGenerationContext, signal: AbortSignal): Promise<string> {\n\t\tconst { model, apiKey, headers } = await resolveFirstAvailableModel(ctx, [\n\t\t\t{ provider: \"anthropic\", id: \"claude-haiku-4-5\" },\n\t\t\t{ provider: \"google\", id: \"gemini-2.5-flash\" },\n\t\t\t{ provider: \"openai\", id: \"gpt-4.1-mini\" },\n\t\t]);\n\t\tconst response = await complete(\n\t\t\tmodel,\n\t\t\t{\n\t\t\t\tmessages: [{\n\t\t\t\t\trole: \"user\",\n\t\t\t\t\tcontent: [{ 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.\\n\\nQuery: ${query}` }],\n\t\t\t\t\ttimestamp: Date.now(),\n\t\t\t\t}],\n\t\t\t},\n\t\t\t{ apiKey, headers, signal },\n\t\t);\n\t\tif (response.stopReason === \"aborted\") throw new Error(\"Aborted\");\n\t\tconst contentParts = Array.isArray(response.content) ? response.content : [];\n\t\tconst text = contentParts\n\t\t\t.map(p => {\n\t\t\t\tif (!p || typeof p !== \"object\") return \"\";\n\t\t\t\tconst part = p as Record<string, unknown>;\n\t\t\t\treturn typeof part.text === \"string\" ? part.text : \"\";\n\t\t\t})\n\t\t\t.join(\"\")\n\t\t\t.trim();\n\t\tif (!text) throw new Error(\"Rewrite returned empty response\");\n\t\treturn text;\n\t}\n\n\tasync function generateSummaryForSelectedIndices(\n\t\tselectedQueryIndices: number[],\n\t\tresultsByIndex: Map<number, QueryResultData>,\n\t\tsummaryContext: SummaryGenerationContext,\n\t\tsignal?: AbortSignal,\n\t\tmodelOverride?: string,\n\t\tfeedback?: string,\n\t): Promise<{ summary: string; meta: SummaryMeta }> {\n\t\tconst selectedResults: QueryResultData[] = [];\n\t\tfor (const qi of selectedQueryIndices) {\n\t\t\tconst result = resultsByIndex.get(qi);\n\t\t\tif (result) selectedResults.push(result);\n\t\t}\n\t\tif (selectedResults.length === 0) {\n\t\t\tthrow new Error(\"No selected results available for summary generation\");\n\t\t}\n\t\ttry {\n\t\t\treturn await generateSummaryDraft(selectedResults, summaryContext, signal, modelOverride, feedback);\n\t\t} catch (err) {\n\t\t\tconst isEmptyResponse = err instanceof Error && err.message.includes(\"Summary model returned empty response\");\n\t\t\tif (!isEmptyResponse) throw err;\n\t\t\tconst deterministic = buildDeterministicSummary(selectedResults);\n\t\t\treturn {\n\t\t\t\tsummary: deterministic.summary,\n\t\t\t\tmeta: {\n\t\t\t\t\t...deterministic.meta,\n\t\t\t\t\tfallbackReason: \"summary-model-empty-response\",\n\t\t\t\t},\n\t\t\t};\n\t\t}\n\t}\n\n\tasync function loadSummaryModelChoices(\n\t\tsummaryContext: SummaryGenerationContext,\n\t): Promise<{ summaryModels: Array<{ value: string; label: string }>; defaultSummaryModel: string | null }> {\n\t\tconst summaryModels: Array<{ value: string; label: string }> = [];\n\t\tconst seen = new Set<string>();\n\t\tconst availableValues = new Set<string>();\n\n\t\tconst addModel = (provider: string, id: string) => {\n\t\t\tconst value = `${provider}/${id}`;\n\t\t\tif (seen.has(value)) return;\n\t\t\tseen.add(value);\n\t\t\tsummaryModels.push({ value, label: value });\n\t\t};\n\n\t\tlet enabledModelPatterns: string[] | null = null;\n\t\tlet scopeLoaded = true;\n\t\ttry {\n\t\t\tenabledModelPatterns = loadEnabledModelPatterns(summaryContext);\n\t\t\tconst availableModels = summaryContext.modelRegistry.getAvailable();\n\t\t\tfor (const model of availableModels) {\n\t\t\t\tif (!modelMatchesEnabledPatterns(model, enabledModelPatterns)) continue;\n\t\t\t\tconst value = `${model.provider}/${model.id}`;\n\t\t\t\tavailableValues.add(value);\n\t\t\t\taddModel(model.provider, model.id);\n\t\t\t}\n\t\t} catch (err) {\n\t\t\tscopeLoaded = false;\n\t\t\tconst message = err instanceof Error ? err.message : String(err);\n\t\t\tconsole.error(`Failed to load summary models: ${message}`);\n\t\t}\n\n\t\tconst currentModelValue = summaryContext.model\n\t\t\t? `${summaryContext.model.provider}/${summaryContext.model.id}`\n\t\t\t: null;\n\t\tif (scopeLoaded && summaryContext.model && currentModelValue && !seen.has(currentModelValue) && modelMatchesEnabledPatterns(summaryContext.model, enabledModelPatterns)) {\n\t\t\taddModel(summaryContext.model.provider, summaryContext.model.id);\n\t\t}\n\n\t\tconst config = loadConfig();\n\t\tconst configuredSummaryModel = typeof config.summaryModel === \"string\" ? config.summaryModel.trim() : \"\";\n\t\tconst preferredDefaults = [\n\t\t\t\"anthropic/claude-haiku-4-5\",\n\t\t\t\"openai-codex/gpt-5.3-codex-spark\",\n\t\t];\n\n\t\tlet defaultSummaryModel: string | null = null;\n\t\tif (configuredSummaryModel.length > 0 && availableValues.has(configuredSummaryModel)) {\n\t\t\tdefaultSummaryModel = configuredSummaryModel;\n\t\t}\n\t\tif (!defaultSummaryModel) {\n\t\t\tfor (const preferred of preferredDefaults) {\n\t\t\t\tif (availableValues.has(preferred)) {\n\t\t\t\t\tdefaultSummaryModel = preferred;\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\treturn { summaryModels, defaultSummaryModel };\n\t}\n\n\tfunction resolveSummaryForSubmit(\n\t\tpayload: { selectedQueryIndices: number[]; summary?: string; summaryMeta?: SummaryMeta },\n\t\tresultsByIndex: Map<number, QueryResultData>,\n\t): { approvedSummary: string; summaryMeta: SummaryMeta } {\n\t\tconst submittedSummary = typeof payload.summary === \"string\" ? payload.summary.trim() : \"\";\n\t\tif (submittedSummary.length > 0) {\n\t\t\treturn {\n\t\t\t\tapprovedSummary: submittedSummary,\n\t\t\t\tsummaryMeta: normalizeSummaryMeta(payload.summaryMeta, submittedSummary),\n\t\t\t};\n\t\t}\n\n\t\tconst selected = filterByQueryIndices(payload.selectedQueryIndices, resultsByIndex).results;\n\t\tconst fallbackResults = selected.length > 0 ? selected : [...resultsByIndex.values()];\n\t\tconst deterministic = buildDeterministicSummary(fallbackResults);\n\t\treturn {\n\t\t\tapprovedSummary: deterministic.summary,\n\t\t\tsummaryMeta: deterministic.meta,\n\t\t};\n\t}\n\n\tfunction buildSearchReturn(opts: SearchReturnOptions) {\n\t\tconst sc = opts.results.filter(r => !r.error).length;\n\t\tconst tr = opts.results.reduce((sum, r) => sum + r.results.length, 0);\n\n\t\tconst hasApprovedSummary = typeof opts.approvedSummary === \"string\" && opts.approvedSummary.trim().length > 0;\n\t\tlet output = \"\";\n\t\tif (hasApprovedSummary) {\n\t\t\toutput = opts.approvedSummary!.trim();\n\t\t} else {\n\t\t\tif (opts.curated) {\n\t\t\t\toutput += \"[These results were manually curated by the user in the browser. Use them as-is — do not re-search or discard.]\\n\\n\";\n\t\t\t}\n\t\t\tconst duplicateQueries = opts.curated ? duplicateQuerySet(opts.results) : new Set<string>();\n\t\t\tfor (const { query, answer, results, error, provider } of opts.results) {\n\t\t\t\tif (opts.queryList.length > 1) {\n\t\t\t\t\toutput += opts.curated\n\t\t\t\t\t\t? formatQueryHeader(query, provider, duplicateQueries)\n\t\t\t\t\t\t: `## Query: \"${query}\"\\n\\n`;\n\t\t\t\t}\n\t\t\t\tif (error) output += `Error: ${error}\\n\\n`;\n\t\t\t\telse if (results.length === 0) output += \"No results found.\\n\\n\";\n\t\t\t\telse output += formatSearchSummary(results, answer) + \"\\n\\n\";\n\t\t\t}\n\t\t}\n\n\t\tconst hasInlineReady = hasFullInlineCoverage(opts.urls, opts.inlineContent);\n\t\tlet fetchId: string | null = null;\n\t\tif (hasInlineReady && opts.inlineContent) {\n\t\t\tfetchId = generateId();\n\t\t\tconst data: StoredSearchData = {\n\t\t\t\tid: fetchId,\n\t\t\t\ttype: \"fetch\",\n\t\t\t\ttimestamp: Date.now(),\n\t\t\t\turls: opts.inlineContent,\n\t\t\t};\n\t\t\tstoreResult(fetchId, data);\n\t\t\tpi.appendEntry(\"web-search-results\", data);\n\t\t\tif (!hasApprovedSummary) {\n\t\t\t\toutput += `---\\nFull content for ${opts.inlineContent.length} sources available [${fetchId}].`;\n\t\t\t}\n\t\t} else if (opts.includeContent) {\n\t\t\tfetchId = startBackgroundFetch(opts.urls);\n\t\t\tif (fetchId && !hasApprovedSummary) {\n\t\t\t\toutput += `---\\nContent fetching in background [${fetchId}]. Will notify when ready.`;\n\t\t\t}\n\t\t}\n\n\t\tconst searchId = storeAndPublishSearch(opts.results);\n\t\tconst isBackgroundFetch = fetchId !== null && !hasInlineReady;\n\n\t\treturn {\n\t\t\tcontent: [{ type: \"text\", text: output.trim() }],\n\t\t\tdetails: {\n\t\t\t\tqueries: opts.queryList,\n\t\t\t\tqueryCount: opts.queryList.length,\n\t\t\t\tsuccessfulQueries: sc,\n\t\t\t\ttotalResults: tr,\n\t\t\t\tincludeContent: opts.includeContent,\n\t\t\t\tfetchId,\n\t\t\t\tfetchUrls: isBackgroundFetch ? opts.urls : undefined,\n\t\t\t\tsearchId,\n\t\t\t\t...(opts.curated ? {\n\t\t\t\t\tcurated: true,\n\t\t\t\t\tcuratedFrom: opts.curatedFrom,\n\t\t\t\t\tcuratedQueries: opts.results.map(r => ({\n\t\t\t\t\t\tquery: r.query,\n\t\t\t\t\t\tprovider: r.provider || null,\n\t\t\t\t\t\tanswer: r.answer || null,\n\t\t\t\t\t\tsources: r.results.map(s => ({ title: s.title, url: s.url })),\n\t\t\t\t\t\terror: r.error,\n\t\t\t\t\t})),\n\t\t\t\t} : {}),\n\t\t\t\t...((opts.workflow && hasApprovedSummary)\n\t\t\t\t\t? {\n\t\t\t\t\t\tsummary: {\n\t\t\t\t\t\t\ttext: opts.approvedSummary!.trim(),\n\t\t\t\t\t\t\tworkflow: opts.workflow,\n\t\t\t\t\t\t\tmodel: opts.summaryMeta?.model ?? null,\n\t\t\t\t\t\t\tdurationMs: opts.summaryMeta?.durationMs ?? 0,\n\t\t\t\t\t\t\ttokenEstimate: opts.summaryMeta?.tokenEstimate ?? 0,\n\t\t\t\t\t\t\tfallbackUsed: opts.summaryMeta?.fallbackUsed === true,\n\t\t\t\t\t\t\tfallbackReason: opts.summaryMeta?.fallbackReason,\n\t\t\t\t\t\t\tedited: opts.summaryMeta?.edited === true,\n\t\t\t\t\t\t},\n\t\t\t\t\t}\n\t\t\t\t\t: {}),\n\t\t\t},\n\t\t};\n\t}\n\n\tfunction filterByQueryIndices(selectedQueryIndices: number[], results: Map<number, QueryResultData>) {\n\t\tconst filteredResults: QueryResultData[] = [];\n\t\tconst filteredUrls: string[] = [];\n\t\tfor (const qi of selectedQueryIndices) {\n\t\t\tconst r = results.get(qi);\n\t\t\tif (r) {\n\t\t\t\tfilteredResults.push(r);\n\t\t\t\tfor (const res of r.results) {\n\t\t\t\t\tif (!filteredUrls.includes(res.url)) filteredUrls.push(res.url);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\treturn { results: filteredResults, urls: filteredUrls };\n\t}\n\n\tfunction collectAllResultsAndUrls(resultsByIndex: Map<number, QueryResultData>) {\n\t\tconst results = [...resultsByIndex.values()];\n\t\tconst urls: string[] = [];\n\t\tfor (const result of results) {\n\t\t\tfor (const source of result.results) {\n\t\t\t\tif (!urls.includes(source.url)) urls.push(source.url);\n\t\t\t}\n\t\t}\n\t\treturn { results, urls };\n\t}\n\n\tasync function openCuratorBrowser(callId: string, pc: PendingCurate, searchesComplete = true): Promise<void> {\n\t\tif (pendingCurates.get(callId) !== pc) return;\n\t\tlet handle: CuratorServerHandle | null = null;\n\t\ttry {\n\t\t\tpc.phase = \"curating\";\n\n\t\t\tconst searchAbort = new AbortController();\n\t\t\tconst addSearchSignal = pc.signal\n\t\t\t\t? AbortSignal.any([pc.signal, searchAbort.signal])\n\t\t\t\t: searchAbort.signal;\n\n\t\t\tconst sessionToken = randomUUID();\n\t\t\thandle = await startCuratorServer(\n\t\t\t\t{\n\t\t\t\t\tqueries: pc.queryList,\n\t\t\t\t\tsessionToken,\n\t\t\t\t\ttimeout: pc.timeoutSeconds,\n\t\t\t\t\tavailableProviders: pc.availableProviders,\n\t\t\t\t\tdefaultProvider: pc.defaultProvider,\n\t\t\t\t\tsearchProvider: pc.searchProvider,\n\t\t\t\t\tsummaryModels: pc.summaryModels,\n\t\t\t\t\tdefaultSummaryModel: pc.defaultSummaryModel,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tasync onSummarize(selectedQueryIndices, summarizeSignal, model, feedback) {\n\t\t\t\t\t\tif (pendingCurates.get(callId) !== pc) throw new Error(\"Curator session is no longer active.\");\n\t\t\t\t\t\tpc.onUpdate?.({\n\t\t\t\t\t\t\tcontent: [{ type: \"text\", text: \"Generating summary draft...\" }],\n\t\t\t\t\t\t\tdetails: { phase: \"generating-summary\", progress: 0.9, curatorUrl: pc.curatorUrl, timeoutSeconds: pc.timeoutSeconds, shortcut: curateKey },\n\t\t\t\t\t\t});\n\t\t\t\t\t\tconst draft = await generateSummaryForSelectedIndices(\n\t\t\t\t\t\t\tselectedQueryIndices,\n\t\t\t\t\t\t\tpc.searchResults,\n\t\t\t\t\t\t\tpc.summaryContext,\n\t\t\t\t\t\t\tsummarizeSignal,\n\t\t\t\t\t\t\tmodel,\n\t\t\t\t\t\t\tfeedback,\n\t\t\t\t\t\t);\n\t\t\t\t\t\tif (pendingCurates.get(callId) !== pc) throw new Error(\"Curator session is no longer active.\");\n\t\t\t\t\t\tpc.onUpdate?.({\n\t\t\t\t\t\t\tcontent: [{ type: \"text\", text: \"Summary draft ready — waiting for approval...\" }],\n\t\t\t\t\t\t\tdetails: { phase: \"waiting-for-approval\", progress: 1, curatorUrl: pc.curatorUrl, timeoutSeconds: pc.timeoutSeconds, shortcut: curateKey },\n\t\t\t\t\t\t});\n\t\t\t\t\t\treturn draft;\n\t\t\t\t\t},\n\t\t\t\t\tonSubmit(payload) {\n\t\t\t\t\t\tif (pendingCurates.get(callId) !== pc) return;\n\t\t\t\t\t\tsearchAbort.abort();\n\t\t\t\t\t\tconst filtered = payload.selectedQueryIndices.length > 0\n\t\t\t\t\t\t\t? filterByQueryIndices(payload.selectedQueryIndices, pc.searchResults)\n\t\t\t\t\t\t\t: collectAllResultsAndUrls(pc.searchResults);\n\t\t\t\t\t\tconst filteredInline = pc.allInlineContent.filter(c => filtered.urls.includes(c.url));\n\t\t\t\t\t\tconst base: SearchReturnOptions = {\n\t\t\t\t\t\t\tqueryList: filtered.results.map(r => r.query),\n\t\t\t\t\t\t\tresults: filtered.results,\n\t\t\t\t\t\t\turls: filtered.urls,\n\t\t\t\t\t\t\tincludeContent: pc.includeContent,\n\t\t\t\t\t\t\tinlineContent: filteredInline.length > 0 ? filteredInline : undefined,\n\t\t\t\t\t\t\tcurated: true,\n\t\t\t\t\t\t\tcuratedFrom: pc.searchResults.size,\n\t\t\t\t\t\t};\n\t\t\t\t\t\tif (!payload.rawResults) {\n\t\t\t\t\t\t\tconst resolvedSummary = resolveSummaryForSubmit(payload, pc.searchResults);\n\t\t\t\t\t\t\tbase.workflow = pc.workflow;\n\t\t\t\t\t\t\tbase.approvedSummary = resolvedSummary.approvedSummary;\n\t\t\t\t\t\t\tbase.summaryMeta = resolvedSummary.summaryMeta;\n\t\t\t\t\t\t}\n\t\t\t\t\t\tpc.finish(buildSearchReturn(base));\n\t\t\t\t\t\tcloseCurator(callId);\n\t\t\t\t\t},\n\t\t\t\t\tonCancel(reason) {\n\t\t\t\t\t\tif (pendingCurates.get(callId) !== pc) return;\n\t\t\t\t\t\tsearchAbort.abort();\n\t\t\t\t\t\tif (reason === \"timeout\") {\n\t\t\t\t\t\t\tconst resolvedSummary = resolveSummaryForSubmit({ selectedQueryIndices: [], summary: undefined, summaryMeta: undefined }, pc.searchResults);\n\t\t\t\t\t\t\tconst all = collectAllResultsAndUrls(pc.searchResults);\n\t\t\t\t\t\t\tconst filteredInline = pc.allInlineContent.filter(c => all.urls.includes(c.url));\n\t\t\t\t\t\t\tpc.finish(buildSearchReturn({\n\t\t\t\t\t\t\t\tqueryList: all.results.map(r => r.query),\n\t\t\t\t\t\t\t\tresults: all.results,\n\t\t\t\t\t\t\t\turls: all.urls,\n\t\t\t\t\t\t\t\tincludeContent: pc.includeContent,\n\t\t\t\t\t\t\t\tinlineContent: filteredInline.length > 0 ? filteredInline : undefined,\n\t\t\t\t\t\t\t\tcurated: true,\n\t\t\t\t\t\t\t\tcuratedFrom: pc.searchResults.size,\n\t\t\t\t\t\t\t\tworkflow: pc.workflow,\n\t\t\t\t\t\t\t\tapprovedSummary: resolvedSummary.approvedSummary,\n\t\t\t\t\t\t\t\tsummaryMeta: resolvedSummary.summaryMeta,\n\t\t\t\t\t\t\t}));\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tconst conn = activeCurators.get(callId)?.getConnectionState();\n\t\t\t\t\t\t\tpc.finish(buildCurationCancelledReturn(reason, {\n\t\t\t\t\t\t\t\tqueries: Array.from(pc.searchResults.values()),\n\t\t\t\t\t\t\t\tqueryCount: pc.queryList.length,\n\t\t\t\t\t\t\t\tbrowserConnected: conn?.browserConnected,\n\t\t\t\t\t\t\t\tlastHeartbeatAgeMs: conn?.lastHeartbeatAgeMs,\n\t\t\t\t\t\t\t\tcuratorUrl: pc.curatorUrl,\n\t\t\t\t\t\t\t\tbrowserOpenError: pc.browserOpenError,\n\t\t\t\t\t\t\t}));\n\t\t\t\t\t\t}\n\t\t\t\t\t\tcloseCurator(callId);\n\t\t\t\t\t},\n\t\t\t\t\tonProviderChange(provider) {\n\t\t\t\t\t\tif (pendingCurates.get(callId) !== pc) return;\n\t\t\t\t\t\tconst normalized = normalizeProviderInput(provider);\n\t\t\t\t\t\tif (!normalized || normalized === \"auto\") return;\n\t\t\t\t\t\tpc.defaultProvider = normalized;\n\t\t\t\t\t\tpc.searchProvider = normalized;\n\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\tsaveConfig({ provider: normalized });\n\t\t\t\t\t\t} catch (err) {\n\t\t\t\t\t\t\tconst message = err instanceof Error ? err.message : String(err);\n\t\t\t\t\t\t\tconsole.error(`Failed to persist default provider: ${message}`);\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\tasync onAddSearch(query, queryIndex, provider) {\n\t\t\t\t\t\tif (pendingCurates.get(callId) !== pc) throw new Error(\"Curator session is no longer active.\");\n\t\t\t\t\t\tconst normalizedProvider = normalizeProviderInput(provider);\n\t\t\t\t\t\tconst requestedProvider = !normalizedProvider || normalizedProvider === \"auto\"\n\t\t\t\t\t\t\t? pc.searchProvider\n\t\t\t\t\t\t\t: normalizedProvider;\n\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\tconst { answer, results, inlineContent, provider: actualProvider } = await search(query, {\n\t\t\t\t\t\t\t\tprovider: requestedProvider,\n\t\t\t\t\t\t\t\tnumResults: pc.numResults,\n\t\t\t\t\t\t\t\trecencyFilter: pc.recencyFilter,\n\t\t\t\t\t\t\t\tdomainFilter: pc.domainFilter,\n\t\t\t\t\t\t\t\tincludeContent: pc.includeContent,\n\t\t\t\t\t\t\t\tsignal: addSearchSignal,\n\t\t\t\t\t\t\t\textensionContext: ctx,\n\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\tif (pendingCurates.get(callId) !== pc) throw new Error(\"Curator session is no longer active.\");\n\t\t\t\t\t\t\tpc.searchResults.set(queryIndex, { query, answer, results, error: null, provider: actualProvider });\n\t\t\t\t\t\t\tif (inlineContent) pc.allInlineContent.push(...inlineContent);\n\t\t\t\t\t\t\treturn {\n\t\t\t\t\t\t\t\tanswer,\n\t\t\t\t\t\t\t\tresults: results.map(r => ({ title: r.title, url: r.url, domain: extractDomain(r.url) })),\n\t\t\t\t\t\t\t\tprovider: actualProvider,\n\t\t\t\t\t\t\t};\n\t\t\t\t\t\t} catch (err) {\n\t\t\t\t\t\t\tconst message = err instanceof Error ? err.message : String(err);\n\t\t\t\t\t\t\tif (pendingCurates.get(callId) === pc) {\n\t\t\t\t\t\t\t\tpc.searchResults.set(queryIndex, { query, answer: \"\", results: [], error: message, provider: requestedProvider });\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tthrow err;\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\tasync onRewriteQuery(query, rewriteSignal) {\n\t\t\t\t\t\tif (pendingCurates.get(callId) !== pc) throw new Error(\"Curator session is no longer active.\");\n\t\t\t\t\t\treturn rewriteSearchQuery(query, pc.summaryContext, rewriteSignal);\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t);\n\n\t\t\tif (pendingCurates.get(callId) !== pc) {\n\t\t\t\thandle.close();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tactiveCurators.set(callId, handle);\n\t\t\tpc.curatorUrl = handle.url;\n\n\t\t\tfor (const [qi, data] of pc.searchResults) {\n\t\t\t\tif (data.error) {\n\t\t\t\t\thandle.pushError(qi, data.error, data.provider);\n\t\t\t\t} else {\n\t\t\t\t\thandle.pushResult(qi, {\n\t\t\t\t\t\tanswer: data.answer,\n\t\t\t\t\t\tresults: data.results.map(r => ({ title: r.title, url: r.url, domain: extractDomain(r.url) })),\n\t\t\t\t\t\tprovider: data.provider || pc.defaultProvider,\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t}\n\t\t\tif (searchesComplete) handle.searchesDone();\n\n\t\t\tconst sendCuratorFallbackUpdate = (message: string) => {\n\t\t\t\tpc.onUpdate?.({\n\t\t\t\t\tcontent: [{ type: \"text\", text: `${message}\\nOpen manually: ${handle.url}` }],\n\t\t\t\t\tdetails: {\n\t\t\t\t\t\tphase: \"curator-fallback\",\n\t\t\t\t\t\tprogress: searchesComplete ? 1 : 0.5,\n\t\t\t\t\t\tcuratorUrl: handle.url,\n\t\t\t\t\t\ttimeoutSeconds: pc.timeoutSeconds,\n\t\t\t\t\t\tshortcut: curateKey,\n\t\t\t\t\t\tbrowserOpenError: pc.browserOpenError,\n\t\t\t\t\t},\n\t\t\t\t});\n\t\t\t};\n\n\t\t\tpc.onUpdate?.({\n\t\t\t\tcontent: [{ type: \"text\", text: searchesComplete ? \"Waiting for summary approval in browser...\" : \"Searches streaming to browser...\" }],\n\t\t\t\tdetails: {\n\t\t\t\t\tphase: \"curating\",\n\t\t\t\t\tprogress: searchesComplete ? 1 : 0.5,\n\t\t\t\t\tcuratorUrl: handle.url,\n\t\t\t\t\ttimeoutSeconds: pc.timeoutSeconds,\n\t\t\t\t\tshortcut: curateKey,\n\t\t\t\t},\n\t\t\t});\n\n\t\t\tconst open = platform() === \"darwin\" ? await getGlimpseOpen() : null;\n\t\t\tif (open) {\n\t\t\t\ttry {\n\t\t\t\t\tconst win = openInGlimpse(open, handle.url, \"Search Curator\");\n\t\t\t\t\tglimpseWins.set(callId, win);\n\t\t\t\t\twin.on(\"closed\", () => {\n\t\t\t\t\t\tif (glimpseWins.get(callId) === win) {\n\t\t\t\t\t\t\tglimpseWins.delete(callId);\n\t\t\t\t\t\t\tcloseCurator(callId);\n\t\t\t\t\t\t}\n\t\t\t\t\t});\n\t\t\t\t\treturn;\n\t\t\t\t} catch (err) {\n\t\t\t\t\tconst message = err instanceof Error ? err.message : String(err);\n\t\t\t\t\tconsole.error(`Failed to open Glimpse curator window: ${message}`);\n\t\t\t\t\tglimpseWins.delete(callId);\n\t\t\t\t}\n\t\t\t}\n\t\t\tawait openInBrowser(pi, handle.url);\n\t\t} catch (err) {\n\t\t\tconst message = err instanceof Error ? err.message : String(err);\n\t\t\tconsole.error(`Failed to open curator UI: ${message}`);\n\t\t\tif (handle && activeCurators.get(callId) === handle && pendingCurates.get(callId) === pc) {\n\t\t\t\tpc.browserOpenError = message;\n\t\t\t\tsendCuratorFallbackUpdate(\"Search curator is running, but the browser did not open automatically.\");\n\t\t\t} else if (pendingCurates.get(callId) === pc || (handle && activeCurators.get(callId) === handle)) {\n\t\t\t\tcloseCurator(callId);\n\t\t\t}\n\t\t}\n\t}\n\n\tpi.registerShortcut(curateKey, {\n\t\tdescription: \"Review search results\",\n\t\thandler: async (ctx) => {\n\t\t\tconst entries = [...pendingCurates.entries()];\n\t\t\tif (entries.length === 0) return;\n\t\t\tconst [callId, pc] = entries[entries.length - 1];\n\n\t\t\tif (pc.phase === \"searching\") {\n\t\t\t\tpc.browserPromise = openCuratorBrowser(callId, pc, false);\n\t\t\t\tctx.ui.notify(\"Opening curator — remaining searches will stream in\", \"info\");\n\t\t\t\treturn;\n\t\t\t}\n\t\t},\n\t});\n\n\tpi.registerShortcut(activityKey, {\n\t\tdescription: \"Toggle web search activity\",\n\t\thandler: async (ctx) => {\n\t\t\twidgetVisible = !widgetVisible;\n\t\t\tif (widgetVisible) {\n\t\t\t\twidgetUnsubscribe = activityMonitor.onUpdate(() => updateWidget(ctx));\n\t\t\t\tupdateWidget(ctx);\n\t\t\t} else {\n\t\t\t\twidgetUnsubscribe?.();\n\t\t\t\twidgetUnsubscribe = null;\n\t\t\t\tctx.ui.setWidget(\"web-activity\", undefined);\n\t\t\t}\n\t\t},\n\t});\n\n\tpi.on(\"session_start\", async (_event, ctx) => handleSessionChange(ctx));\n\tpi.on(\"session_tree\", async (_event, ctx) => handleSessionChange(ctx));\n\n\tpi.on(\"session_shutdown\", () => {\n\t\tsessionActive = false;\n\t\tabortPendingFetches();\n\t\tcloseCurator();\n\t\tclearCloneCache();\n\t\tclearResults();\n\t\t// Unsubscribe before clear() to avoid callback with stale ctx\n\t\twidgetUnsubscribe?.();\n\t\twidgetUnsubscribe = null;\n\t\tactivityMonitor.clear();\n\t\twidgetVisible = false;\n\t});\n\n\tif (initConfig.webSearch?.enabled !== false) pi.registerTool({\n\t\tname: \"web_search\",\n\t\tlabel: \"Web Search\",\n\t\tdescription:\n\t\t\t`Search the web using OpenAI, Brave, Parallel, Tavily, Exa, Perplexity, or Gemini. Returns an AI-synthesized answer with source citations. OpenAI web_search uses a Codex subscription or OpenAI API key. For comprehensive research, prefer queries (plural) with 2-4 varied angles over a single query — each query gets its own synthesized answer, so varying phrasing and scope gives much broader coverage. When includeContent is true, full page content is fetched in the background. Searches auto-open the interactive browser curator and stream results live; set workflow to \"none\" to skip curation or \"auto-summary\" for a model-generated summary without the browser curator. Provider auto-selects: OpenAI when suitable and available, then Exa, Brave, Parallel, Tavily, Perplexity, Gemini API, then Gemini Web.`,\n\t\tpromptSnippet:\n\t\t\t\"Use for web research questions. Prefer {queries:[...]} with 2-4 varied angles over a single query for broader coverage.\",\n\t\tparameters: Type.Object({\n\t\t\tquery: Type.Optional(Type.String({ description: \"Single search query. For research tasks, prefer 'queries' with multiple varied angles instead.\" })),\n\t\t\tqueries: Type.Optional(Type.Array(Type.String(), { description: \"Multiple queries searched in sequence, each returning its own synthesized answer. Prefer this for research — vary phrasing, scope, and angle across 2-4 queries to maximize coverage. Good: ['React vs Vue performance benchmarks 2026', 'React vs Vue developer experience comparison', 'React ecosystem size vs Vue ecosystem']. Bad: ['React vs Vue', 'React vs Vue comparison', 'React vs Vue review'] (too similar, redundant results).\" })),\n\t\t\tnumResults: Type.Optional(Type.Number({ description: \"Results per query (default: 5, max: 20)\" })),\n\t\t\tincludeContent: Type.Optional(Type.Boolean({ description: \"Fetch full page content (async)\" })),\n\t\t\trecencyFilter: Type.Optional(\n\t\t\t\tStringEnum([\"day\", \"week\", \"month\", \"year\"], { description: \"Filter by recency\" }),\n\t\t\t),\n\t\t\tdomainFilter: Type.Optional(Type.Array(Type.String(), { description: \"Limit to domains (prefix with - to exclude)\" })),\n\t\t\tprovider: Type.Optional(\n\t\t\t\tStringEnum([\"auto\", \"openai\", \"brave\", \"parallel\", \"tavily\", \"exa\", \"perplexity\", \"gemini\"], { description: \"Search provider (default: auto)\" }),\n\t\t\t),\n\t\t\tworkflow: Type.Optional(\n\t\t\t\tStringEnum([\"none\", \"summary-review\", \"auto-summary\"], {\n\t\t\t\t\tdescription: \"Search workflow mode: none = no curator, summary-review = open curator with auto summary draft (default), auto-summary = generate summary without opening curator\",\n\t\t\t\t}),\n\t\t\t),\n\t\t}),\n\n\t\tasync execute(callId, params, signal, onUpdate, ctx) {\n\t\t\tconst rawQueryList: unknown[] = Array.isArray(params.queries)\n\t\t\t\t? params.queries\n\t\t\t\t: (params.query !== undefined ? [params.query] : []);\n\t\t\tconst queryList = normalizeQueryList(rawQueryList);\n\t\t\tconst configWorkflow = loadConfigForExtensionInit().workflow;\n\t\t\tconst workflow = resolveWorkflow(params.workflow ?? configWorkflow, ctx?.hasUI !== false);\n\t\t\tconst shouldCurate = workflow === \"summary-review\";\n\n\t\t\tif (queryList.length === 0) {\n\t\t\t\treturn {\n\t\t\t\t\tcontent: [{ type: \"text\", text: \"Error: No query provided. Use 'query' or 'queries' parameter.\" }],\n\t\t\t\t\tdetails: { error: \"No query provided\" },\n\t\t\t\t};\n\t\t\t}\n\n\t\t\tif (shouldCurate && !ctx) {\n\t\t\t\treturn {\n\t\t\t\t\tcontent: [{ type: \"text\", text: \"Error: Curation requires an active extension context.\" }],\n\t\t\t\t\tdetails: { error: \"Missing extension context\" },\n\t\t\t\t};\n\t\t\t}\n\n\t\t\tif (shouldCurate) {\n\t\t\t\tcloseCurator(callId);\n\n\t\t\t\tlet resolvePromise: (value: unknown) => void = () => {};\n\t\t\t\tconst promise = new Promise<unknown>((resolve) => {\n\t\t\t\t\tresolvePromise = resolve;\n\t\t\t\t});\n\t\t\t\tconst includeContent = params.includeContent ?? false;\n\t\t\t\tconst searchResults = new Map<number, QueryResultData>();\n\t\t\t\tconst allInlineContent: ExtractedContent[] = [];\n\t\t\t\tconst searchAbort = new AbortController();\n\t\t\t\tconst searchSignal = signal\n\t\t\t\t\t? AbortSignal.any([signal, searchAbort.signal])\n\t\t\t\t\t: searchAbort.signal;\n\t\t\t\tlet cancelled = false;\n\n\t\t\t\tconst bootstrap = await loadCuratorBootstrap(params.provider, ctx, {\n\t\t\t\t\tnumResults: params.numResults,\n\t\t\t\t\trecencyFilter: params.recencyFilter,\n\t\t\t\t});\n\t\t\t\tconst availableProviders = bootstrap.availableProviders;\n\t\t\t\tconst defaultProvider = bootstrap.defaultProvider;\n\t\t\t\tconst rawSearchProvider = normalizeProviderInput(params.provider ?? loadConfig().provider ?? \"auto\") ?? \"auto\";\n\t\t\t\tconst searchProvider = rawSearchProvider === \"auto\" ? \"auto\" : defaultProvider;\n\t\t\t\tconst curatorTimeoutSeconds = bootstrap.timeoutSeconds;\n\t\t\t\tconst curatorWorkflow: CuratorWorkflow = \"summary-review\";\n\n\t\t\t\tconst summaryContext: SummaryGenerationContext = {\n\t\t\t\t\tmodel: ctx.model,\n\t\t\t\t\tmodelRegistry: ctx.modelRegistry,\n\t\t\t\t\tcwd: ctx.cwd,\n\t\t\t\t\tisProjectTrusted: () => ctx.isProjectTrusted(),\n\t\t\t\t};\n\t\t\t\tconst summaryModelChoices = await loadSummaryModelChoices(summaryContext);\n\n\t\t\t\tconst pc: PendingCurate = {\n\t\t\t\t\tphase: \"searching\",\n\t\t\t\t\tworkflow: curatorWorkflow,\n\t\t\t\t\tsummaryContext,\n\t\t\t\t\tsearchResults,\n\t\t\t\t\tallInlineContent,\n\t\t\t\t\tqueryList,\n\t\t\t\t\tincludeContent,\n\t\t\t\t\tnumResults: params.numResults,\n\t\t\t\t\trecencyFilter: params.recencyFilter,\n\t\t\t\t\tdomainFilter: params.domainFilter,\n\t\t\t\t\tavailableProviders,\n\t\t\t\t\tdefaultProvider,\n\t\t\t\t\tsearchProvider,\n\t\t\t\t\tsummaryModels: summaryModelChoices.summaryModels,\n\t\t\t\t\tdefaultSummaryModel: summaryModelChoices.defaultSummaryModel,\n\t\t\t\t\ttimeoutSeconds: curatorTimeoutSeconds,\n\t\t\t\t\tonUpdate: onUpdate as PendingCurate[\"onUpdate\"],\n\t\t\t\t\tsignal,\n\t\t\t\t\tabortSearches: () => {\n\t\t\t\t\t\tif (!searchAbort.signal.aborted) searchAbort.abort();\n\t\t\t\t\t},\n\t\t\t\t\tfinish: () => {},\n\t\t\t\t\tcancel: () => {},\n\t\t\t\t};\n\n\t\t\t\tconst finish = (value: unknown) => {\n\t\t\t\t\tif (cancelled) return;\n\t\t\t\t\tcancelled = true;\n\t\t\t\t\tpc.abortSearches();\n\t\t\t\t\tsignal?.removeEventListener(\"abort\", onAbort);\n\t\t\t\t\tpendingCurates.delete(callId);\n\t\t\t\t\tresolvePromise(value);\n\t\t\t\t};\n\n\t\t\t\tconst cancel = (reason: \"user\" | \"stale\" = \"stale\") => {\n\t\t\t\t\tif (cancelled) return;\n\t\t\t\t\tconst conn = activeCurators.get(callId)?.getConnectionState();\n\t\t\t\t\tfinish(buildCurationCancelledReturn(reason, {\n\t\t\t\t\t\tqueries: Array.from(searchResults.values()),\n\t\t\t\t\t\tqueryCount: queryList.length,\n\t\t\t\t\t\tbrowserConnected: conn?.browserConnected,\n\t\t\t\t\t\tlastHeartbeatAgeMs: conn?.lastHeartbeatAgeMs,\n\t\t\t\t\t\tcuratorUrl: pc.curatorUrl,\n\t\t\t\t\t\tbrowserOpenError: pc.browserOpenError,\n\t\t\t\t\t}));\n\t\t\t\t};\n\n\t\t\t\tpc.finish = finish;\n\t\t\t\tpc.cancel = cancel;\n\n\t\t\t\tconst onAbort = () => closeCurator(callId);\n\t\t\t\tpendingCurates.set(callId, pc);\n\t\t\t\tsignal?.addEventListener(\"abort\", onAbort, { once: true });\n\t\t\t\tpc.browserPromise = openCuratorBrowser(callId, pc, false);\n\n\t\t\t\tfor (let qi = 0; qi < queryList.length; qi++) {\n\t\t\t\t\tif (signal?.aborted || cancelled || searchAbort.signal.aborted) break;\n\t\t\t\t\tonUpdate?.({\n\t\t\t\t\t\tcontent: [{ type: \"text\", text: `Searching ${qi + 1}/${queryList.length}: \"${queryList[qi]}\"...` }],\n\t\t\t\t\t\tdetails: { phase: \"searching\", progress: qi / queryList.length, currentQuery: queryList[qi] },\n\t\t\t\t\t});\n\t\t\t\t\tconst requestedProvider = pc.searchProvider;\n\t\t\t\t\ttry {\n\t\t\t\t\t\tconst { answer, results, inlineContent, provider } = await search(queryList[qi], {\n\t\t\t\t\t\t\tprovider: requestedProvider,\n\t\t\t\t\t\t\tnumResults: params.numResults,\n\t\t\t\t\t\t\trecencyFilter: params.recencyFilter,\n\t\t\t\t\t\t\tdomainFilter: params.domainFilter,\n\t\t\t\t\t\t\tincludeContent: params.includeContent,\n\t\t\t\t\t\t\tsignal: searchSignal,\n\t\t\t\t\t\t\textensionContext: ctx,\n\t\t\t\t\t\t});\n\t\t\t\t\t\tif (signal?.aborted || cancelled || searchAbort.signal.aborted) break;\n\t\t\t\t\t\tsearchResults.set(qi, { query: queryList[qi], answer, results, error: null, provider });\n\t\t\t\t\t\tif (inlineContent) allInlineContent.push(...inlineContent);\n\t\t\t\t\t\tconst curator = activeCurators.get(callId);\n\t\t\t\t\t\tif (curator) {\n\t\t\t\t\t\t\tcurator.pushResult(qi, {\n\t\t\t\t\t\t\t\tanswer,\n\t\t\t\t\t\t\t\tresults: results.map(r => ({ title: r.title, url: r.url, domain: extractDomain(r.url) })),\n\t\t\t\t\t\t\t\tprovider,\n\t\t\t\t\t\t\t});\n\t\t\t\t\t\t}\n\t\t\t\t\t} catch (err) {\n\t\t\t\t\t\tif (signal?.aborted || cancelled || searchAbort.signal.aborted) break;\n\t\t\t\t\t\tconst message = err instanceof Error ? err.message : String(err);\n\t\t\t\t\t\tsearchResults.set(qi, { query: queryList[qi], answer: \"\", results: [], error: message, provider: requestedProvider });\n\t\t\t\t\t\tconst curator = activeCurators.get(callId);\n\t\t\t\t\t\tif (curator) {\n\t\t\t\t\t\t\tcurator.pushError(qi, message, requestedProvider);\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tif (signal?.aborted || cancelled || searchAbort.signal.aborted) {\n\t\t\t\t\tcancel();\n\t\t\t\t\treturn promise;\n\t\t\t\t}\n\n\t\t\t\tawait pc.browserPromise;\n\t\t\t\tconst curator = activeCurators.get(callId);\n\t\t\t\tif (curator && !cancelled) {\n\t\t\t\t\tcurator.searchesDone();\n\t\t\t\t\tif (pc.browserOpenError) {\n\t\t\t\t\t\tpc.onUpdate?.({\n\t\t\t\t\t\t\tcontent: [{ type: \"text\", text: `All searches complete. Open the curator manually: ${pc.curatorUrl}` }],\n\t\t\t\t\t\t\tdetails: {\n\t\t\t\t\t\t\t\tphase: \"curator-fallback\",\n\t\t\t\t\t\t\t\tprogress: 1,\n\t\t\t\t\t\t\t\tcuratorUrl: pc.curatorUrl,\n\t\t\t\t\t\t\t\ttimeoutSeconds: pc.timeoutSeconds,\n\t\t\t\t\t\t\t\tshortcut: curateKey,\n\t\t\t\t\t\t\t\tbrowserOpenError: pc.browserOpenError,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t});\n\t\t\t\t\t} else {\n\t\t\t\t\t\tpc.onUpdate?.({\n\t\t\t\t\t\t\tcontent: [{ type: \"text\", text: \"All searches complete — waiting for summary approval in browser...\" }],\n\t\t\t\t\t\t\tdetails: {\n\t\t\t\t\t\t\t\tphase: \"curating\",\n\t\t\t\t\t\t\t\tprogress: 1,\n\t\t\t\t\t\t\t\tcuratorUrl: pc.curatorUrl,\n\t\t\t\t\t\t\t\ttimeoutSeconds: pc.timeoutSeconds,\n\t\t\t\t\t\t\t\tshortcut: curateKey,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t});\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\treturn promise;\n\t\t\t}\n\n\t\t\tconst searchResults: QueryResultData[] = [];\n\t\t\tconst allUrls: string[] = [];\n\t\t\tconst allInlineContent: ExtractedContent[] = [];\n\t\t\tconst resolvedProvider = normalizeProviderInput(params.provider ?? loadConfig().provider);\n\n\t\t\tfor (let i = 0; i < queryList.length; i++) {\n\t\t\t\tconst query = queryList[i];\n\n\t\t\t\tonUpdate?.({\n\t\t\t\t\tcontent: [{ type: \"text\", text: `Searching ${i + 1}/${queryList.length}: \"${query}\"...` }],\n\t\t\t\t\tdetails: { phase: \"search\", progress: i / queryList.length, currentQuery: query },\n\t\t\t\t});\n\n\t\t\t\ttry {\n\t\t\t\t\tconst { answer, results, inlineContent, provider } = await search(query, {\n\t\t\t\t\t\tprovider: resolvedProvider,\n\t\t\t\t\t\tnumResults: params.numResults,\n\t\t\t\t\t\trecencyFilter: params.recencyFilter,\n\t\t\t\t\t\tdomainFilter: params.domainFilter,\n\t\t\t\t\t\tincludeContent: params.includeContent,\n\t\t\t\t\t\tsignal,\n\t\t\t\t\t\textensionContext: ctx,\n\t\t\t\t\t});\n\n\t\t\t\t\tsearchResults.push({ query, answer, results, error: null, provider });\n\t\t\t\t\tfor (const r of results) {\n\t\t\t\t\t\tif (!allUrls.includes(r.url)) {\n\t\t\t\t\t\t\tallUrls.push(r.url);\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tif (inlineContent) allInlineContent.push(...inlineContent);\n\t\t\t\t} catch (err) {\n\t\t\t\t\tconst message = err instanceof Error ? err.message : String(err);\n\t\t\t\t\tconst requestedProvider = typeof resolvedProvider === \"string\" && resolvedProvider !== \"auto\"\n\t\t\t\t\t\t? resolvedProvider\n\t\t\t\t\t\t: undefined;\n\t\t\t\t\tsearchResults.push({ query, answer: \"\", results: [], error: message, provider: requestedProvider });\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tlet approvedSummary: string | undefined;\n\t\t\tlet summaryMeta: SummaryMeta | undefined;\n\t\t\tif (workflow === \"auto-summary\") {\n\t\t\t\tif (!ctx) {\n\t\t\t\t\treturn {\n\t\t\t\t\t\tcontent: [{ type: \"text\", text: \"Error: Auto-summary requires an active extension context.\" }],\n\t\t\t\t\t\tdetails: { error: \"Missing extension context\" },\n\t\t\t\t\t};\n\t\t\t\t}\n\t\t\t\tonUpdate?.({\n\t\t\t\t\tcontent: [{ type: \"text\", text: \"Generating summary...\" }],\n\t\t\t\t\tdetails: { phase: \"generating-summary\", progress: 1 },\n\t\t\t\t});\n\t\t\t\tconst summaryContext: SummaryGenerationContext = {\n\t\t\t\t\tmodel: ctx.model,\n\t\t\t\t\tmodelRegistry: ctx.modelRegistry,\n\t\t\t\t\tcwd: ctx.cwd,\n\t\t\t\t\tisProjectTrusted: () => ctx.isProjectTrusted(),\n\t\t\t\t};\n\t\t\t\tconst summaryModelChoices = await loadSummaryModelChoices(summaryContext);\n\t\t\t\tconst generated = await generateSummaryDraft(searchResults, summaryContext, signal, summaryModelChoices.defaultSummaryModel ?? undefined);\n\t\t\t\tapprovedSummary = generated.summary;\n\t\t\t\tsummaryMeta = generated.meta;\n\t\t\t}\n\n\t\t\treturn buildSearchReturn({\n\t\t\t\tqueryList,\n\t\t\t\tresults: searchResults,\n\t\t\t\turls: allUrls,\n\t\t\t\tincludeContent: params.includeContent ?? false,\n\t\t\t\tinlineContent: allInlineContent.length > 0 ? allInlineContent : undefined,\n\t\t\t\tworkflow: workflow === \"auto-summary\" ? \"auto-summary\" : undefined,\n\t\t\t\tapprovedSummary,\n\t\t\t\tsummaryMeta,\n\t\t\t});\n\t\t},\n\n\t\trenderCall(args, theme, context) {\n\t\t\t// lunr: collapsed header is one line (title · count). Query list lives in expanded renderResult.\n\t\t\tconst queryList = collectSearchQueries(args as { query?: unknown; queries?: unknown });\n\t\t\tconst compact = Boolean(context && !context.isPartial && !context.expanded && !context.isError);\n\t\t\tconst chrome = formatSearchChrome({\n\t\t\t\tqueries: queryList,\n\t\t\t\tdetails: compact ? context?.result?.details : undefined,\n\t\t\t\texpanded: false,\n\t\t\t});\n\t\t\tconst call = formatSearchCallTitle(queryList);\n\t\t\tif (call.empty) {\n\t\t\t\treturn new Text(theme.fg(\"toolTitle\", theme.bold(\"search \")) + theme.fg(\"error\", \"(no query)\"), 0, 0);\n\t\t\t}\n\t\t\treturn new Text(theme.fg(\"toolTitle\", theme.bold(chrome.call)), 0, 0);\n\t\t},\n\n\t\trenderResult(result, { expanded, isPartial }, theme, context) {\n\t\t\ttype QueryDetail = {\n\t\t\t\tquery: string;\n\t\t\t\tprovider: string | null;\n\t\t\t\tanswer: string | null;\n\t\t\t\tsources: Array<{ title: string; url: string }>;\n\t\t\t\terror: string | null;\n\t\t\t};\n\t\t\tconst details = result.details as {\n\t\t\t\tqueryCount?: number;\n\t\t\t\tsuccessfulQueries?: number;\n\t\t\t\ttotalResults?: number;\n\t\t\t\terror?: string;\n\t\t\t\tfetchId?: string;\n\t\t\t\tfetchUrls?: string[];\n\t\t\t\tphase?: string;\n\t\t\t\tprogress?: number;\n\t\t\t\tcurrentQuery?: string;\n\t\t\t\tcurated?: boolean;\n\t\t\t\tcuratedFrom?: number;\n\t\t\t\tcuratedQueries?: QueryDetail[];\n\t\t\t\tcancelled?: boolean;\n\t\t\t\tcancelReason?: string;\n\t\t\t\tbrowserConnected?: boolean;\n\t\t\t\tlastHeartbeatAgeMs?: number | null;\n\t\t\t\tcancelledQueries?: import(\"./render-search-error.ts\").CancelledQueryDetail[];\n\t\t\t\tcuratorUrl?: string;\n\t\t\t\tbrowserOpenError?: string;\n\t\t\t\ttimeoutSeconds?: number;\n\t\t\t\tshortcut?: string;\n\t\t\t\tsummary?: {\n\t\t\t\t\ttext: string;\n\t\t\t\t\tworkflow: SummaryWorkflow;\n\t\t\t\t\tmodel: string | null;\n\t\t\t\t\tdurationMs: number;\n\t\t\t\t\ttokenEstimate: number;\n\t\t\t\t\tfallbackUsed: boolean;\n\t\t\t\t\tfallbackReason?: string;\n\t\t\t\t\tedited?: boolean;\n\t\t\t\t};\n\t\t\t};\n\n\t\t\tif (isPartial) {\n\t\t\t\tif (details?.phase === \"curator-fallback\") {\n\t\t\t\t\tconst lines = [theme.fg(\"warning\", \"Open the search curator manually:\")];\n\t\t\t\t\tif (details?.curatorUrl) lines.push(theme.fg(\"muted\", ` ${details.curatorUrl}`));\n\t\t\t\t\tif (details?.browserOpenError) lines.push(theme.fg(\"dim\", ` auto-open failed: ${details.browserOpenError}`));\n\t\t\t\t\tconst timeout = typeof details?.timeoutSeconds === \"number\" ? details.timeoutSeconds : undefined;\n\t\t\t\t\tconst shortcut = typeof details?.shortcut === \"string\" ? details.shortcut : curateKey;\n\t\t\t\t\tlines.push(theme.fg(\"dim\", timeout ? ` auto-submits after ${timeout}s idle; ${shortcut} reopens` : ` ${shortcut} reopens`));\n\t\t\t\t\treturn new Text(lines.join(\"\\n\"), 0, 0);\n\t\t\t\t}\n\t\t\t\tif (details?.phase === \"curating\" || details?.phase === \"waiting-for-approval\" || details?.phase === \"generating-summary\") {\n\t\t\t\t\tconst phaseText = details?.phase === \"generating-summary\"\n\t\t\t\t\t\t? \"generating summary draft...\"\n\t\t\t\t\t\t: details?.phase === \"waiting-for-approval\"\n\t\t\t\t\t\t\t? \"summary draft ready; approve in browser...\"\n\t\t\t\t\t\t\t: \"waiting for summary approval in browser...\";\n\t\t\t\t\tconst lines = [theme.fg(\"accent\", phaseText)];\n\t\t\t\t\tif (details?.curatorUrl) {\n\t\t\t\t\t\tlines.push(theme.fg(\"muted\", ` ${details.curatorUrl}`));\n\t\t\t\t\t}\n\t\t\t\t\tconst timeout = typeof details?.timeoutSeconds === \"number\" ? details.timeoutSeconds : undefined;\n\t\t\t\t\tconst shortcut = typeof details?.shortcut === \"string\" ? details.shortcut : curateKey;\n\t\t\t\t\tif (timeout) {\n\t\t\t\t\t\tlines.push(theme.fg(\"dim\", ` auto-submits after ${timeout}s idle; ${shortcut} reopens`));\n\t\t\t\t\t} else {\n\t\t\t\t\t\tlines.push(theme.fg(\"dim\", ` ${shortcut} reopens`));\n\t\t\t\t\t}\n\t\t\t\t\treturn new Text(lines.join(\"\\n\"), 0, 0);\n\t\t\t\t}\n\t\t\t\tif (details?.phase === \"searching\") {\n\t\t\t\t\tconst progress = details?.progress ?? 0;\n\t\t\t\t\tconst bar = \"\\u2588\".repeat(Math.floor(progress * 10)) + \"\\u2591\".repeat(10 - Math.floor(progress * 10));\n\t\t\t\t\tconst query = details?.currentQuery || \"\";\n\t\t\t\t\tconst display = query.length > 40 ? query.slice(0, 37) + \"...\" : query;\n\t\t\t\t\treturn new Text(theme.fg(\"accent\", `[${bar}] ${display}`), 0, 0);\n\t\t\t\t}\n\t\t\t\tconst progress = details?.progress ?? 0;\n\t\t\t\tconst bar = \"\\u2588\".repeat(Math.floor(progress * 10)) + \"\\u2591\".repeat(10 - Math.floor(progress * 10));\n\t\t\t\treturn new Text(theme.fg(\"accent\", `[${bar}] ${details?.phase || \"searching\"}`), 0, 0);\n\t\t\t}\n\n\t\t\tif (details?.error) {\n\t\t\t\t// Expandable Ctrl+O diagnostics: which queries completed, per-query errors,\n\t\t\t\t// browser connection state, cancel reason. See render-search-error.ts.\n\t\t\t\tconst plan = buildSearchErrorPlan(details as SearchErrorDetails);\n\t\t\t\tif (plan) return renderSearchErrorPlan(plan, expanded, theme);\n\t\t\t\treturn new Text(theme.fg(\"error\", `Error: ${details.error}`), 0, 0);\n\t\t\t}\n\n\t\t\t// lunr: collapsed = status/count only. Expanded = query list, no answers/sources/summary.\n\t\t\tconst queryList = collectSearchQueries(context?.args);\n\t\t\tconst fallbackQueries = (details?.curatedQueries ?? []).map((cq) => cq.query).filter(Boolean);\n\t\t\tconst chrome = formatSearchChrome({\n\t\t\t\tqueries: queryList.length > 0 ? queryList : fallbackQueries,\n\t\t\t\tdetails,\n\t\t\t\texpanded,\n\t\t\t});\n\t\t\tconst statusColor = expanded ? \"accent\" : \"success\";\n\t\t\treturn new Text(chrome.result.map((line) => theme.fg(statusColor, line)).join(\"\\n\"), 0, 0);\n\t\t},\n\t});\n\n\tpi.registerTool({\n\t\tname: \"fetch_content\",\n\t\tlabel: \"Fetch Content\",\n\t\tdescription: \"Fetch URL(s) and extract readable content as markdown. Supports YouTube video transcripts (with thumbnail), GitHub repository contents, and local video files (with frame thumbnail). Video frames can be extracted via timestamp/range or sampled across the entire video with frames alone. Falls back to Gemini for pages that block bots or fail Readability extraction. For YouTube and video files: ALWAYS pass the user's specific question via the prompt parameter — this directs the AI to focus on that aspect of the video, producing much better results than a generic extraction. Content is always stored and can be retrieved with get_search_content.\",\n\t\tpromptSnippet:\n\t\t\t\"Use to extract readable content from URL(s), YouTube, GitHub repos, or local videos. For video questions, pass the user's exact question in prompt.\",\n\t\tparameters: Type.Object({\n\t\t\turl: Type.Optional(Type.String({ description: \"Single URL to fetch\" })),\n\t\t\turls: Type.Optional(Type.Array(Type.String(), { description: \"Multiple URLs (parallel)\" })),\n\t\t\tforceClone: Type.Optional(Type.Boolean({\n\t\t\t\tdescription: \"Force cloning large GitHub repositories that exceed the size threshold\",\n\t\t\t})),\n\t\t\tprompt: Type.Optional(Type.String({\n\t\t\t\tdescription: \"Question or instruction for video analysis (YouTube and video files). Pass the user's specific question here — e.g. 'describe the book shown at the advice for beginners section'. Without this, a generic transcript extraction is used which may miss what the user is asking about.\",\n\t\t\t})),\n\t\t\ttimestamp: Type.Optional(Type.String({\n\t\t\t\tdescription: \"Extract video frame(s) at a timestamp or time range. Single: '1:23:45', '23:45', or '85' (seconds). Range: '23:41-25:00' extracts evenly-spaced frames across that span (default 6). Use frames with ranges to control density; single+frames uses a fixed 5s interval. YouTube requires yt-dlp + ffmpeg; local videos require ffmpeg. Use a range when you know the approximate area but not the exact moment — you'll get a contact sheet to visually identify the right frame.\",\n\t\t\t})),\n\t\t\tframes: Type.Optional(Type.Integer({\n\t\t\t\tminimum: 1,\n\t\t\t\tmaximum: 12,\n\t\t\t\tdescription: \"Number of frames to extract. Use with timestamp range for custom density, with single timestamp to get N frames at 5s intervals, or alone to sample across the entire video. Requires yt-dlp + ffmpeg for YouTube, ffmpeg for local video.\",\n\t\t\t})),\n\t\t\tmodel: Type.Optional(Type.String({\n\t\t\t\tdescription: \"Override the Gemini model for video/YouTube analysis (e.g. 'gemini-2.5-flash', 'gemini-3-flash-preview'). Defaults to config or gemini-3-flash-preview.\",\n\t\t\t})),\n\t\t}),\n\n\t\tasync execute(_toolCallId, params, signal, onUpdate) {\n\t\t\tconst { urlList, options } = normalizeFetchContentParams(params);\n\t\t\tif (urlList.length === 0) {\n\t\t\t\treturn {\n\t\t\t\t\tcontent: [{ type: \"text\", text: \"Error: No URL provided.\" }],\n\t\t\t\t\tdetails: { error: \"No URL provided\" },\n\t\t\t\t};\n\t\t\t}\n\n\t\t\tonUpdate?.({\n\t\t\t\tcontent: [{ type: \"text\", text: `Fetching ${urlList.length} URL(s)...` }],\n\t\t\t\tdetails: { phase: \"fetch\", progress: 0 },\n\t\t\t});\n\n\t\t\tconst fetchResults = await fetchAllContent(urlList, signal, options);\n\t\t\tconst successful = fetchResults.filter((r) => !r.error).length;\n\t\t\tconst totalChars = fetchResults.reduce((sum, r) => sum + r.content.length, 0);\n\n\t\t\t// ALWAYS store results (even for single URL)\n\t\t\tconst responseId = generateId();\n\t\t\tconst data: StoredSearchData = {\n\t\t\t\tid: responseId,\n\t\t\t\ttype: \"fetch\",\n\t\t\t\ttimestamp: Date.now(),\n\t\t\t\turls: stripThumbnails(fetchResults),\n\t\t\t};\n\t\t\tstoreResult(responseId, data);\n\t\t\tpi.appendEntry(\"web-search-results\", data);\n\n\t\t\t// Single URL: return content directly (possibly truncated) with responseId\n\t\t\tif (urlList.length === 1) {\n\t\t\t\tconst result = fetchResults[0];\n\t\t\t\tif (result.error) {\n\t\t\t\t\treturn {\n\t\t\t\t\t\tcontent: [{ type: \"text\", text: `Error: ${result.error}` }],\n\t\t\t\t\t\tdetails: { urls: urlList, urlCount: 1, successful: 0, error: result.error, responseId, prompt: params.prompt, timestamp: params.timestamp, frames: params.frames },\n\t\t\t\t\t};\n\t\t\t\t}\n\n\t\t\t\tconst fullLength = result.content.length;\n\t\t\t\tconst truncated = fullLength > MAX_INLINE_CONTENT;\n\t\t\t\tlet output = truncated\n\t\t\t\t\t? result.content.slice(0, MAX_INLINE_CONTENT) + \"\\n\\n[Content truncated...]\"\n\t\t\t\t\t: result.content;\n\n\t\t\t\tif (truncated) {\n\t\t\t\t\toutput += `\\n\\n---\\nShowing ${MAX_INLINE_CONTENT} of ${fullLength} chars. ` +\n\t\t\t\t\t\t`Use get_search_content({ responseId: \"${responseId}\", urlIndex: 0 }) for full content.`;\n\t\t\t\t}\n\n\t\t\t\tconst content: Array<{ type: string; text?: string; data?: string; mimeType?: string }> = [];\n\t\t\t\tif (result.frames?.length) {\n\t\t\t\t\tfor (const frame of result.frames) {\n\t\t\t\t\t\tcontent.push({ type: \"image\", data: frame.data, mimeType: frame.mimeType });\n\t\t\t\t\t\tcontent.push({ type: \"text\", text: `Frame at ${frame.timestamp}` });\n\t\t\t\t\t}\n\t\t\t\t} else if (result.thumbnail) {\n\t\t\t\t\tcontent.push({ type: \"image\", data: result.thumbnail.data, mimeType: result.thumbnail.mimeType });\n\t\t\t\t}\n\t\t\t\tcontent.push({ type: \"text\", text: output });\n\n\t\t\t\tconst imageCount = (result.frames?.length ?? 0) + (result.thumbnail ? 1 : 0);\n\t\t\t\treturn {\n\t\t\t\t\tcontent,\n\t\t\t\t\tdetails: {\n\t\t\t\t\t\turls: urlList,\n\t\t\t\t\t\turlCount: 1,\n\t\t\t\t\t\tsuccessful: 1,\n\t\t\t\t\t\ttotalChars: fullLength,\n\t\t\t\t\t\ttitle: result.title,\n\t\t\t\t\t\tresponseId,\n\t\t\t\t\t\ttruncated,\n\t\t\t\t\t\thasImage: imageCount > 0,\n\t\t\t\t\t\timageCount,\n\t\t\t\t\t\tprompt: params.prompt,\n\t\t\t\t\t\ttimestamp: params.timestamp,\n\t\t\t\t\t\tframes: params.frames,\n\t\t\t\t\t\tduration: result.duration,\n\t\t\t\t\t},\n\t\t\t\t};\n\t\t\t}\n\n\t\t\t// Multi-URL: existing behavior (summary + responseId)\n\t\t\tlet output = \"## Fetched URLs\\n\\n\";\n\t\t\tfor (const { url, title, content, error } of fetchResults) {\n\t\t\t\tif (error) {\n\t\t\t\t\toutput += `- ${url}: Error - ${error}\\n`;\n\t\t\t\t} else {\n\t\t\t\t\toutput += `- ${title || url} (${content.length} chars)\\n`;\n\t\t\t\t}\n\t\t\t}\n\t\t\toutput += `\\n---\\nUse get_search_content({ responseId: \"${responseId}\", urlIndex: 0 }) to retrieve full content.`;\n\n\t\t\treturn {\n\t\t\t\tcontent: [{ type: \"text\", text: output }],\n\t\t\t\tdetails: { urls: urlList, urlCount: urlList.length, successful, totalChars, responseId },\n\t\t\t};\n\t\t},\n\n\t\trenderCall(args, theme, context) {\n\t\t\t// lunr: collapsed header is one line (title · count). URL list lives in expanded renderResult.\n\t\t\tconst urlList = collectFetchUrls(args as { url?: string; urls?: string[] });\n\t\t\tconst compact = Boolean(context && !context.isPartial && !context.expanded && !context.isError);\n\t\t\tconst chrome = formatFetchChrome({\n\t\t\t\turls: urlList,\n\t\t\t\tdetails: compact ? context?.result?.details : undefined,\n\t\t\t\texpanded: false,\n\t\t\t});\n\t\t\tconst call = formatFetchCallTitle(urlList);\n\t\t\tif (call.empty) {\n\t\t\t\treturn new Text(theme.fg(\"toolTitle\", theme.bold(\"fetch \")) + theme.fg(\"error\", \"(no URL)\"), 0, 0);\n\t\t\t}\n\t\t\treturn new Text(theme.fg(\"toolTitle\", theme.bold(chrome.call)), 0, 0);\n\t\t},\n\n\t\trenderResult(result, { expanded, isPartial }, theme, context) {\n\t\t\tconst details = result.details as {\n\t\t\t\turlCount?: number;\n\t\t\t\tsuccessful?: number;\n\t\t\t\ttotalChars?: number;\n\t\t\t\terror?: string;\n\t\t\t\ttitle?: string;\n\t\t\t\ttruncated?: boolean;\n\t\t\t\tresponseId?: string;\n\t\t\t\tphase?: string;\n\t\t\t\tprogress?: number;\n\t\t\t\thasImage?: boolean;\n\t\t\t\timageCount?: number;\n\t\t\t\tprompt?: string;\n\t\t\t\ttimestamp?: string;\n\t\t\t\tframes?: number;\n\t\t\t\tduration?: number;\n\t\t\t};\n\n\t\t\tif (isPartial) {\n\t\t\t\tconst progress = details?.progress ?? 0;\n\t\t\t\tconst bar = \"\\u2588\".repeat(Math.floor(progress * 10)) + \"\\u2591\".repeat(10 - Math.floor(progress * 10));\n\t\t\t\treturn new Text(theme.fg(\"accent\", `[${bar}] ${details?.phase || \"fetching\"}`), 0, 0);\n\t\t\t}\n\n\t\t\tif (details?.error) {\n\t\t\t\tconst fd = details as typeof details & { urls?: string[] };\n\t\t\t\tconst extras: string[] = [];\n\t\t\t\tif (typeof fd.urlCount === \"number\" || typeof fd.successful === \"number\") {\n\t\t\t\t\textras.push(`urls: ${fd.successful ?? 0}/${fd.urlCount ?? 0} succeeded`);\n\t\t\t\t}\n\t\t\t\tif (fd.responseId) extras.push(`response id: ${fd.responseId}`);\n\t\t\t\tif (fd.urls && fd.urls.length > 0) {\n\t\t\t\t\tfor (const u of fd.urls.slice(0, 8)) extras.push(` \\u25b8 ${u}`);\n\t\t\t\t\tif (fd.urls.length > 8) extras.push(` ... and ${fd.urls.length - 8} more`);\n\t\t\t\t}\n\t\t\t\tconst plan = buildSearchErrorPlan({ error: details.error, extraLines: extras });\n\t\t\t\tif (plan) return renderSearchErrorPlan(plan, expanded, theme);\n\t\t\t\treturn new Text(theme.fg(\"error\", `Error: ${details.error}`), 0, 0);\n\t\t\t}\n\n\t\t\t// lunr: collapsed = count/status only. Expanded = URL list, no markdown/prompt/frames.\n\t\t\tconst urlList = collectFetchUrls(context?.args);\n\t\t\tconst fallbackUrls = Array.isArray((details as { urls?: string[] })?.urls)\n\t\t\t\t? (details as { urls?: string[] }).urls\n\t\t\t\t: [];\n\t\t\tconst chrome = formatFetchChrome({\n\t\t\t\turls: urlList.length > 0 ? urlList : fallbackUrls,\n\t\t\t\tdetails,\n\t\t\t\texpanded,\n\t\t\t});\n\t\t\tconst statusColor = expanded ? \"accent\" : ((details?.successful ?? 0) > 0 || details?.urlCount === 1 ? \"success\" : \"error\");\n\t\t\treturn new Text(chrome.result.map((line) => theme.fg(statusColor, line)).join(\"\\n\"), 0, 0);\n\t\t},\n\t});\n\n\tpi.registerTool({\n\t\tname: \"get_search_content\",\n\t\tlabel: \"Get Search Content\",\n\t\tdescription: \"Retrieve full content from a previous web_search or fetch_content call.\",\n\t\tpromptSnippet:\n\t\t\t\"Use after web_search/fetch_content when full stored content is needed via responseId plus query/url selectors.\",\n\t\tparameters: Type.Object({\n\t\t\tresponseId: Type.String({ description: \"The responseId from web_search or fetch_content\" }),\n\t\t\tquery: Type.Optional(Type.String({ description: \"Get content for this query (web_search)\" })),\n\t\t\tqueryIndex: Type.Optional(Type.Number({ description: \"Get content for query at index\" })),\n\t\t\turl: Type.Optional(Type.String({ description: \"Get content for this URL\" })),\n\t\t\turlIndex: Type.Optional(Type.Number({ description: \"Get content for URL at index\" })),\n\t\t}),\n\n\t\tasync execute(_toolCallId, params) {\n\t\t\tconst data = getResult(params.responseId);\n\t\t\tif (!data) {\n\t\t\t\treturn {\n\t\t\t\t\tcontent: [{ type: \"text\", text: `Error: No stored results for \"${params.responseId}\"` }],\n\t\t\t\t\tdetails: { error: \"Not found\", responseId: params.responseId },\n\t\t\t\t};\n\t\t\t}\n\n\t\t\tif (data.type === \"search\" && data.queries) {\n\t\t\t\tlet queryData: QueryResultData | undefined;\n\n\t\t\t\tif (params.query !== undefined) {\n\t\t\t\t\tqueryData = data.queries.find((q) => q.query === params.query);\n\t\t\t\t\tif (!queryData) {\n\t\t\t\t\t\tconst available = data.queries.map((q) => `\"${q.query}\"`).join(\", \");\n\t\t\t\t\t\treturn {\n\t\t\t\t\t\t\tcontent: [{ type: \"text\", text: `Query \"${params.query}\" not found. Available: ${available}` }],\n\t\t\t\t\t\t\tdetails: { error: \"Query not found\" },\n\t\t\t\t\t\t};\n\t\t\t\t\t}\n\t\t\t\t} else if (params.queryIndex !== undefined) {\n\t\t\t\t\tqueryData = data.queries[params.queryIndex];\n\t\t\t\t\tif (!queryData) {\n\t\t\t\t\t\treturn {\n\t\t\t\t\t\t\tcontent: [{ type: \"text\", text: `Index ${params.queryIndex} out of range (0-${data.queries.length - 1})` }],\n\t\t\t\t\t\t\tdetails: { error: \"Index out of range\" },\n\t\t\t\t\t\t};\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tconst available = data.queries.map((q, i) => `${i}: \"${q.query}\"`).join(\", \");\n\t\t\t\t\treturn {\n\t\t\t\t\t\tcontent: [{ type: \"text\", text: `Specify query or queryIndex. Available: ${available}` }],\n\t\t\t\t\t\tdetails: { error: \"No query specified\" },\n\t\t\t\t\t};\n\t\t\t\t}\n\n\t\t\t\tif (queryData.error) {\n\t\t\t\t\treturn {\n\t\t\t\t\t\tcontent: [{ type: \"text\", text: `Error for \"${queryData.query}\": ${queryData.error}` }],\n\t\t\t\t\t\tdetails: { error: queryData.error, query: queryData.query },\n\t\t\t\t\t};\n\t\t\t\t}\n\n\t\t\t\treturn {\n\t\t\t\t\tcontent: [{ type: \"text\", text: formatFullResults(queryData) }],\n\t\t\t\t\tdetails: { query: queryData.query, resultCount: queryData.results.length },\n\t\t\t\t};\n\t\t\t}\n\n\t\t\tif (data.type === \"fetch\" && data.urls) {\n\t\t\t\tlet urlData: ExtractedContent | undefined;\n\n\t\t\t\tif (params.url !== undefined) {\n\t\t\t\t\turlData = data.urls.find((u) => u.url === params.url);\n\t\t\t\t\tif (!urlData) {\n\t\t\t\t\t\tconst available = data.urls.map((u) => u.url).join(\"\\n \");\n\t\t\t\t\t\treturn {\n\t\t\t\t\t\t\tcontent: [{ type: \"text\", text: `URL not found. Available:\\n ${available}` }],\n\t\t\t\t\t\t\tdetails: { error: \"URL not found\" },\n\t\t\t\t\t\t};\n\t\t\t\t\t}\n\t\t\t\t} else if (params.urlIndex !== undefined) {\n\t\t\t\t\turlData = data.urls[params.urlIndex];\n\t\t\t\t\tif (!urlData) {\n\t\t\t\t\t\treturn {\n\t\t\t\t\t\t\tcontent: [{ type: \"text\", text: `Index ${params.urlIndex} out of range (0-${data.urls.length - 1})` }],\n\t\t\t\t\t\t\tdetails: { error: \"Index out of range\" },\n\t\t\t\t\t\t};\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tconst available = data.urls.map((u, i) => `${i}: ${u.url}`).join(\"\\n \");\n\t\t\t\t\treturn {\n\t\t\t\t\t\tcontent: [{ type: \"text\", text: `Specify url or urlIndex. Available:\\n ${available}` }],\n\t\t\t\t\t\tdetails: { error: \"No URL specified\" },\n\t\t\t\t\t};\n\t\t\t\t}\n\n\t\t\t\tif (urlData.error) {\n\t\t\t\t\treturn {\n\t\t\t\t\t\tcontent: [{ type: \"text\", text: `Error for ${urlData.url}: ${urlData.error}` }],\n\t\t\t\t\t\tdetails: { error: urlData.error, url: urlData.url },\n\t\t\t\t\t};\n\t\t\t\t}\n\n\t\t\t\treturn {\n\t\t\t\t\tcontent: [{ type: \"text\", text: `# ${urlData.title}\\n\\n${urlData.content}` }],\n\t\t\t\t\tdetails: { url: urlData.url, title: urlData.title, contentLength: urlData.content.length },\n\t\t\t\t};\n\t\t\t}\n\n\t\t\treturn {\n\t\t\t\tcontent: [{ type: \"text\", text: \"Invalid stored data format\" }],\n\t\t\t\tdetails: { error: \"Invalid data\" },\n\t\t\t};\n\t\t},\n\n\t\trenderCall(args, theme) {\n\t\t\tconst { responseId, query, queryIndex, url, urlIndex } = args as {\n\t\t\t\tresponseId: string;\n\t\t\t\tquery?: string;\n\t\t\t\tqueryIndex?: number;\n\t\t\t\turl?: string;\n\t\t\t\turlIndex?: number;\n\t\t\t};\n\t\t\tlet target = \"\";\n\t\t\tif (query) target = `query=\"${query}\"`;\n\t\t\telse if (queryIndex !== undefined) target = `queryIndex=${queryIndex}`;\n\t\t\telse if (url) target = url.length > 30 ? url.slice(0, 27) + \"...\" : url;\n\t\t\telse if (urlIndex !== undefined) target = `urlIndex=${urlIndex}`;\n\t\t\treturn new Text(theme.fg(\"toolTitle\", theme.bold(\"get_content \")) + theme.fg(\"accent\", target || responseId.slice(0, 8)), 0, 0);\n\t\t},\n\n\t\trenderResult(result, { expanded }, theme) {\n\t\t\tconst details = result.details as {\n\t\t\t\terror?: string;\n\t\t\t\tquery?: string;\n\t\t\t\turl?: string;\n\t\t\t\ttitle?: string;\n\t\t\t\tresultCount?: number;\n\t\t\t\tcontentLength?: number;\n\t\t\t};\n\n\t\t\tif (details?.error) {\n\t\t\t\tconst extras: string[] = [];\n\t\t\t\tif (details.query) extras.push(`query: ${details.query}`);\n\t\t\t\tif (details.url) extras.push(`url: ${details.url}`);\n\t\t\t\telse if (details.title) extras.push(`resource: ${details.title}`);\n\t\t\t\tconst plan = buildSearchErrorPlan({ error: details.error, extraLines: extras });\n\t\t\t\tif (plan) return renderSearchErrorPlan(plan, expanded, theme);\n\t\t\t\treturn new Text(theme.fg(\"error\", `Error: ${details.error}`), 0, 0);\n\t\t\t}\n\n\t\t\tlet statusLine: string;\n\t\t\tif (details?.query) {\n\t\t\t\tstatusLine = theme.fg(\"success\", `\"${details.query}\"`) + theme.fg(\"muted\", ` (${details.resultCount} results)`);\n\t\t\t} else {\n\t\t\t\tstatusLine = theme.fg(\"success\", details?.title || \"Content\") + theme.fg(\"muted\", ` (${details?.contentLength ?? 0} chars)`);\n\t\t\t}\n\n\t\t\tif (!expanded) {\n\t\t\t\treturn new Text(statusLine, 0, 0);\n\t\t\t}\n\n\t\t\tconst textContent = result.content.find((c) => c.type === \"text\")?.text || \"\";\n\t\t\tconst preview = textContent.length > 500 ? textContent.slice(0, 500) + \"...\" : textContent;\n\t\t\treturn new Text(statusLine + \"\\n\" + theme.fg(\"dim\", preview), 0, 0);\n\t\t},\n\t});\n\n\tpi.registerCommand(\"websearch\", {\n\t\tdescription: \"Open web search curator\",\n\t\thandler: async (args, ctx) => {\n\t\t\tconst sessionToken = randomUUID();\n\t\t\tconst commandCallId = `cmd:${sessionToken}`;\n\t\t\tcloseCurator(commandCallId);\n\n\t\t\tconst raw = args.trim();\n\t\t\tconst queries = raw.length > 0\n\t\t\t\t? normalizeQueryList(raw.split(\",\"))\n\t\t\t\t: [];\n\n\t\t\tlet bootstrap: CuratorBootstrap;\n\t\t\ttry {\n\t\t\t\tbootstrap = await loadCuratorBootstrap(undefined, ctx);\n\t\t\t} catch (err) {\n\t\t\t\tconst message = err instanceof Error ? err.message : String(err);\n\t\t\t\tctx.ui.notify(`Failed to load web search config: ${message}`, \"error\");\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tconst availableProviders = bootstrap.availableProviders;\n\t\t\tconst initialProvider = bootstrap.defaultProvider;\n\t\t\tconst curatorTimeoutSeconds = bootstrap.timeoutSeconds;\n\t\t\tlet currentProvider = initialProvider;\n\t\t\tconst rawSearchProvider = normalizeProviderInput(loadConfig().provider ?? \"auto\") ?? \"auto\";\n\t\t\tlet currentSearchProvider = rawSearchProvider === \"auto\" ? \"auto\" : initialProvider;\n\t\t\tconst summaryContext: SummaryGenerationContext = {\n\t\t\t\tmodel: ctx.model,\n\t\t\t\tmodelRegistry: ctx.modelRegistry,\n\t\t\t\tcwd: ctx.cwd,\n\t\t\t\tisProjectTrusted: () => ctx.isProjectTrusted(),\n\t\t\t};\n\t\t\tconst summaryModelChoices = await loadSummaryModelChoices(summaryContext);\n\n\t\t\tctx.ui.notify(\"Opening web search curator...\", \"info\");\n\n\t\t\tconst collected = new Map<number, QueryResultData>();\n\t\t\tconst searchAbort = new AbortController();\n\t\t\tlet aborted = false;\n\t\t\tlet commandHandle: CuratorServerHandle | null = null;\n\t\t\tconst isCommandActive = () => commandHandle !== null && activeCurators.get(commandCallId) === commandHandle;\n\n\t\t\tfunction sendFollowUpFromReturn(payload: ReturnType<typeof buildSearchReturn>) {\n\t\t\t\tpi.sendMessage({\n\t\t\t\t\tcustomType: \"web-search-results\",\n\t\t\t\t\tcontent: payload.content,\n\t\t\t\t\tdisplay: true,\n\t\t\t\t\tdetails: payload.details,\n\t\t\t\t}, { triggerTurn: true, deliverAs: \"followUp\" });\n\t\t\t}\n\n\t\t\ttry {\n\t\t\t\tconst handle = await startCuratorServer(\n\t\t\t\t\t{\n\t\t\t\t\t\tqueries,\n\t\t\t\t\t\tsessionToken,\n\t\t\t\t\t\ttimeout: curatorTimeoutSeconds,\n\t\t\t\t\t\tavailableProviders,\n\t\t\t\t\t\tdefaultProvider: initialProvider,\n\t\t\t\t\t\tsearchProvider: currentSearchProvider,\n\t\t\t\t\t\tsummaryModels: summaryModelChoices.summaryModels,\n\t\t\t\t\t\tdefaultSummaryModel: summaryModelChoices.defaultSummaryModel,\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tasync onSummarize(selectedQueryIndices, summarizeSignal, model, feedback) {\n\t\t\t\t\t\t\tif (commandHandle && !isCommandActive()) {\n\t\t\t\t\t\t\t\tthrow new Error(\"Curator session is no longer active.\");\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\treturn generateSummaryForSelectedIndices(\n\t\t\t\t\t\t\t\tselectedQueryIndices,\n\t\t\t\t\t\t\t\tcollected,\n\t\t\t\t\t\t\t\tsummaryContext,\n\t\t\t\t\t\t\t\tsummarizeSignal,\n\t\t\t\t\t\t\t\tmodel,\n\t\t\t\t\t\t\t\tfeedback,\n\t\t\t\t\t\t\t);\n\t\t\t\t\t\t},\n\t\t\t\t\t\tonSubmit(payload) {\n\t\t\t\t\t\t\tif (commandHandle && !isCommandActive()) return;\n\t\t\t\t\t\t\taborted = true;\n\t\t\t\t\t\t\tsearchAbort.abort();\n\t\t\t\t\t\t\tconst filtered = payload.selectedQueryIndices.length > 0\n\t\t\t\t\t\t\t\t? filterByQueryIndices(payload.selectedQueryIndices, collected)\n\t\t\t\t\t\t\t\t: collectAllResultsAndUrls(collected);\n\t\t\t\t\t\t\tconst base: SearchReturnOptions = {\n\t\t\t\t\t\t\t\tqueryList: filtered.results.map(r => r.query),\n\t\t\t\t\t\t\t\tresults: filtered.results,\n\t\t\t\t\t\t\t\turls: filtered.urls,\n\t\t\t\t\t\t\t\tincludeContent: false,\n\t\t\t\t\t\t\t\tcurated: true,\n\t\t\t\t\t\t\t\tcuratedFrom: collected.size,\n\t\t\t\t\t\t\t};\n\t\t\t\t\t\t\tif (!payload.rawResults) {\n\t\t\t\t\t\t\t\tconst resolvedSummary = resolveSummaryForSubmit(payload, collected);\n\t\t\t\t\t\t\t\tbase.workflow = \"summary-review\";\n\t\t\t\t\t\t\t\tbase.approvedSummary = resolvedSummary.approvedSummary;\n\t\t\t\t\t\t\t\tbase.summaryMeta = resolvedSummary.summaryMeta;\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tsendFollowUpFromReturn(buildSearchReturn(base));\n\t\t\t\t\t\t\tcloseCurator(commandCallId);\n\t\t\t\t\t\t},\n\t\t\t\t\t\tonCancel(reason) {\n\t\t\t\t\t\t\tif (commandHandle && !isCommandActive()) return;\n\t\t\t\t\t\t\taborted = true;\n\t\t\t\t\t\t\tsearchAbort.abort();\n\t\t\t\t\t\t\tif (reason === \"timeout\") {\n\t\t\t\t\t\t\t\tconst all = collectAllResultsAndUrls(collected);\n\t\t\t\t\t\t\t\tconst resolvedSummary = resolveSummaryForSubmit({ selectedQueryIndices: [], summary: undefined, summaryMeta: undefined }, collected);\n\t\t\t\t\t\t\t\tsendFollowUpFromReturn(buildSearchReturn({\n\t\t\t\t\t\t\t\t\tqueryList: all.results.map(r => r.query),\n\t\t\t\t\t\t\t\t\tresults: all.results,\n\t\t\t\t\t\t\t\t\turls: all.urls,\n\t\t\t\t\t\t\t\t\tincludeContent: false,\n\t\t\t\t\t\t\t\t\tcurated: true,\n\t\t\t\t\t\t\t\t\tcuratedFrom: collected.size,\n\t\t\t\t\t\t\t\t\tworkflow: \"summary-review\",\n\t\t\t\t\t\t\t\t\tapprovedSummary: resolvedSummary.approvedSummary,\n\t\t\t\t\t\t\t\t\tsummaryMeta: resolvedSummary.summaryMeta,\n\t\t\t\t\t\t\t\t}));\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tcloseCurator(commandCallId);\n\t\t\t\t\t\t},\n\t\t\t\t\t\tonProviderChange(provider) {\n\t\t\t\t\t\t\tif (commandHandle && !isCommandActive()) return;\n\t\t\t\t\t\t\tconst normalized = normalizeProviderInput(provider);\n\t\t\t\t\t\t\tif (!normalized || normalized === \"auto\") return;\n\t\t\t\t\t\t\tcurrentProvider = normalized;\n\t\t\t\t\t\t\tcurrentSearchProvider = normalized;\n\t\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\t\tsaveConfig({ provider: normalized });\n\t\t\t\t\t\t\t} catch (err) {\n\t\t\t\t\t\t\t\tconst message = err instanceof Error ? err.message : String(err);\n\t\t\t\t\t\t\t\tconsole.error(`Failed to persist default provider: ${message}`);\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t},\n\t\t\t\t\t\tasync onAddSearch(query, queryIndex, provider) {\n\t\t\t\t\t\t\tif (commandHandle && !isCommandActive()) {\n\t\t\t\t\t\t\t\tthrow new Error(\"Curator session is no longer active.\");\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tconst normalizedProvider = normalizeProviderInput(provider);\n\t\t\t\t\t\t\tconst requestedProvider = !normalizedProvider || normalizedProvider === \"auto\"\n\t\t\t\t\t\t\t\t? currentSearchProvider\n\t\t\t\t\t\t\t\t: normalizedProvider;\n\t\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\t\tconst { answer, results, provider: actualProvider } = await search(query, {\n\t\t\t\t\t\t\t\t\tprovider: requestedProvider,\n\t\t\t\t\t\t\t\t\tsignal: searchAbort.signal,\n\t\t\t\t\t\t\t\t\textensionContext: ctx,\n\t\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\t\tif (commandHandle && !isCommandActive()) {\n\t\t\t\t\t\t\t\t\tthrow new Error(\"Curator session is no longer active.\");\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\tcollected.set(queryIndex, { query, answer, results, error: null, provider: actualProvider });\n\t\t\t\t\t\t\t\treturn {\n\t\t\t\t\t\t\t\t\tanswer,\n\t\t\t\t\t\t\t\t\tresults: results.map(r => ({ title: r.title, url: r.url, domain: extractDomain(r.url) })),\n\t\t\t\t\t\t\t\t\tprovider: actualProvider,\n\t\t\t\t\t\t\t\t};\n\t\t\t\t\t\t\t} catch (err) {\n\t\t\t\t\t\t\t\tconst message = err instanceof Error ? err.message : String(err);\n\t\t\t\t\t\t\t\tif (!commandHandle || isCommandActive()) {\n\t\t\t\t\t\t\t\t\tcollected.set(queryIndex, { query, answer: \"\", results: [], error: message, provider: requestedProvider });\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\tthrow err;\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t},\n\t\t\t\t\t\tasync onRewriteQuery(query, rewriteSignal) {\n\t\t\t\t\t\t\tif (commandHandle && !isCommandActive()) {\n\t\t\t\t\t\t\t\tthrow new Error(\"Curator session is no longer active.\");\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\treturn rewriteSearchQuery(query, summaryContext, rewriteSignal);\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t);\n\n\t\t\t\tcommandHandle = handle;\n\t\t\t\tactiveCurators.set(commandCallId, handle);\n\t\t\t\tconst open = platform() === \"darwin\" ? await getGlimpseOpen() : null;\n\t\t\t\tlet browserOpenError: string | null = null;\n\t\t\t\tif (open) {\n\t\t\t\t\ttry {\n\t\t\t\t\t\tconst win = openInGlimpse(open, handle.url, \"Search Curator\");\n\t\t\t\t\t\tglimpseWins.set(commandCallId, win);\n\t\t\t\t\t\twin.on(\"closed\", () => {\n\t\t\t\t\t\t\tif (glimpseWins.get(commandCallId) === win) {\n\t\t\t\t\t\t\t\tglimpseWins.delete(commandCallId);\n\t\t\t\t\t\t\t\tcloseCurator(commandCallId);\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t});\n\t\t\t\t\t} catch (err) {\n\t\t\t\t\t\tconst message = err instanceof Error ? err.message : String(err);\n\t\t\t\t\t\tconsole.error(`Failed to open Glimpse curator window: ${message}`);\n\t\t\t\t\t\tglimpseWins.delete(commandCallId);\n\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\tawait openInBrowser(pi, handle.url);\n\t\t\t\t\t\t} catch (browserErr) {\n\t\t\t\t\t\t\tbrowserOpenError = browserErr instanceof Error ? browserErr.message : String(browserErr);\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\ttry {\n\t\t\t\t\t\tawait openInBrowser(pi, handle.url);\n\t\t\t\t\t} catch (browserErr) {\n\t\t\t\t\t\tbrowserOpenError = browserErr instanceof Error ? browserErr.message : String(browserErr);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif (browserOpenError) {\n\t\t\t\t\tconsole.error(`Failed to open curator UI: ${browserOpenError}`);\n\t\t\t\t\tctx.ui.notify(`Search curator is running, but the browser did not open automatically. Open manually: ${handle.url}`, \"info\");\n\t\t\t\t}\n\n\t\t\t\tif (queries.length > 0) {\n\t\t\t\t\t(async () => {\n\t\t\t\t\t\tfor (let qi = 0; qi < queries.length; qi++) {\n\t\t\t\t\t\t\tif (aborted || !isCommandActive()) break;\n\t\t\t\t\t\t\tconst requestedProvider = currentSearchProvider;\n\t\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\t\tconst { answer, results, provider } = await search(queries[qi], {\n\t\t\t\t\t\t\t\t\tprovider: requestedProvider,\n\t\t\t\t\t\t\t\t\tsignal: searchAbort.signal,\n\t\t\t\t\t\t\t\t\textensionContext: ctx,\n\t\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\t\tif (aborted || !isCommandActive()) break;\n\t\t\t\t\t\t\t\thandle.pushResult(qi, {\n\t\t\t\t\t\t\t\t\tanswer,\n\t\t\t\t\t\t\t\t\tresults: results.map(r => ({ title: r.title, url: r.url, domain: extractDomain(r.url) })),\n\t\t\t\t\t\t\t\t\tprovider,\n\t\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\t\tcollected.set(qi, { query: queries[qi], answer, results, error: null, provider });\n\t\t\t\t\t\t\t} catch (err) {\n\t\t\t\t\t\t\t\tif (aborted || !isCommandActive()) break;\n\t\t\t\t\t\t\t\tconst message = err instanceof Error ? err.message : String(err);\n\t\t\t\t\t\t\t\thandle.pushError(qi, message, requestedProvider);\n\t\t\t\t\t\t\t\tcollected.set(qi, { query: queries[qi], answer: \"\", results: [], error: message, provider: requestedProvider });\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t\tif (!aborted && isCommandActive()) handle.searchesDone();\n\t\t\t\t\t})();\n\t\t\t\t} else {\n\t\t\t\t\tif (isCommandActive()) handle.searchesDone();\n\t\t\t\t}\n\t\t\t} catch (err) {\n\t\t\t\tcloseCurator(commandCallId);\n\t\t\t\tconst message = err instanceof Error ? err.message : String(err);\n\t\t\t\tctx.ui.notify(`Failed to open curator: ${message}`, \"error\");\n\t\t\t}\n\t\t},\n\t});\n\n\tpi.registerCommand(\"curator\", {\n\t\tdescription: \"Toggle or configure the search curator workflow\",\n\t\thandler: async (args, ctx) => {\n\t\t\tconst arg = args.trim().toLowerCase();\n\n\t\t\tlet newWorkflow: WebSearchWorkflow;\n\t\t\tif (arg.length === 0) {\n\t\t\t\tconst current = resolveWorkflow(loadConfigForExtensionInit().workflow, true);\n\t\t\t\tnewWorkflow = current === \"none\" ? \"summary-review\" : \"none\";\n\t\t\t} else if (arg === \"on\") {\n\t\t\t\tnewWorkflow = \"summary-review\";\n\t\t\t} else if (arg === \"off\") {\n\t\t\t\tnewWorkflow = \"none\";\n\t\t\t} else if (arg === \"none\" || arg === \"summary-review\" || arg === \"auto-summary\") {\n\t\t\t\tnewWorkflow = arg;\n\t\t\t} else {\n\t\t\t\tctx.ui.notify(`Unknown option: ${arg}. Use on, off, summary-review, or auto-summary.`, \"error\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\ttry {\n\t\t\t\tsaveConfig({ workflow: newWorkflow });\n\t\t\t} catch (err) {\n\t\t\t\tconst message = err instanceof Error ? err.message : String(err);\n\t\t\t\tctx.ui.notify(`Failed to save config: ${message}`, \"error\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst label = newWorkflow === \"none\"\n\t\t\t\t? \"Curator disabled — web_search will return raw results\"\n\t\t\t\t: newWorkflow === \"auto-summary\"\n\t\t\t\t\t? \"Auto-summary enabled — web_search will generate a summary without opening the curator\"\n\t\t\t\t\t: \"Curator enabled — web_search will open curator and auto-generate a summary draft\";\n\t\t\tpi.sendMessage({\n\t\t\t\tcustomType: \"curator-config\",\n\t\t\t\tcontent: [{ type: \"text\", text: label }],\n\t\t\t\tdisplay: true,\n\t\t\t\tdetails: { workflow: newWorkflow },\n\t\t\t}, { triggerTurn: false, deliverAs: \"followUp\" });\n\t\t},\n\t});\n\n\tpi.registerCommand(\"google-account\", {\n\t\tdescription: \"Show the active Google account for Gemini Web\",\n\t\thandler: async () => {\n\t\t\tif (!isBrowserCookieAccessAllowed()) {\n\t\t\t\tpi.sendMessage({\n\t\t\t\t\tcustomType: \"google-account\",\n\t\t\t\t\tcontent: [{ type: \"text\", text: `Gemini Web browser cookie access is disabled. Set allowBrowserCookies: true in ${WEB_SEARCH_CONFIG_PATH} to enable it.` }],\n\t\t\t\t\tdisplay: true,\n\t\t\t\t\tdetails: { available: false, cookieAccessAllowed: false },\n\t\t\t\t}, { triggerTurn: true, deliverAs: \"followUp\" });\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst cookies = await isGeminiWebAvailable();\n\t\t\tif (!cookies) {\n\t\t\t\tpi.sendMessage({\n\t\t\t\t\tcustomType: \"google-account\",\n\t\t\t\t\tcontent: [{ type: \"text\", text: \"Gemini Web is unavailable. Sign into gemini.google.com in a supported Chromium-based browser.\" }],\n\t\t\t\t\tdisplay: true,\n\t\t\t\t\tdetails: { available: false, cookieAccessAllowed: true },\n\t\t\t\t}, { triggerTurn: true, deliverAs: \"followUp\" });\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst email = await getActiveGoogleEmail(cookies);\n\t\t\tconst text = email\n\t\t\t\t? `Active Google account: ${email}`\n\t\t\t\t: \"Gemini Web is available, but the active Google account could not be determined.\";\n\n\t\t\tpi.sendMessage({\n\t\t\t\tcustomType: \"google-account\",\n\t\t\t\tcontent: [{ type: \"text\", text }],\n\t\t\t\tdisplay: true,\n\t\t\t\tdetails: { available: true, email: email ?? null },\n\t\t\t}, { triggerTurn: true, deliverAs: \"followUp\" });\n\t\t},\n\t});\n\n\tpi.registerCommand(\"search\", {\n\t\tdescription: \"Browse stored web search results\",\n\t\thandler: async (_args, ctx) => {\n\t\t\tconst results = getAllResults();\n\n\t\t\tif (results.length === 0) {\n\t\t\t\tctx.ui.notify(\"No stored search results\", \"info\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst options = results.map((r) => {\n\t\t\t\tconst age = Math.floor((Date.now() - r.timestamp) / 60000);\n\t\t\t\tconst ageStr = age < 60 ? `${age}m ago` : `${Math.floor(age / 60)}h ago`;\n\t\t\t\tif (r.type === \"search\" && r.queries) {\n\t\t\t\t\tconst query = r.queries[0]?.query || \"unknown\";\n\t\t\t\t\treturn `[${r.id.slice(0, 6)}] \"${query}\" (${r.queries.length} queries) - ${ageStr}`;\n\t\t\t\t}\n\t\t\t\tif (r.type === \"fetch\" && r.urls) {\n\t\t\t\t\treturn `[${r.id.slice(0, 6)}] ${r.urls.length} URLs fetched - ${ageStr}`;\n\t\t\t\t}\n\t\t\t\treturn `[${r.id.slice(0, 6)}] ${r.type} - ${ageStr}`;\n\t\t\t});\n\n\t\t\tconst choice = await ctx.ui.select(\"Stored Search Results\", options);\n\t\t\tif (!choice) return;\n\n\t\t\tconst match = choice.match(/^\\[([a-z0-9]+)\\]/);\n\t\t\tif (!match) return;\n\n\t\t\tconst selected = results.find((r) => r.id.startsWith(match[1]));\n\t\t\tif (!selected) return;\n\n\t\t\tconst actions = [\"View details\", \"Delete\"];\n\t\t\tconst action = await ctx.ui.select(`Result ${selected.id.slice(0, 6)}`, actions);\n\n\t\t\tif (action === \"Delete\") {\n\t\t\t\tdeleteResult(selected.id);\n\t\t\t\tctx.ui.notify(`Deleted ${selected.id.slice(0, 6)}`, \"info\");\n\t\t\t} else if (action === \"View details\") {\n\t\t\t\tlet info = `ID: ${selected.id}\\nType: ${selected.type}\\nAge: ${Math.floor((Date.now() - selected.timestamp) / 60000)}m\\n\\n`;\n\t\t\t\tif (selected.type === \"search\" && selected.queries) {\n\t\t\t\t\tinfo += \"Queries:\\n\";\n\t\t\t\t\tconst queries = selected.queries.slice(0, 10);\n\t\t\t\t\tfor (const q of queries) {\n\t\t\t\t\t\tinfo += `- \"${q.query}\" (${q.results.length} results)\\n`;\n\t\t\t\t\t}\n\t\t\t\t\tif (selected.queries.length > 10) {\n\t\t\t\t\t\tinfo += `... and ${selected.queries.length - 10} more\\n`;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif (selected.type === \"fetch\" && selected.urls) {\n\t\t\t\t\tinfo += \"URLs:\\n\";\n\t\t\t\t\tconst urls = selected.urls.slice(0, 10);\n\t\t\t\t\tfor (const u of urls) {\n\t\t\t\t\t\tconst urlDisplay = u.url.length > 50 ? u.url.slice(0, 47) + \"...\" : u.url;\n\t\t\t\t\t\tinfo += `- ${urlDisplay} (${u.error || `${u.content.length} chars`})\\n`;\n\t\t\t\t\t}\n\t\t\t\t\tif (selected.urls.length > 10) {\n\t\t\t\t\t\tinfo += `... and ${selected.urls.length - 10} more\\n`;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tctx.ui.notify(info, \"info\");\n\t\t\t}\n\t\t},\n\t});\n}\n"]}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/builtin-extensions/pi-web-access/index.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,YAAY,EAAoB,MAAM,iCAAiC,CAAC;AA+iBtF,MAAM,CAAC,OAAO,WAAW,EAAE,EAAE,YAAY,QAy1DxC","sourcesContent":["// @ts-nocheck\nimport type { ExtensionAPI, ExtensionContext } from \"@ashx-j/lunr\";\nimport { Box, Text, truncateToWidth } from \"@ashx-j/lunr-tui\";\nimport { Type } from \"typebox\";\nimport { StringEnum, complete, type Model } from \"@ashx-j/lunr-ai/compat\";\nimport { fetchAllContent, type ExtractedContent } from \"./extract.ts\";\nimport { normalizeFetchContentParams } from \"./fetch-params.ts\";\nimport { clearCloneCache } from \"./github-extract.ts\";\nimport { search, type SearchProvider, type ResolvedSearchProvider } from \"./gemini-search.ts\";\nimport type { SearchResult } from \"./perplexity.ts\";\nimport { getWebSearchConfigDir, getWebSearchConfigPath } from \"./utils.ts\";\nimport {\n\tclearResults,\n\tdeleteResult,\n\tgenerateId,\n\tgetAllResults,\n\tgetResult,\n\trestoreFromSession,\n\tstoreResult,\n\ttype QueryResultData,\n\ttype StoredSearchData,\n} from \"./storage.ts\";\nimport { activityMonitor, type ActivityEntry } from \"./activity.ts\";\nimport { startCuratorServer, type CuratorServerHandle } from \"./curator-server.ts\";\nimport {\n\tbuildDeterministicSummary,\n\tgenerateSummaryDraft,\n\ttype SummaryGenerationContext,\n\ttype SummaryMeta,\n} from \"./summary-review.ts\";\nimport { randomUUID } from \"node:crypto\";\nimport { execFileSync } from \"node:child_process\";\nimport { createRequire } from \"node:module\";\nimport { platform } from \"node:os\";\nimport { existsSync, readFileSync, writeFileSync, mkdirSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport { isPerplexityAvailable } from \"./perplexity.ts\";\nimport { isExaAvailable } from \"./exa.ts\";\nimport { isGeminiApiAvailable } from \"./gemini-api.ts\";\nimport { getActiveGoogleEmail, isGeminiWebAvailable } from \"./gemini-web.ts\";\nimport { isBrowserCookieAccessAllowed } from \"./gemini-web-config.ts\";\nimport { isBraveAvailable } from \"./brave.ts\";\nimport { isOpenAISearchAvailable } from \"./openai-search.ts\";\nimport { isParallelAvailable } from \"./parallel.ts\";\nimport { isTavilyAvailable } from \"./tavily.ts\";\nimport { buildSearchErrorPlan, type SearchErrorDetails, type SearchErrorPlan } from \"./render-search-error.ts\";\nimport {\n\tcollectFetchUrls,\n\tcollectSearchQueries,\n\tformatFetchCallTitle,\n\tformatFetchChrome,\n\tformatFetchDetail,\n\tformatSearchCallTitle,\n\tformatSearchChrome,\n\tformatSearchDetail,\n} from \"./render-search-chrome.ts\";\nimport { formatGroupedCall, toolStatusDotFromContext } from \"../../core/tools/render-utils.ts\";\nimport { loadEnabledModelPatterns, modelMatchesEnabledPatterns } from \"./summary-model-scope.ts\";\n\nconst WEB_SEARCH_CONFIG_PATH = getWebSearchConfigPath();\n\n/** Shared collapsed/expanded renderer for an error/cancel plan produced by\n * buildSearchErrorPlan(). Used by every tool renderResult's error branch so\n * Ctrl+O (app.tools.expand) reveals diagnostics instead of a dead-end single line. */\nfunction renderSearchErrorPlan(plan: SearchErrorPlan, expanded: boolean, theme: { fg: (key: string, s: string) => string; bg: (key: string, s: string) => string }) {\n\tif (expanded) {\n\t\treturn new Text(plan.expanded.map((l, i) => i === 0 ? theme.fg(\"error\", l) : theme.fg(\"toolOutput\", l)).join(\"\\n\"), 0, 0);\n\t}\n\tconst box = new Box(1, 0, (t) => theme.bg(\"toolErrorBg\", t));\n\tbox.addChild(new Text(theme.fg(\"error\", plan.expanded[0]), 0, 0));\n\tfor (const line of plan.collapsed) {\n\t\tbox.addChild(new Text(theme.fg(\"dim\", line), 0, 0));\n\t}\n\tif (plan.expandHint) {\n\t\tbox.addChild(new Text(theme.fg(\"muted\", plan.expandHint), 0, 0));\n\t}\n\treturn box;\n}\n\ninterface WebSearchConfig {\n\tprovider?: string;\n\tworkflow?: string;\n\tcuratorTimeoutSeconds?: unknown;\n\tsummaryModel?: string;\n\twebSearch?: {\n\t\tenabled?: boolean;\n\t};\n\tshortcuts?: {\n\t\tcurate?: string;\n\t\tactivity?: string;\n\t};\n\tssrf?: {\n\t\t/** CIDR ranges exempted from the SSRF guard (e.g. fake-IP proxy ranges). */\n\t\tallowRanges?: string[];\n\t};\n}\n\ninterface ProviderAvailability {\n\topenai: boolean;\n\tbrave: boolean;\n\tparallel: boolean;\n\ttavily: boolean;\n\tperplexity: boolean;\n\texa: boolean;\n\tgemini: boolean;\n}\n\ntype WebSearchWorkflow = \"none\" | \"summary-review\" | \"auto-summary\";\ntype CuratorWorkflow = \"summary-review\";\ntype SummaryWorkflow = \"summary-review\" | \"auto-summary\";\n\ninterface CuratorBootstrap {\n\tavailableProviders: ProviderAvailability;\n\tdefaultProvider: ResolvedSearchProvider;\n\ttimeoutSeconds: number;\n}\n\nfunction loadConfig(): WebSearchConfig {\n\tif (!existsSync(WEB_SEARCH_CONFIG_PATH)) return {};\n\tconst raw = readFileSync(WEB_SEARCH_CONFIG_PATH, \"utf-8\");\n\ttry {\n\t\treturn JSON.parse(raw) as WebSearchConfig;\n\t} catch (err) {\n\t\tconst message = err instanceof Error ? err.message : String(err);\n\t\tthrow new Error(`Failed to parse ${WEB_SEARCH_CONFIG_PATH}: ${message}`);\n\t}\n}\n\nfunction saveConfig(updates: Partial<WebSearchConfig>): void {\n\tlet config: Record<string, unknown> = {};\n\tif (existsSync(WEB_SEARCH_CONFIG_PATH)) {\n\t\tconst raw = readFileSync(WEB_SEARCH_CONFIG_PATH, \"utf-8\");\n\t\ttry {\n\t\t\tconfig = JSON.parse(raw) as Record<string, unknown>;\n\t\t} catch (err) {\n\t\t\tconst message = err instanceof Error ? err.message : String(err);\n\t\t\tthrow new Error(`Failed to parse ${WEB_SEARCH_CONFIG_PATH}: ${message}`);\n\t\t}\n\t}\n\n\tObject.assign(config, updates);\n\tconst dir = getWebSearchConfigDir();\n\tif (!existsSync(dir)) mkdirSync(dir, { recursive: true });\n\twriteFileSync(WEB_SEARCH_CONFIG_PATH, JSON.stringify(config, null, 2) + \"\\n\");\n}\n\nconst DEFAULT_SHORTCUTS = { curate: \"ctrl+shift+s\", activity: \"ctrl+shift+w\" };\nconst DEFAULT_CURATOR_TIMEOUT_SECONDS = 20;\nconst MAX_CURATOR_TIMEOUT_SECONDS = 600;\n\nfunction loadConfigForExtensionInit(): WebSearchConfig {\n\ttry {\n\t\treturn loadConfig();\n\t} catch (err) {\n\t\tconst message = err instanceof Error ? err.message : String(err);\n\t\tconsole.error(`[pi-web-access] ${message}`);\n\t\treturn {};\n\t}\n}\n\nfunction normalizeProviderInput(value: unknown): SearchProvider | undefined {\n\tif (value === undefined) return undefined;\n\tif (typeof value !== \"string\") return \"auto\";\n\tconst normalized = value.trim().toLowerCase();\n\tconst valid: SearchProvider[] = [\"auto\", \"openai\", \"brave\", \"parallel\", \"tavily\", \"exa\", \"perplexity\", \"gemini\"];\n\treturn valid.includes(normalized as SearchProvider) ? normalized as SearchProvider : \"auto\";\n}\n\nfunction normalizeCuratorTimeoutSeconds(value: unknown): number | undefined {\n\tif (typeof value !== \"number\" || !Number.isFinite(value)) return undefined;\n\tconst normalized = Math.floor(value);\n\tif (normalized < 1) return undefined;\n\treturn Math.min(normalized, MAX_CURATOR_TIMEOUT_SECONDS);\n}\n\nfunction resolveWorkflow(input: unknown, hasUI: boolean): WebSearchWorkflow {\n\tconst normalized = typeof input === \"string\" ? input.trim().toLowerCase() : \"\";\n\tif (normalized === \"auto-summary\") return \"auto-summary\";\n\tif (!hasUI) return \"none\";\n\tif (normalized === \"none\") return \"none\";\n\treturn \"summary-review\";\n}\n\nfunction normalizeQueryList(queryList: unknown[]): string[] {\n\treturn collectSearchQueries({ queries: queryList });\n}\n\nfunction getCuratorTimeoutSeconds(): number {\n\tconst source = loadConfig();\n\treturn normalizeCuratorTimeoutSeconds(source.curatorTimeoutSeconds) ?? DEFAULT_CURATOR_TIMEOUT_SECONDS;\n}\n\nasync function getProviderAvailability(ctx: ExtensionContext): Promise<ProviderAvailability> {\n\tconst geminiWebAvail = await isGeminiWebAvailable();\n\treturn {\n\t\topenai: await isOpenAISearchAvailable(ctx),\n\t\tbrave: isBraveAvailable(),\n\t\tparallel: isParallelAvailable(),\n\t\ttavily: isTavilyAvailable(),\n\t\tperplexity: isPerplexityAvailable(),\n\t\texa: isExaAvailable(),\n\t\tgemini: isGeminiApiAvailable() || !!geminiWebAvail,\n\t};\n}\n\nfunction shouldPreferOpenAI(options?: Pick<PendingCurate, \"numResults\" | \"recencyFilter\">): boolean {\n\tif (!options) return true;\n\tif (options.recencyFilter) return false;\n\tif (typeof options.numResults === \"number\" && Number.isFinite(options.numResults) && Math.floor(options.numResults) !== 5) {\n\t\treturn false;\n\t}\n\treturn true;\n}\n\nasync function loadCuratorBootstrap(\n\trequestedProvider: unknown,\n\tctx: ExtensionContext,\n\toptions?: Pick<PendingCurate, \"numResults\" | \"recencyFilter\">,\n): Promise<CuratorBootstrap> {\n\tconst availableProviders = await getProviderAvailability(ctx);\n\treturn {\n\t\tavailableProviders,\n\t\tdefaultProvider: resolveProvider(requestedProvider, availableProviders, options),\n\t\ttimeoutSeconds: getCuratorTimeoutSeconds(),\n\t};\n}\n\nfunction firstAvailableProvider(available: ProviderAvailability, preferOpenAI: boolean, fallback: ResolvedSearchProvider): ResolvedSearchProvider {\n\tif (preferOpenAI && available.openai) return \"openai\";\n\tif (available.exa) return \"exa\";\n\tif (available.brave) return \"brave\";\n\tif (available.parallel) return \"parallel\";\n\tif (available.tavily) return \"tavily\";\n\tif (available.perplexity) return \"perplexity\";\n\tif (available.gemini) return \"gemini\";\n\treturn fallback;\n}\n\nfunction resolveProvider(\n\trequested: unknown,\n\tavailable: ProviderAvailability,\n\toptions?: Pick<PendingCurate, \"numResults\" | \"recencyFilter\">,\n): ResolvedSearchProvider {\n\tconst provider = normalizeProviderInput(requested ?? loadConfig().provider ?? \"auto\") ?? \"auto\";\n\tconst preferOpenAI = shouldPreferOpenAI(options);\n\n\tif (provider === \"auto\") {\n\t\treturn firstAvailableProvider(available, preferOpenAI, \"exa\");\n\t}\n\tif (provider === \"openai\" && !available.openai) {\n\t\treturn firstAvailableProvider(available, false, \"openai\");\n\t}\n\tif (provider === \"brave\" && !available.brave) {\n\t\treturn firstAvailableProvider(available, preferOpenAI, \"brave\");\n\t}\n\tif (provider === \"parallel\" && !available.parallel) {\n\t\treturn firstAvailableProvider(available, preferOpenAI, \"parallel\");\n\t}\n\tif (provider === \"tavily\" && !available.tavily) {\n\t\treturn firstAvailableProvider(available, preferOpenAI, \"tavily\");\n\t}\n\tif (provider === \"exa\" && !available.exa) {\n\t\treturn firstAvailableProvider(available, preferOpenAI, \"exa\");\n\t}\n\tif (provider === \"perplexity\" && !available.perplexity) {\n\t\treturn firstAvailableProvider(available, preferOpenAI, \"perplexity\");\n\t}\n\tif (provider === \"gemini\" && !available.gemini) {\n\t\treturn firstAvailableProvider(available, preferOpenAI, \"gemini\");\n\t}\n\treturn provider;\n}\n\nconst pendingFetches = new Map<string, AbortController>();\nlet sessionActive = false;\nlet widgetVisible = false;\nlet widgetUnsubscribe: (() => void) | null = null;\nconst pendingCurates = new Map<string, PendingCurate>();\nconst activeCurators = new Map<string, CuratorServerHandle>();\nconst glimpseWins = new Map<string, GlimpseWindow>();\n\ninterface PendingCurate {\n\tphase: \"searching\" | \"curating\";\n\tworkflow: CuratorWorkflow;\n\tsummaryContext: SummaryGenerationContext;\n\tsearchResults: Map<number, QueryResultData>;\n\tallInlineContent: ExtractedContent[];\n\tqueryList: string[];\n\tincludeContent: boolean;\n\tnumResults?: number;\n\trecencyFilter?: \"day\" | \"week\" | \"month\" | \"year\";\n\tdomainFilter?: string[];\n\tavailableProviders: ProviderAvailability;\n\tdefaultProvider: ResolvedSearchProvider;\n\tsearchProvider: SearchProvider;\n\tsummaryModels: Array<{ value: string; label: string }>;\n\tdefaultSummaryModel: string | null;\n\ttimeoutSeconds: number;\n\tcuratorUrl?: string;\n\tonUpdate: ((update: { content: Array<{ type: string; text: string }>; details?: Record<string, unknown> }) => void) | undefined;\n\tsignal: AbortSignal | undefined;\n\tabortSearches: () => void;\n\tfinish: (value: unknown) => void;\n\tcancel: (reason?: \"user\" | \"stale\") => void;\n\tbrowserPromise?: Promise<void>;\n\tbrowserOpenError?: string;\n}\n\n\nconst MAX_INLINE_CONTENT = 30000; // Content returned directly to agent\n\nfunction stripThumbnails(results: ExtractedContent[]): ExtractedContent[] {\n\treturn results.map(({ thumbnail, frames, ...rest }) => rest);\n}\n\nfunction formatSearchSummary(results: SearchResult[], answer: string): string {\n\tlet output = answer ? `${answer}\\n\\n---\\n\\n**Sources:**\\n` : \"\";\n\toutput += results.map((r, i) => `${i + 1}. ${r.title}\\n ${r.url}`).join(\"\\n\\n\");\n\treturn output;\n}\n\nfunction duplicateQuerySet(results: QueryResultData[]): Set<string> {\n\tconst counts = new Map<string, number>();\n\tfor (const result of results) {\n\t\tcounts.set(result.query, (counts.get(result.query) ?? 0) + 1);\n\t}\n\tconst duplicates = new Set<string>();\n\tfor (const [query, count] of counts) {\n\t\tif (count > 1) duplicates.add(query);\n\t}\n\treturn duplicates;\n}\n\nfunction formatQueryHeader(query: string, provider: string | undefined, duplicateQueries: Set<string>): string {\n\tconst suffix = duplicateQueries.has(query) && provider ? ` (${provider})` : \"\";\n\treturn `## Query: \"${query}\"${suffix}\\n\\n`;\n}\n\nfunction hasFullInlineCoverage(urls: string[], inlineContent: ExtractedContent[] | undefined): boolean {\n\tif (!inlineContent || inlineContent.length === 0) return false;\n\tconst coveredUrls = new Set(inlineContent.map(c => c.url));\n\treturn urls.every(url => coveredUrls.has(url));\n}\n\nfunction formatFullResults(queryData: QueryResultData): string {\n\tlet output = `## Results for: \"${queryData.query}\"\\n\\n`;\n\tif (queryData.answer) {\n\t\toutput += `${queryData.answer}\\n\\n---\\n\\n`;\n\t}\n\tfor (const r of queryData.results) {\n\t\toutput += `### ${r.title}\\n${r.url}\\n\\n`;\n\t}\n\treturn output;\n}\n\nfunction abortPendingFetches(): void {\n\tfor (const controller of pendingFetches.values()) {\n\t\tcontroller.abort();\n\t}\n\tpendingFetches.clear();\n}\n\nfunction closeCurator(callId?: string): void {\n\tif (callId !== undefined) {\n\t\tconst win = glimpseWins.get(callId);\n\t\tglimpseWins.delete(callId);\n\t\ttry { win?.close(); } catch {}\n\t\tpendingCurates.get(callId)?.cancel(\"stale\");\n\t\tpendingCurates.delete(callId);\n\t\tconst curator = activeCurators.get(callId);\n\t\tactiveCurators.delete(callId);\n\t\ttry { curator?.close(); } catch {}\n\t\treturn;\n\t}\n\n\tfor (const win of glimpseWins.values()) {\n\t\ttry { win.close(); } catch {}\n\t}\n\tglimpseWins.clear();\n\tfor (const pc of pendingCurates.values()) {\n\t\ttry { pc.cancel(\"stale\"); } catch {}\n\t}\n\tpendingCurates.clear();\n\tfor (const curator of activeCurators.values()) {\n\t\ttry { curator.close(); } catch {}\n\t}\n\tactiveCurators.clear();\n}\n\nasync function openInBrowser(pi: ExtensionAPI, url: string): Promise<void> {\n\tconst plat = platform();\n\tconst result = plat === \"darwin\"\n\t\t? await pi.exec(\"open\", [url])\n\t\t: plat === \"win32\"\n\t\t\t? await pi.exec(\"cmd\", [\"/c\", \"start\", \"\", url])\n\t\t\t: await pi.exec(\"xdg-open\", [url]);\n\tif (result.code !== 0) {\n\t\tthrow new Error(result.stderr || `Failed to open browser (exit code ${result.code})`);\n\t}\n}\n\ninterface GlimpseWindow {\n\ton(event: \"closed\", handler: () => void): void;\n\ton(event: \"message\", handler: (data: unknown) => void): void;\n\ton(event: \"ready\", handler: (info: { screen?: { visibleHeight?: number } }) => void): void;\n\tclose(): void;\n\t_write(obj: Record<string, unknown>): void;\n}\n\nlet glimpseOpen: ((html: string, opts: Record<string, unknown>) => GlimpseWindow) | null | undefined;\n\nfunction findGlimpseMjs(): string | null {\n\ttry {\n\t\tconst req = createRequire(import.meta.url);\n\t\treturn req.resolve(\"glimpseui\");\n\t} catch {\n\t\t// Optional dependency.\n\t}\n\ttry {\n\t\tconst globalRoot = execFileSync(\"npm\", [\"root\", \"-g\"], { encoding: \"utf-8\" }).trim();\n\t\tconst entry = join(globalRoot, \"glimpseui\", \"src\", \"glimpse.mjs\");\n\t\tif (existsSync(entry)) return entry;\n\t} catch {\n\t\t// npm may be unavailable.\n\t}\n\treturn null;\n}\n\nasync function getGlimpseOpen() {\n\tif (glimpseOpen !== undefined) return glimpseOpen;\n\tconst resolved = findGlimpseMjs();\n\tif (resolved) {\n\t\ttry {\n\t\t\tglimpseOpen = (await import(resolved)).open;\n\t\t\treturn glimpseOpen;\n\t\t} catch {}\n\t}\n\tglimpseOpen = null;\n\treturn glimpseOpen;\n}\n\nfunction openInGlimpse(\n\topen: (html: string, opts: Record<string, unknown>) => GlimpseWindow,\n\turl: string,\n\ttitle: string,\n): GlimpseWindow {\n\tconst shellHTML = `<!DOCTYPE html>\n<html>\n<head><meta charset=\"UTF-8\"><title>${title}</title></head>\n<body style=\"margin:0; background:#1a1a2e;\">\n <script>window.location.replace(${JSON.stringify(url)});</script>\n</body>\n</html>`;\n\tconst win = open(shellHTML, {\n\t\twidth: 800,\n\t\theight: 900,\n\t\ttitle,\n\t});\n\n\tlet maxHeight = 1200;\n\twin.on(\"ready\", (info) => {\n\t\tconst visibleHeight = info?.screen?.visibleHeight;\n\t\tif (typeof visibleHeight === \"number\" && visibleHeight > 0) {\n\t\t\tmaxHeight = Math.floor(visibleHeight * 0.85);\n\t\t}\n\t});\n\twin.on(\"message\", (data) => {\n\t\tif (!data || typeof data !== \"object\") return;\n\t\tconst msg = data as Record<string, unknown>;\n\t\tif (msg.type !== \"resize\" || typeof msg.height !== \"number\") return;\n\t\tconst clamped = Math.max(400, Math.min(Math.round(msg.height), maxHeight));\n\t\twin._write({ type: \"resize\", width: 800, height: clamped });\n\t});\n\n\treturn win;\n}\n\nfunction extractDomain(url: string): string {\n\ttry { return new URL(url).hostname; }\n\tcatch { return url; }\n}\n\nfunction updateWidget(ctx: ExtensionContext): void {\n\tconst theme = ctx.ui.theme;\n\tconst entries = activityMonitor.getEntries();\n\tconst lines: string[] = [];\n\n\tlines.push(theme.fg(\"accent\", \"─── Web Search Activity \" + \"─\".repeat(36)));\n\n\tif (entries.length === 0) {\n\t\tlines.push(theme.fg(\"muted\", \" No activity yet\"));\n\t} else {\n\t\tfor (const e of entries) {\n\t\t\tlines.push(\" \" + formatEntryLine(e, theme));\n\t\t}\n\t}\n\n\tlines.push(theme.fg(\"accent\", \"─\".repeat(60)));\n\n\tconst rateInfo = activityMonitor.getRateLimitInfo();\n\tconst resetMs = rateInfo.oldestTimestamp ? Math.max(0, rateInfo.oldestTimestamp + rateInfo.windowMs - Date.now()) : 0;\n\tconst resetSec = Math.ceil(resetMs / 1000);\n\tlines.push(\n\t\ttheme.fg(\"muted\", `Rate: ${rateInfo.used}/${rateInfo.max}`) +\n\t\t\t(resetMs > 0 ? theme.fg(\"dim\", ` (resets in ${resetSec}s)`) : \"\"),\n\t);\n\n\tctx.ui.setWidget(\"web-activity\", new Text(lines.join(\"\\n\"), 0, 0));\n}\n\nfunction formatEntryLine(\n\tentry: ActivityEntry,\n\ttheme: { fg: (color: string, text: string) => string },\n): string {\n\tconst typeStr = entry.type === \"api\" ? \"API\" : \"GET\";\n\tconst target =\n\t\tentry.type === \"api\"\n\t\t\t? `\"${truncateToWidth(entry.query || \"\", 28, \"\")}\"`\n\t\t\t: truncateToWidth(entry.url?.replace(/^https?:\\/\\//, \"\") || \"\", 30, \"\");\n\n\tconst duration = entry.endTime\n\t\t? `${((entry.endTime - entry.startTime) / 1000).toFixed(1)}s`\n\t\t: `${((Date.now() - entry.startTime) / 1000).toFixed(1)}s`;\n\n\tlet statusStr: string;\n\tlet indicator: string;\n\tif (entry.error) {\n\t\tstatusStr = \"err\";\n\t\tindicator = theme.fg(\"error\", \"✗\");\n\t} else if (entry.status === null) {\n\t\tstatusStr = \"...\";\n\t\tindicator = theme.fg(\"warning\", \"⋯\");\n\t} else if (entry.status === 0) {\n\t\tstatusStr = \"abort\";\n\t\tindicator = theme.fg(\"muted\", \"○\");\n\t} else {\n\t\tstatusStr = String(entry.status);\n\t\tindicator = entry.status >= 200 && entry.status < 300 ? theme.fg(\"success\", \"✓\") : theme.fg(\"error\", \"✗\");\n\t}\n\n\treturn `${typeStr.padEnd(4)} ${target.padEnd(32)} ${statusStr.padStart(5)} ${duration.padStart(5)} ${indicator}`;\n}\n\nfunction handleSessionChange(ctx: ExtensionContext): void {\n\tabortPendingFetches();\n\tcloseCurator();\n\tclearCloneCache();\n\tsessionActive = true;\n\trestoreFromSession(ctx);\n\t// Unsubscribe before clear() to avoid callback with stale ctx\n\twidgetUnsubscribe?.();\n\twidgetUnsubscribe = null;\n\tactivityMonitor.clear();\n\tif (widgetVisible) {\n\t\t// Re-subscribe with new ctx\n\t\twidgetUnsubscribe = activityMonitor.onUpdate(() => updateWidget(ctx));\n\t\tupdateWidget(ctx);\n\t}\n}\n\nexport default function (pi: ExtensionAPI) {\n\tconst initConfig = loadConfigForExtensionInit();\n\tconst curateKey = initConfig.shortcuts?.curate || DEFAULT_SHORTCUTS.curate;\n\tconst activityKey = initConfig.shortcuts?.activity || DEFAULT_SHORTCUTS.activity;\n\n\t// lunr: expose curator workflow state to core (/settings \"Extensions\" submenu)\n\t// so the /settings toggle writes through the same web-search.json saveConfig\n\t// path as /curator — single source of truth, no forked state.\n\t(globalThis as Record<symbol, unknown>)[Symbol.for(\"@lunr/search-curator\")] = {\n\t\tgetWorkflow(): WebSearchWorkflow {\n\t\t\treturn resolveWorkflow(loadConfigForExtensionInit().workflow, true);\n\t\t},\n\t\tsetWorkflow(workflow: WebSearchWorkflow): void {\n\t\t\tsaveConfig({ workflow });\n\t\t},\n\t};\n\n\tfunction startBackgroundFetch(urls: string[]): string | null {\n\t\tif (urls.length === 0) return null;\n\t\tconst fetchId = generateId();\n\t\tconst controller = new AbortController();\n\t\tpendingFetches.set(fetchId, controller);\n\t\tfetchAllContent(urls, controller.signal)\n\t\t\t.then((fetched) => {\n\t\t\t\tif (!sessionActive || !pendingFetches.has(fetchId)) return;\n\t\t\t\tconst data: StoredSearchData = {\n\t\t\t\t\tid: fetchId,\n\t\t\t\t\ttype: \"fetch\",\n\t\t\t\t\ttimestamp: Date.now(),\n\t\t\t\t\turls: stripThumbnails(fetched),\n\t\t\t\t};\n\t\t\t\tstoreResult(fetchId, data);\n\t\t\t\tpi.appendEntry(\"web-search-results\", data);\n\t\t\t\tconst ok = fetched.filter(f => !f.error).length;\n\t\t\t\tpi.sendMessage(\n\t\t\t\t\t{\n\t\t\t\t\t\tcustomType: \"web-search-content-ready\",\n\t\t\t\t\t\tcontent: `Content fetched for ${ok}/${fetched.length} URLs [${fetchId}]. Full page content now available.`,\n\t\t\t\t\t\tdisplay: true,\n\t\t\t\t\t},\n\t\t\t\t\t{ triggerTurn: true },\n\t\t\t\t);\n\t\t\t})\n\t\t\t.catch((err) => {\n\t\t\t\tif (!sessionActive || !pendingFetches.has(fetchId)) return;\n\t\t\t\tconst message = err instanceof Error ? err.message : String(err);\n\t\t\t\tconst isAbort = (err instanceof Error && err.name === \"AbortError\") || message.toLowerCase().includes(\"abort\");\n\t\t\t\tif (!isAbort) {\n\t\t\t\t\tpi.sendMessage(\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tcustomType: \"web-search-error\",\n\t\t\t\t\t\t\tcontent: `Content fetch failed [${fetchId}]: ${message}`,\n\t\t\t\t\t\t\tdisplay: true,\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{ triggerTurn: false },\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t})\n\t\t\t.finally(() => { pendingFetches.delete(fetchId); });\n\t\treturn fetchId;\n\t}\n\n\tfunction storeAndPublishSearch(results: QueryResultData[]): string {\n\t\tconst id = generateId();\n\t\tconst data: StoredSearchData = {\n\t\t\tid, type: \"search\", timestamp: Date.now(), queries: results,\n\t\t};\n\t\tstoreResult(id, data);\n\t\tpi.appendEntry(\"web-search-results\", data);\n\t\treturn id;\n\t}\n\n\tinterface SearchReturnOptions {\n\t\tqueryList: string[];\n\t\tresults: QueryResultData[];\n\t\turls: string[];\n\t\tincludeContent: boolean;\n\t\tinlineContent?: ExtractedContent[];\n\t\tcurated?: boolean;\n\t\tcuratedFrom?: number;\n\t\tworkflow?: SummaryWorkflow;\n\t\tapprovedSummary?: string;\n\t\tsummaryMeta?: SummaryMeta;\n\t}\n\n\tfunction normalizeSummaryMeta(meta: SummaryMeta | undefined, summaryText: string): SummaryMeta {\n\t\tconst normalizedText = summaryText.trim();\n\t\tif (!meta) {\n\t\t\treturn {\n\t\t\t\tmodel: null,\n\t\t\t\tdurationMs: 0,\n\t\t\t\ttokenEstimate: normalizedText.length > 0 ? Math.max(1, Math.ceil(normalizedText.length / 4)) : 0,\n\t\t\t\tfallbackUsed: false,\n\t\t\t\tedited: false,\n\t\t\t};\n\t\t}\n\n\t\treturn {\n\t\t\tmodel: meta.model,\n\t\t\tdurationMs: Number.isFinite(meta.durationMs) && meta.durationMs >= 0 ? meta.durationMs : 0,\n\t\t\ttokenEstimate: Number.isFinite(meta.tokenEstimate) && meta.tokenEstimate >= 0\n\t\t\t\t? meta.tokenEstimate\n\t\t\t\t: (normalizedText.length > 0 ? Math.max(1, Math.ceil(normalizedText.length / 4)) : 0),\n\t\t\tfallbackUsed: meta.fallbackUsed === true,\n\t\t\tfallbackReason: meta.fallbackReason,\n\t\t\tedited: meta.edited === true,\n\t\t};\n\t}\n\n\tfunction buildCurationCancelledReturn(\n\t\treason: \"user\" | \"stale\",\n\t\tpartial?: {\n\t\t\tqueries?: QueryResultData[];\n\t\t\tqueryCount?: number;\n\t\t\tbrowserConnected?: boolean;\n\t\t\tlastHeartbeatAgeMs?: number | null;\n\t\t\tcuratorUrl?: string;\n\t\t\tbrowserOpenError?: string;\n\t\t},\n\t) {\n\t\tconst message = `Search curation cancelled (${reason}).`;\n\t\tconst cancelledQueries = partial?.queries?.length\n\t\t\t? partial.queries.map(q => ({\n\t\t\t\tquery: q.query,\n\t\t\t\tprovider: q.provider ?? null,\n\t\t\t\terror: q.error,\n\t\t\t\tresultCount: q.results?.length ?? 0,\n\t\t\t}))\n\t\t\t: undefined;\n\t\tconst extraLines: string[] = [];\n\t\tif (partial?.curatorUrl) extraLines.push(`curator: ${partial.curatorUrl}`);\n\t\tif (partial?.browserOpenError) extraLines.push(`browser open error: ${partial.browserOpenError}`);\n\t\treturn {\n\t\t\tcontent: [{ type: \"text\", text: message }],\n\t\t\tdetails: {\n\t\t\t\terror: message,\n\t\t\t\tcancelled: true,\n\t\t\t\tcancelReason: reason,\n\t\t\t\tbrowserConnected: partial?.browserConnected,\n\t\t\t\tlastHeartbeatAgeMs: partial?.lastHeartbeatAgeMs,\n\t\t\t\tqueryCount: partial?.queryCount,\n\t\t\t\tcancelledQueries,\n\t\t\t\textraLines: extraLines.length > 0 ? extraLines : undefined,\n\t\t\t},\n\t\t};\n\t}\n\n\tasync function resolveFirstAvailableModel(\n\t\tctx: SummaryGenerationContext,\n\t\tcandidates: Array<{ provider: string; id: string }>,\n\t): Promise<{ model: Model; apiKey: string; headers?: Record<string, string> }> {\n\t\tconst enabledModelPatterns = loadEnabledModelPatterns(ctx);\n\t\tfor (const { provider, id } of candidates) {\n\t\t\tconst model = ctx.modelRegistry.find(provider, id);\n\t\t\tif (!model || !modelMatchesEnabledPatterns(model, enabledModelPatterns)) continue;\n\t\t\tconst auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);\n\t\t\tif (auth.ok && auth.apiKey) return { model, apiKey: auth.apiKey, headers: auth.headers };\n\t\t}\n\t\tthrow new Error(`No enabled model available: ${candidates.map(c => `${c.provider}/${c.id}`).join(\", \")}`);\n\t}\n\n\tasync function rewriteSearchQuery(query: string, ctx: SummaryGenerationContext, signal: AbortSignal): Promise<string> {\n\t\tconst { model, apiKey, headers } = await resolveFirstAvailableModel(ctx, [\n\t\t\t{ provider: \"anthropic\", id: \"claude-haiku-4-5\" },\n\t\t\t{ provider: \"google\", id: \"gemini-2.5-flash\" },\n\t\t\t{ provider: \"openai\", id: \"gpt-4.1-mini\" },\n\t\t]);\n\t\tconst response = await complete(\n\t\t\tmodel,\n\t\t\t{\n\t\t\t\tmessages: [{\n\t\t\t\t\trole: \"user\",\n\t\t\t\t\tcontent: [{ 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.\\n\\nQuery: ${query}` }],\n\t\t\t\t\ttimestamp: Date.now(),\n\t\t\t\t}],\n\t\t\t},\n\t\t\t{ apiKey, headers, signal },\n\t\t);\n\t\tif (response.stopReason === \"aborted\") throw new Error(\"Aborted\");\n\t\tconst contentParts = Array.isArray(response.content) ? response.content : [];\n\t\tconst text = contentParts\n\t\t\t.map(p => {\n\t\t\t\tif (!p || typeof p !== \"object\") return \"\";\n\t\t\t\tconst part = p as Record<string, unknown>;\n\t\t\t\treturn typeof part.text === \"string\" ? part.text : \"\";\n\t\t\t})\n\t\t\t.join(\"\")\n\t\t\t.trim();\n\t\tif (!text) throw new Error(\"Rewrite returned empty response\");\n\t\treturn text;\n\t}\n\n\tasync function generateSummaryForSelectedIndices(\n\t\tselectedQueryIndices: number[],\n\t\tresultsByIndex: Map<number, QueryResultData>,\n\t\tsummaryContext: SummaryGenerationContext,\n\t\tsignal?: AbortSignal,\n\t\tmodelOverride?: string,\n\t\tfeedback?: string,\n\t): Promise<{ summary: string; meta: SummaryMeta }> {\n\t\tconst selectedResults: QueryResultData[] = [];\n\t\tfor (const qi of selectedQueryIndices) {\n\t\t\tconst result = resultsByIndex.get(qi);\n\t\t\tif (result) selectedResults.push(result);\n\t\t}\n\t\tif (selectedResults.length === 0) {\n\t\t\tthrow new Error(\"No selected results available for summary generation\");\n\t\t}\n\t\ttry {\n\t\t\treturn await generateSummaryDraft(selectedResults, summaryContext, signal, modelOverride, feedback);\n\t\t} catch (err) {\n\t\t\tconst isEmptyResponse = err instanceof Error && err.message.includes(\"Summary model returned empty response\");\n\t\t\tif (!isEmptyResponse) throw err;\n\t\t\tconst deterministic = buildDeterministicSummary(selectedResults);\n\t\t\treturn {\n\t\t\t\tsummary: deterministic.summary,\n\t\t\t\tmeta: {\n\t\t\t\t\t...deterministic.meta,\n\t\t\t\t\tfallbackReason: \"summary-model-empty-response\",\n\t\t\t\t},\n\t\t\t};\n\t\t}\n\t}\n\n\tasync function loadSummaryModelChoices(\n\t\tsummaryContext: SummaryGenerationContext,\n\t): Promise<{ summaryModels: Array<{ value: string; label: string }>; defaultSummaryModel: string | null }> {\n\t\tconst summaryModels: Array<{ value: string; label: string }> = [];\n\t\tconst seen = new Set<string>();\n\t\tconst availableValues = new Set<string>();\n\n\t\tconst addModel = (provider: string, id: string) => {\n\t\t\tconst value = `${provider}/${id}`;\n\t\t\tif (seen.has(value)) return;\n\t\t\tseen.add(value);\n\t\t\tsummaryModels.push({ value, label: value });\n\t\t};\n\n\t\tlet enabledModelPatterns: string[] | null = null;\n\t\tlet scopeLoaded = true;\n\t\ttry {\n\t\t\tenabledModelPatterns = loadEnabledModelPatterns(summaryContext);\n\t\t\tconst availableModels = summaryContext.modelRegistry.getAvailable();\n\t\t\tfor (const model of availableModels) {\n\t\t\t\tif (!modelMatchesEnabledPatterns(model, enabledModelPatterns)) continue;\n\t\t\t\tconst value = `${model.provider}/${model.id}`;\n\t\t\t\tavailableValues.add(value);\n\t\t\t\taddModel(model.provider, model.id);\n\t\t\t}\n\t\t} catch (err) {\n\t\t\tscopeLoaded = false;\n\t\t\tconst message = err instanceof Error ? err.message : String(err);\n\t\t\tconsole.error(`Failed to load summary models: ${message}`);\n\t\t}\n\n\t\tconst currentModelValue = summaryContext.model\n\t\t\t? `${summaryContext.model.provider}/${summaryContext.model.id}`\n\t\t\t: null;\n\t\tif (scopeLoaded && summaryContext.model && currentModelValue && !seen.has(currentModelValue) && modelMatchesEnabledPatterns(summaryContext.model, enabledModelPatterns)) {\n\t\t\taddModel(summaryContext.model.provider, summaryContext.model.id);\n\t\t}\n\n\t\tconst config = loadConfig();\n\t\tconst configuredSummaryModel = typeof config.summaryModel === \"string\" ? config.summaryModel.trim() : \"\";\n\t\tconst preferredDefaults = [\n\t\t\t\"anthropic/claude-haiku-4-5\",\n\t\t\t\"openai-codex/gpt-5.3-codex-spark\",\n\t\t];\n\n\t\tlet defaultSummaryModel: string | null = null;\n\t\tif (configuredSummaryModel.length > 0 && availableValues.has(configuredSummaryModel)) {\n\t\t\tdefaultSummaryModel = configuredSummaryModel;\n\t\t}\n\t\tif (!defaultSummaryModel) {\n\t\t\tfor (const preferred of preferredDefaults) {\n\t\t\t\tif (availableValues.has(preferred)) {\n\t\t\t\t\tdefaultSummaryModel = preferred;\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\treturn { summaryModels, defaultSummaryModel };\n\t}\n\n\tfunction resolveSummaryForSubmit(\n\t\tpayload: { selectedQueryIndices: number[]; summary?: string; summaryMeta?: SummaryMeta },\n\t\tresultsByIndex: Map<number, QueryResultData>,\n\t): { approvedSummary: string; summaryMeta: SummaryMeta } {\n\t\tconst submittedSummary = typeof payload.summary === \"string\" ? payload.summary.trim() : \"\";\n\t\tif (submittedSummary.length > 0) {\n\t\t\treturn {\n\t\t\t\tapprovedSummary: submittedSummary,\n\t\t\t\tsummaryMeta: normalizeSummaryMeta(payload.summaryMeta, submittedSummary),\n\t\t\t};\n\t\t}\n\n\t\tconst selected = filterByQueryIndices(payload.selectedQueryIndices, resultsByIndex).results;\n\t\tconst fallbackResults = selected.length > 0 ? selected : [...resultsByIndex.values()];\n\t\tconst deterministic = buildDeterministicSummary(fallbackResults);\n\t\treturn {\n\t\t\tapprovedSummary: deterministic.summary,\n\t\t\tsummaryMeta: deterministic.meta,\n\t\t};\n\t}\n\n\tfunction buildSearchReturn(opts: SearchReturnOptions) {\n\t\tconst sc = opts.results.filter(r => !r.error).length;\n\t\tconst tr = opts.results.reduce((sum, r) => sum + r.results.length, 0);\n\n\t\tconst hasApprovedSummary = typeof opts.approvedSummary === \"string\" && opts.approvedSummary.trim().length > 0;\n\t\tlet output = \"\";\n\t\tif (hasApprovedSummary) {\n\t\t\toutput = opts.approvedSummary!.trim();\n\t\t} else {\n\t\t\tif (opts.curated) {\n\t\t\t\toutput += \"[These results were manually curated by the user in the browser. Use them as-is — do not re-search or discard.]\\n\\n\";\n\t\t\t}\n\t\t\tconst duplicateQueries = opts.curated ? duplicateQuerySet(opts.results) : new Set<string>();\n\t\t\tfor (const { query, answer, results, error, provider } of opts.results) {\n\t\t\t\tif (opts.queryList.length > 1) {\n\t\t\t\t\toutput += opts.curated\n\t\t\t\t\t\t? formatQueryHeader(query, provider, duplicateQueries)\n\t\t\t\t\t\t: `## Query: \"${query}\"\\n\\n`;\n\t\t\t\t}\n\t\t\t\tif (error) output += `Error: ${error}\\n\\n`;\n\t\t\t\telse if (results.length === 0) output += \"No results found.\\n\\n\";\n\t\t\t\telse output += formatSearchSummary(results, answer) + \"\\n\\n\";\n\t\t\t}\n\t\t}\n\n\t\tconst hasInlineReady = hasFullInlineCoverage(opts.urls, opts.inlineContent);\n\t\tlet fetchId: string | null = null;\n\t\tif (hasInlineReady && opts.inlineContent) {\n\t\t\tfetchId = generateId();\n\t\t\tconst data: StoredSearchData = {\n\t\t\t\tid: fetchId,\n\t\t\t\ttype: \"fetch\",\n\t\t\t\ttimestamp: Date.now(),\n\t\t\t\turls: opts.inlineContent,\n\t\t\t};\n\t\t\tstoreResult(fetchId, data);\n\t\t\tpi.appendEntry(\"web-search-results\", data);\n\t\t\tif (!hasApprovedSummary) {\n\t\t\t\toutput += `---\\nFull content for ${opts.inlineContent.length} sources available [${fetchId}].`;\n\t\t\t}\n\t\t} else if (opts.includeContent) {\n\t\t\tfetchId = startBackgroundFetch(opts.urls);\n\t\t\tif (fetchId && !hasApprovedSummary) {\n\t\t\t\toutput += `---\\nContent fetching in background [${fetchId}]. Will notify when ready.`;\n\t\t\t}\n\t\t}\n\n\t\tconst searchId = storeAndPublishSearch(opts.results);\n\t\tconst isBackgroundFetch = fetchId !== null && !hasInlineReady;\n\n\t\treturn {\n\t\t\tcontent: [{ type: \"text\", text: output.trim() }],\n\t\t\tdetails: {\n\t\t\t\tqueries: opts.queryList,\n\t\t\t\tqueryCount: opts.queryList.length,\n\t\t\t\tsuccessfulQueries: sc,\n\t\t\t\ttotalResults: tr,\n\t\t\t\tincludeContent: opts.includeContent,\n\t\t\t\tfetchId,\n\t\t\t\tfetchUrls: isBackgroundFetch ? opts.urls : undefined,\n\t\t\t\tsearchId,\n\t\t\t\t...(opts.curated ? {\n\t\t\t\t\tcurated: true,\n\t\t\t\t\tcuratedFrom: opts.curatedFrom,\n\t\t\t\t\tcuratedQueries: opts.results.map(r => ({\n\t\t\t\t\t\tquery: r.query,\n\t\t\t\t\t\tprovider: r.provider || null,\n\t\t\t\t\t\tanswer: r.answer || null,\n\t\t\t\t\t\tsources: r.results.map(s => ({ title: s.title, url: s.url })),\n\t\t\t\t\t\terror: r.error,\n\t\t\t\t\t})),\n\t\t\t\t} : {}),\n\t\t\t\t...((opts.workflow && hasApprovedSummary)\n\t\t\t\t\t? {\n\t\t\t\t\t\tsummary: {\n\t\t\t\t\t\t\ttext: opts.approvedSummary!.trim(),\n\t\t\t\t\t\t\tworkflow: opts.workflow,\n\t\t\t\t\t\t\tmodel: opts.summaryMeta?.model ?? null,\n\t\t\t\t\t\t\tdurationMs: opts.summaryMeta?.durationMs ?? 0,\n\t\t\t\t\t\t\ttokenEstimate: opts.summaryMeta?.tokenEstimate ?? 0,\n\t\t\t\t\t\t\tfallbackUsed: opts.summaryMeta?.fallbackUsed === true,\n\t\t\t\t\t\t\tfallbackReason: opts.summaryMeta?.fallbackReason,\n\t\t\t\t\t\t\tedited: opts.summaryMeta?.edited === true,\n\t\t\t\t\t\t},\n\t\t\t\t\t}\n\t\t\t\t\t: {}),\n\t\t\t},\n\t\t};\n\t}\n\n\tfunction filterByQueryIndices(selectedQueryIndices: number[], results: Map<number, QueryResultData>) {\n\t\tconst filteredResults: QueryResultData[] = [];\n\t\tconst filteredUrls: string[] = [];\n\t\tfor (const qi of selectedQueryIndices) {\n\t\t\tconst r = results.get(qi);\n\t\t\tif (r) {\n\t\t\t\tfilteredResults.push(r);\n\t\t\t\tfor (const res of r.results) {\n\t\t\t\t\tif (!filteredUrls.includes(res.url)) filteredUrls.push(res.url);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\treturn { results: filteredResults, urls: filteredUrls };\n\t}\n\n\tfunction collectAllResultsAndUrls(resultsByIndex: Map<number, QueryResultData>) {\n\t\tconst results = [...resultsByIndex.values()];\n\t\tconst urls: string[] = [];\n\t\tfor (const result of results) {\n\t\t\tfor (const source of result.results) {\n\t\t\t\tif (!urls.includes(source.url)) urls.push(source.url);\n\t\t\t}\n\t\t}\n\t\treturn { results, urls };\n\t}\n\n\tasync function openCuratorBrowser(callId: string, pc: PendingCurate, searchesComplete = true): Promise<void> {\n\t\tif (pendingCurates.get(callId) !== pc) return;\n\t\tlet handle: CuratorServerHandle | null = null;\n\t\ttry {\n\t\t\tpc.phase = \"curating\";\n\n\t\t\tconst searchAbort = new AbortController();\n\t\t\tconst addSearchSignal = pc.signal\n\t\t\t\t? AbortSignal.any([pc.signal, searchAbort.signal])\n\t\t\t\t: searchAbort.signal;\n\n\t\t\tconst sessionToken = randomUUID();\n\t\t\thandle = await startCuratorServer(\n\t\t\t\t{\n\t\t\t\t\tqueries: pc.queryList,\n\t\t\t\t\tsessionToken,\n\t\t\t\t\ttimeout: pc.timeoutSeconds,\n\t\t\t\t\tavailableProviders: pc.availableProviders,\n\t\t\t\t\tdefaultProvider: pc.defaultProvider,\n\t\t\t\t\tsearchProvider: pc.searchProvider,\n\t\t\t\t\tsummaryModels: pc.summaryModels,\n\t\t\t\t\tdefaultSummaryModel: pc.defaultSummaryModel,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tasync onSummarize(selectedQueryIndices, summarizeSignal, model, feedback) {\n\t\t\t\t\t\tif (pendingCurates.get(callId) !== pc) throw new Error(\"Curator session is no longer active.\");\n\t\t\t\t\t\tpc.onUpdate?.({\n\t\t\t\t\t\t\tcontent: [{ type: \"text\", text: \"Generating summary draft...\" }],\n\t\t\t\t\t\t\tdetails: { phase: \"generating-summary\", progress: 0.9, curatorUrl: pc.curatorUrl, timeoutSeconds: pc.timeoutSeconds, shortcut: curateKey },\n\t\t\t\t\t\t});\n\t\t\t\t\t\tconst draft = await generateSummaryForSelectedIndices(\n\t\t\t\t\t\t\tselectedQueryIndices,\n\t\t\t\t\t\t\tpc.searchResults,\n\t\t\t\t\t\t\tpc.summaryContext,\n\t\t\t\t\t\t\tsummarizeSignal,\n\t\t\t\t\t\t\tmodel,\n\t\t\t\t\t\t\tfeedback,\n\t\t\t\t\t\t);\n\t\t\t\t\t\tif (pendingCurates.get(callId) !== pc) throw new Error(\"Curator session is no longer active.\");\n\t\t\t\t\t\tpc.onUpdate?.({\n\t\t\t\t\t\t\tcontent: [{ type: \"text\", text: \"Summary draft ready — waiting for approval...\" }],\n\t\t\t\t\t\t\tdetails: { phase: \"waiting-for-approval\", progress: 1, curatorUrl: pc.curatorUrl, timeoutSeconds: pc.timeoutSeconds, shortcut: curateKey },\n\t\t\t\t\t\t});\n\t\t\t\t\t\treturn draft;\n\t\t\t\t\t},\n\t\t\t\t\tonSubmit(payload) {\n\t\t\t\t\t\tif (pendingCurates.get(callId) !== pc) return;\n\t\t\t\t\t\tsearchAbort.abort();\n\t\t\t\t\t\tconst filtered = payload.selectedQueryIndices.length > 0\n\t\t\t\t\t\t\t? filterByQueryIndices(payload.selectedQueryIndices, pc.searchResults)\n\t\t\t\t\t\t\t: collectAllResultsAndUrls(pc.searchResults);\n\t\t\t\t\t\tconst filteredInline = pc.allInlineContent.filter(c => filtered.urls.includes(c.url));\n\t\t\t\t\t\tconst base: SearchReturnOptions = {\n\t\t\t\t\t\t\tqueryList: filtered.results.map(r => r.query),\n\t\t\t\t\t\t\tresults: filtered.results,\n\t\t\t\t\t\t\turls: filtered.urls,\n\t\t\t\t\t\t\tincludeContent: pc.includeContent,\n\t\t\t\t\t\t\tinlineContent: filteredInline.length > 0 ? filteredInline : undefined,\n\t\t\t\t\t\t\tcurated: true,\n\t\t\t\t\t\t\tcuratedFrom: pc.searchResults.size,\n\t\t\t\t\t\t};\n\t\t\t\t\t\tif (!payload.rawResults) {\n\t\t\t\t\t\t\tconst resolvedSummary = resolveSummaryForSubmit(payload, pc.searchResults);\n\t\t\t\t\t\t\tbase.workflow = pc.workflow;\n\t\t\t\t\t\t\tbase.approvedSummary = resolvedSummary.approvedSummary;\n\t\t\t\t\t\t\tbase.summaryMeta = resolvedSummary.summaryMeta;\n\t\t\t\t\t\t}\n\t\t\t\t\t\tpc.finish(buildSearchReturn(base));\n\t\t\t\t\t\tcloseCurator(callId);\n\t\t\t\t\t},\n\t\t\t\t\tonCancel(reason) {\n\t\t\t\t\t\tif (pendingCurates.get(callId) !== pc) return;\n\t\t\t\t\t\tsearchAbort.abort();\n\t\t\t\t\t\tif (reason === \"timeout\") {\n\t\t\t\t\t\t\tconst resolvedSummary = resolveSummaryForSubmit({ selectedQueryIndices: [], summary: undefined, summaryMeta: undefined }, pc.searchResults);\n\t\t\t\t\t\t\tconst all = collectAllResultsAndUrls(pc.searchResults);\n\t\t\t\t\t\t\tconst filteredInline = pc.allInlineContent.filter(c => all.urls.includes(c.url));\n\t\t\t\t\t\t\tpc.finish(buildSearchReturn({\n\t\t\t\t\t\t\t\tqueryList: all.results.map(r => r.query),\n\t\t\t\t\t\t\t\tresults: all.results,\n\t\t\t\t\t\t\t\turls: all.urls,\n\t\t\t\t\t\t\t\tincludeContent: pc.includeContent,\n\t\t\t\t\t\t\t\tinlineContent: filteredInline.length > 0 ? filteredInline : undefined,\n\t\t\t\t\t\t\t\tcurated: true,\n\t\t\t\t\t\t\t\tcuratedFrom: pc.searchResults.size,\n\t\t\t\t\t\t\t\tworkflow: pc.workflow,\n\t\t\t\t\t\t\t\tapprovedSummary: resolvedSummary.approvedSummary,\n\t\t\t\t\t\t\t\tsummaryMeta: resolvedSummary.summaryMeta,\n\t\t\t\t\t\t\t}));\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tconst conn = activeCurators.get(callId)?.getConnectionState();\n\t\t\t\t\t\t\tpc.finish(buildCurationCancelledReturn(reason, {\n\t\t\t\t\t\t\t\tqueries: Array.from(pc.searchResults.values()),\n\t\t\t\t\t\t\t\tqueryCount: pc.queryList.length,\n\t\t\t\t\t\t\t\tbrowserConnected: conn?.browserConnected,\n\t\t\t\t\t\t\t\tlastHeartbeatAgeMs: conn?.lastHeartbeatAgeMs,\n\t\t\t\t\t\t\t\tcuratorUrl: pc.curatorUrl,\n\t\t\t\t\t\t\t\tbrowserOpenError: pc.browserOpenError,\n\t\t\t\t\t\t\t}));\n\t\t\t\t\t\t}\n\t\t\t\t\t\tcloseCurator(callId);\n\t\t\t\t\t},\n\t\t\t\t\tonProviderChange(provider) {\n\t\t\t\t\t\tif (pendingCurates.get(callId) !== pc) return;\n\t\t\t\t\t\tconst normalized = normalizeProviderInput(provider);\n\t\t\t\t\t\tif (!normalized || normalized === \"auto\") return;\n\t\t\t\t\t\tpc.defaultProvider = normalized;\n\t\t\t\t\t\tpc.searchProvider = normalized;\n\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\tsaveConfig({ provider: normalized });\n\t\t\t\t\t\t} catch (err) {\n\t\t\t\t\t\t\tconst message = err instanceof Error ? err.message : String(err);\n\t\t\t\t\t\t\tconsole.error(`Failed to persist default provider: ${message}`);\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\tasync onAddSearch(query, queryIndex, provider) {\n\t\t\t\t\t\tif (pendingCurates.get(callId) !== pc) throw new Error(\"Curator session is no longer active.\");\n\t\t\t\t\t\tconst normalizedProvider = normalizeProviderInput(provider);\n\t\t\t\t\t\tconst requestedProvider = !normalizedProvider || normalizedProvider === \"auto\"\n\t\t\t\t\t\t\t? pc.searchProvider\n\t\t\t\t\t\t\t: normalizedProvider;\n\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\tconst { answer, results, inlineContent, provider: actualProvider } = await search(query, {\n\t\t\t\t\t\t\t\tprovider: requestedProvider,\n\t\t\t\t\t\t\t\tnumResults: pc.numResults,\n\t\t\t\t\t\t\t\trecencyFilter: pc.recencyFilter,\n\t\t\t\t\t\t\t\tdomainFilter: pc.domainFilter,\n\t\t\t\t\t\t\t\tincludeContent: pc.includeContent,\n\t\t\t\t\t\t\t\tsignal: addSearchSignal,\n\t\t\t\t\t\t\t\textensionContext: ctx,\n\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\tif (pendingCurates.get(callId) !== pc) throw new Error(\"Curator session is no longer active.\");\n\t\t\t\t\t\t\tpc.searchResults.set(queryIndex, { query, answer, results, error: null, provider: actualProvider });\n\t\t\t\t\t\t\tif (inlineContent) pc.allInlineContent.push(...inlineContent);\n\t\t\t\t\t\t\treturn {\n\t\t\t\t\t\t\t\tanswer,\n\t\t\t\t\t\t\t\tresults: results.map(r => ({ title: r.title, url: r.url, domain: extractDomain(r.url) })),\n\t\t\t\t\t\t\t\tprovider: actualProvider,\n\t\t\t\t\t\t\t};\n\t\t\t\t\t\t} catch (err) {\n\t\t\t\t\t\t\tconst message = err instanceof Error ? err.message : String(err);\n\t\t\t\t\t\t\tif (pendingCurates.get(callId) === pc) {\n\t\t\t\t\t\t\t\tpc.searchResults.set(queryIndex, { query, answer: \"\", results: [], error: message, provider: requestedProvider });\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tthrow err;\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\tasync onRewriteQuery(query, rewriteSignal) {\n\t\t\t\t\t\tif (pendingCurates.get(callId) !== pc) throw new Error(\"Curator session is no longer active.\");\n\t\t\t\t\t\treturn rewriteSearchQuery(query, pc.summaryContext, rewriteSignal);\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t);\n\n\t\t\tif (pendingCurates.get(callId) !== pc) {\n\t\t\t\thandle.close();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tactiveCurators.set(callId, handle);\n\t\t\tpc.curatorUrl = handle.url;\n\n\t\t\tfor (const [qi, data] of pc.searchResults) {\n\t\t\t\tif (data.error) {\n\t\t\t\t\thandle.pushError(qi, data.error, data.provider);\n\t\t\t\t} else {\n\t\t\t\t\thandle.pushResult(qi, {\n\t\t\t\t\t\tanswer: data.answer,\n\t\t\t\t\t\tresults: data.results.map(r => ({ title: r.title, url: r.url, domain: extractDomain(r.url) })),\n\t\t\t\t\t\tprovider: data.provider || pc.defaultProvider,\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t}\n\t\t\tif (searchesComplete) handle.searchesDone();\n\n\t\t\tconst sendCuratorFallbackUpdate = (message: string) => {\n\t\t\t\tpc.onUpdate?.({\n\t\t\t\t\tcontent: [{ type: \"text\", text: `${message}\\nOpen manually: ${handle.url}` }],\n\t\t\t\t\tdetails: {\n\t\t\t\t\t\tphase: \"curator-fallback\",\n\t\t\t\t\t\tprogress: searchesComplete ? 1 : 0.5,\n\t\t\t\t\t\tcuratorUrl: handle.url,\n\t\t\t\t\t\ttimeoutSeconds: pc.timeoutSeconds,\n\t\t\t\t\t\tshortcut: curateKey,\n\t\t\t\t\t\tbrowserOpenError: pc.browserOpenError,\n\t\t\t\t\t},\n\t\t\t\t});\n\t\t\t};\n\n\t\t\tpc.onUpdate?.({\n\t\t\t\tcontent: [{ type: \"text\", text: searchesComplete ? \"Waiting for summary approval in browser...\" : \"Searches streaming to browser...\" }],\n\t\t\t\tdetails: {\n\t\t\t\t\tphase: \"curating\",\n\t\t\t\t\tprogress: searchesComplete ? 1 : 0.5,\n\t\t\t\t\tcuratorUrl: handle.url,\n\t\t\t\t\ttimeoutSeconds: pc.timeoutSeconds,\n\t\t\t\t\tshortcut: curateKey,\n\t\t\t\t},\n\t\t\t});\n\n\t\t\tconst open = platform() === \"darwin\" ? await getGlimpseOpen() : null;\n\t\t\tif (open) {\n\t\t\t\ttry {\n\t\t\t\t\tconst win = openInGlimpse(open, handle.url, \"Search Curator\");\n\t\t\t\t\tglimpseWins.set(callId, win);\n\t\t\t\t\twin.on(\"closed\", () => {\n\t\t\t\t\t\tif (glimpseWins.get(callId) === win) {\n\t\t\t\t\t\t\tglimpseWins.delete(callId);\n\t\t\t\t\t\t\tcloseCurator(callId);\n\t\t\t\t\t\t}\n\t\t\t\t\t});\n\t\t\t\t\treturn;\n\t\t\t\t} catch (err) {\n\t\t\t\t\tconst message = err instanceof Error ? err.message : String(err);\n\t\t\t\t\tconsole.error(`Failed to open Glimpse curator window: ${message}`);\n\t\t\t\t\tglimpseWins.delete(callId);\n\t\t\t\t}\n\t\t\t}\n\t\t\tawait openInBrowser(pi, handle.url);\n\t\t} catch (err) {\n\t\t\tconst message = err instanceof Error ? err.message : String(err);\n\t\t\tconsole.error(`Failed to open curator UI: ${message}`);\n\t\t\tif (handle && activeCurators.get(callId) === handle && pendingCurates.get(callId) === pc) {\n\t\t\t\tpc.browserOpenError = message;\n\t\t\t\tsendCuratorFallbackUpdate(\"Search curator is running, but the browser did not open automatically.\");\n\t\t\t} else if (pendingCurates.get(callId) === pc || (handle && activeCurators.get(callId) === handle)) {\n\t\t\t\tcloseCurator(callId);\n\t\t\t}\n\t\t}\n\t}\n\n\tpi.registerShortcut(curateKey, {\n\t\tdescription: \"Review search results\",\n\t\thandler: async (ctx) => {\n\t\t\tconst entries = [...pendingCurates.entries()];\n\t\t\tif (entries.length === 0) return;\n\t\t\tconst [callId, pc] = entries[entries.length - 1];\n\n\t\t\tif (pc.phase === \"searching\") {\n\t\t\t\tpc.browserPromise = openCuratorBrowser(callId, pc, false);\n\t\t\t\tctx.ui.notify(\"Opening curator — remaining searches will stream in\", \"info\");\n\t\t\t\treturn;\n\t\t\t}\n\t\t},\n\t});\n\n\tpi.registerShortcut(activityKey, {\n\t\tdescription: \"Toggle web search activity\",\n\t\thandler: async (ctx) => {\n\t\t\twidgetVisible = !widgetVisible;\n\t\t\tif (widgetVisible) {\n\t\t\t\twidgetUnsubscribe = activityMonitor.onUpdate(() => updateWidget(ctx));\n\t\t\t\tupdateWidget(ctx);\n\t\t\t} else {\n\t\t\t\twidgetUnsubscribe?.();\n\t\t\t\twidgetUnsubscribe = null;\n\t\t\t\tctx.ui.setWidget(\"web-activity\", undefined);\n\t\t\t}\n\t\t},\n\t});\n\n\tpi.on(\"session_start\", async (_event, ctx) => handleSessionChange(ctx));\n\tpi.on(\"session_tree\", async (_event, ctx) => handleSessionChange(ctx));\n\n\tpi.on(\"session_shutdown\", () => {\n\t\tsessionActive = false;\n\t\tabortPendingFetches();\n\t\tcloseCurator();\n\t\tclearCloneCache();\n\t\tclearResults();\n\t\t// Unsubscribe before clear() to avoid callback with stale ctx\n\t\twidgetUnsubscribe?.();\n\t\twidgetUnsubscribe = null;\n\t\tactivityMonitor.clear();\n\t\twidgetVisible = false;\n\t});\n\n\tif (initConfig.webSearch?.enabled !== false) pi.registerTool({\n\t\tname: \"web_search\",\n\t\tlabel: \"Web Search\",\n\t\tdescription:\n\t\t\t`Search the web using OpenAI, Brave, Parallel, Tavily, Exa, Perplexity, or Gemini. Returns an AI-synthesized answer with source citations. OpenAI web_search uses a Codex subscription or OpenAI API key. For comprehensive research, prefer queries (plural) with 2-4 varied angles over a single query — each query gets its own synthesized answer, so varying phrasing and scope gives much broader coverage. When includeContent is true, full page content is fetched in the background. Searches auto-open the interactive browser curator and stream results live; set workflow to \"none\" to skip curation or \"auto-summary\" for a model-generated summary without the browser curator. Provider auto-selects: OpenAI when suitable and available, then Exa, Brave, Parallel, Tavily, Perplexity, Gemini API, then Gemini Web.`,\n\t\tpromptSnippet:\n\t\t\t\"Use for web research questions. Prefer {queries:[...]} with 2-4 varied angles over a single query for broader coverage.\",\n\t\tparameters: Type.Object({\n\t\t\tquery: Type.Optional(Type.String({ description: \"Single search query. For research tasks, prefer 'queries' with multiple varied angles instead.\" })),\n\t\t\tqueries: Type.Optional(Type.Array(Type.String(), { description: \"Multiple queries searched in sequence, each returning its own synthesized answer. Prefer this for research — vary phrasing, scope, and angle across 2-4 queries to maximize coverage. Good: ['React vs Vue performance benchmarks 2026', 'React vs Vue developer experience comparison', 'React ecosystem size vs Vue ecosystem']. Bad: ['React vs Vue', 'React vs Vue comparison', 'React vs Vue review'] (too similar, redundant results).\" })),\n\t\t\tnumResults: Type.Optional(Type.Number({ description: \"Results per query (default: 5, max: 20)\" })),\n\t\t\tincludeContent: Type.Optional(Type.Boolean({ description: \"Fetch full page content (async)\" })),\n\t\t\trecencyFilter: Type.Optional(\n\t\t\t\tStringEnum([\"day\", \"week\", \"month\", \"year\"], { description: \"Filter by recency\" }),\n\t\t\t),\n\t\t\tdomainFilter: Type.Optional(Type.Array(Type.String(), { description: \"Limit to domains (prefix with - to exclude)\" })),\n\t\t\tprovider: Type.Optional(\n\t\t\t\tStringEnum([\"auto\", \"openai\", \"brave\", \"parallel\", \"tavily\", \"exa\", \"perplexity\", \"gemini\"], { description: \"Search provider (default: auto)\" }),\n\t\t\t),\n\t\t\tworkflow: Type.Optional(\n\t\t\t\tStringEnum([\"none\", \"summary-review\", \"auto-summary\"], {\n\t\t\t\t\tdescription: \"Search workflow mode: none = no curator, summary-review = open curator with auto summary draft (default), auto-summary = generate summary without opening curator\",\n\t\t\t\t}),\n\t\t\t),\n\t\t}),\n\n\t\tasync execute(callId, params, signal, onUpdate, ctx) {\n\t\t\tconst rawQueryList: unknown[] = Array.isArray(params.queries)\n\t\t\t\t? params.queries\n\t\t\t\t: (params.query !== undefined ? [params.query] : []);\n\t\t\tconst queryList = normalizeQueryList(rawQueryList);\n\t\t\tconst configWorkflow = loadConfigForExtensionInit().workflow;\n\t\t\tconst workflow = resolveWorkflow(params.workflow ?? configWorkflow, ctx?.hasUI !== false);\n\t\t\tconst shouldCurate = workflow === \"summary-review\";\n\n\t\t\tif (queryList.length === 0) {\n\t\t\t\treturn {\n\t\t\t\t\tcontent: [{ type: \"text\", text: \"Error: No query provided. Use 'query' or 'queries' parameter.\" }],\n\t\t\t\t\tdetails: { error: \"No query provided\" },\n\t\t\t\t};\n\t\t\t}\n\n\t\t\tif (shouldCurate && !ctx) {\n\t\t\t\treturn {\n\t\t\t\t\tcontent: [{ type: \"text\", text: \"Error: Curation requires an active extension context.\" }],\n\t\t\t\t\tdetails: { error: \"Missing extension context\" },\n\t\t\t\t};\n\t\t\t}\n\n\t\t\tif (shouldCurate) {\n\t\t\t\tcloseCurator(callId);\n\n\t\t\t\tlet resolvePromise: (value: unknown) => void = () => {};\n\t\t\t\tconst promise = new Promise<unknown>((resolve) => {\n\t\t\t\t\tresolvePromise = resolve;\n\t\t\t\t});\n\t\t\t\tconst includeContent = params.includeContent ?? false;\n\t\t\t\tconst searchResults = new Map<number, QueryResultData>();\n\t\t\t\tconst allInlineContent: ExtractedContent[] = [];\n\t\t\t\tconst searchAbort = new AbortController();\n\t\t\t\tconst searchSignal = signal\n\t\t\t\t\t? AbortSignal.any([signal, searchAbort.signal])\n\t\t\t\t\t: searchAbort.signal;\n\t\t\t\tlet cancelled = false;\n\n\t\t\t\tconst bootstrap = await loadCuratorBootstrap(params.provider, ctx, {\n\t\t\t\t\tnumResults: params.numResults,\n\t\t\t\t\trecencyFilter: params.recencyFilter,\n\t\t\t\t});\n\t\t\t\tconst availableProviders = bootstrap.availableProviders;\n\t\t\t\tconst defaultProvider = bootstrap.defaultProvider;\n\t\t\t\tconst rawSearchProvider = normalizeProviderInput(params.provider ?? loadConfig().provider ?? \"auto\") ?? \"auto\";\n\t\t\t\tconst searchProvider = rawSearchProvider === \"auto\" ? \"auto\" : defaultProvider;\n\t\t\t\tconst curatorTimeoutSeconds = bootstrap.timeoutSeconds;\n\t\t\t\tconst curatorWorkflow: CuratorWorkflow = \"summary-review\";\n\n\t\t\t\tconst summaryContext: SummaryGenerationContext = {\n\t\t\t\t\tmodel: ctx.model,\n\t\t\t\t\tmodelRegistry: ctx.modelRegistry,\n\t\t\t\t\tcwd: ctx.cwd,\n\t\t\t\t\tisProjectTrusted: () => ctx.isProjectTrusted(),\n\t\t\t\t};\n\t\t\t\tconst summaryModelChoices = await loadSummaryModelChoices(summaryContext);\n\n\t\t\t\tconst pc: PendingCurate = {\n\t\t\t\t\tphase: \"searching\",\n\t\t\t\t\tworkflow: curatorWorkflow,\n\t\t\t\t\tsummaryContext,\n\t\t\t\t\tsearchResults,\n\t\t\t\t\tallInlineContent,\n\t\t\t\t\tqueryList,\n\t\t\t\t\tincludeContent,\n\t\t\t\t\tnumResults: params.numResults,\n\t\t\t\t\trecencyFilter: params.recencyFilter,\n\t\t\t\t\tdomainFilter: params.domainFilter,\n\t\t\t\t\tavailableProviders,\n\t\t\t\t\tdefaultProvider,\n\t\t\t\t\tsearchProvider,\n\t\t\t\t\tsummaryModels: summaryModelChoices.summaryModels,\n\t\t\t\t\tdefaultSummaryModel: summaryModelChoices.defaultSummaryModel,\n\t\t\t\t\ttimeoutSeconds: curatorTimeoutSeconds,\n\t\t\t\t\tonUpdate: onUpdate as PendingCurate[\"onUpdate\"],\n\t\t\t\t\tsignal,\n\t\t\t\t\tabortSearches: () => {\n\t\t\t\t\t\tif (!searchAbort.signal.aborted) searchAbort.abort();\n\t\t\t\t\t},\n\t\t\t\t\tfinish: () => {},\n\t\t\t\t\tcancel: () => {},\n\t\t\t\t};\n\n\t\t\t\tconst finish = (value: unknown) => {\n\t\t\t\t\tif (cancelled) return;\n\t\t\t\t\tcancelled = true;\n\t\t\t\t\tpc.abortSearches();\n\t\t\t\t\tsignal?.removeEventListener(\"abort\", onAbort);\n\t\t\t\t\tpendingCurates.delete(callId);\n\t\t\t\t\tresolvePromise(value);\n\t\t\t\t};\n\n\t\t\t\tconst cancel = (reason: \"user\" | \"stale\" = \"stale\") => {\n\t\t\t\t\tif (cancelled) return;\n\t\t\t\t\tconst conn = activeCurators.get(callId)?.getConnectionState();\n\t\t\t\t\tfinish(buildCurationCancelledReturn(reason, {\n\t\t\t\t\t\tqueries: Array.from(searchResults.values()),\n\t\t\t\t\t\tqueryCount: queryList.length,\n\t\t\t\t\t\tbrowserConnected: conn?.browserConnected,\n\t\t\t\t\t\tlastHeartbeatAgeMs: conn?.lastHeartbeatAgeMs,\n\t\t\t\t\t\tcuratorUrl: pc.curatorUrl,\n\t\t\t\t\t\tbrowserOpenError: pc.browserOpenError,\n\t\t\t\t\t}));\n\t\t\t\t};\n\n\t\t\t\tpc.finish = finish;\n\t\t\t\tpc.cancel = cancel;\n\n\t\t\t\tconst onAbort = () => closeCurator(callId);\n\t\t\t\tpendingCurates.set(callId, pc);\n\t\t\t\tsignal?.addEventListener(\"abort\", onAbort, { once: true });\n\t\t\t\tpc.browserPromise = openCuratorBrowser(callId, pc, false);\n\n\t\t\t\tfor (let qi = 0; qi < queryList.length; qi++) {\n\t\t\t\t\tif (signal?.aborted || cancelled || searchAbort.signal.aborted) break;\n\t\t\t\t\tonUpdate?.({\n\t\t\t\t\t\tcontent: [{ type: \"text\", text: `Searching ${qi + 1}/${queryList.length}: \"${queryList[qi]}\"...` }],\n\t\t\t\t\t\tdetails: { phase: \"searching\", progress: qi / queryList.length, currentQuery: queryList[qi] },\n\t\t\t\t\t});\n\t\t\t\t\tconst requestedProvider = pc.searchProvider;\n\t\t\t\t\ttry {\n\t\t\t\t\t\tconst { answer, results, inlineContent, provider } = await search(queryList[qi], {\n\t\t\t\t\t\t\tprovider: requestedProvider,\n\t\t\t\t\t\t\tnumResults: params.numResults,\n\t\t\t\t\t\t\trecencyFilter: params.recencyFilter,\n\t\t\t\t\t\t\tdomainFilter: params.domainFilter,\n\t\t\t\t\t\t\tincludeContent: params.includeContent,\n\t\t\t\t\t\t\tsignal: searchSignal,\n\t\t\t\t\t\t\textensionContext: ctx,\n\t\t\t\t\t\t});\n\t\t\t\t\t\tif (signal?.aborted || cancelled || searchAbort.signal.aborted) break;\n\t\t\t\t\t\tsearchResults.set(qi, { query: queryList[qi], answer, results, error: null, provider });\n\t\t\t\t\t\tif (inlineContent) allInlineContent.push(...inlineContent);\n\t\t\t\t\t\tconst curator = activeCurators.get(callId);\n\t\t\t\t\t\tif (curator) {\n\t\t\t\t\t\t\tcurator.pushResult(qi, {\n\t\t\t\t\t\t\t\tanswer,\n\t\t\t\t\t\t\t\tresults: results.map(r => ({ title: r.title, url: r.url, domain: extractDomain(r.url) })),\n\t\t\t\t\t\t\t\tprovider,\n\t\t\t\t\t\t\t});\n\t\t\t\t\t\t}\n\t\t\t\t\t} catch (err) {\n\t\t\t\t\t\tif (signal?.aborted || cancelled || searchAbort.signal.aborted) break;\n\t\t\t\t\t\tconst message = err instanceof Error ? err.message : String(err);\n\t\t\t\t\t\tsearchResults.set(qi, { query: queryList[qi], answer: \"\", results: [], error: message, provider: requestedProvider });\n\t\t\t\t\t\tconst curator = activeCurators.get(callId);\n\t\t\t\t\t\tif (curator) {\n\t\t\t\t\t\t\tcurator.pushError(qi, message, requestedProvider);\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tif (signal?.aborted || cancelled || searchAbort.signal.aborted) {\n\t\t\t\t\tcancel();\n\t\t\t\t\treturn promise;\n\t\t\t\t}\n\n\t\t\t\tawait pc.browserPromise;\n\t\t\t\tconst curator = activeCurators.get(callId);\n\t\t\t\tif (curator && !cancelled) {\n\t\t\t\t\tcurator.searchesDone();\n\t\t\t\t\tif (pc.browserOpenError) {\n\t\t\t\t\t\tpc.onUpdate?.({\n\t\t\t\t\t\t\tcontent: [{ type: \"text\", text: `All searches complete. Open the curator manually: ${pc.curatorUrl}` }],\n\t\t\t\t\t\t\tdetails: {\n\t\t\t\t\t\t\t\tphase: \"curator-fallback\",\n\t\t\t\t\t\t\t\tprogress: 1,\n\t\t\t\t\t\t\t\tcuratorUrl: pc.curatorUrl,\n\t\t\t\t\t\t\t\ttimeoutSeconds: pc.timeoutSeconds,\n\t\t\t\t\t\t\t\tshortcut: curateKey,\n\t\t\t\t\t\t\t\tbrowserOpenError: pc.browserOpenError,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t});\n\t\t\t\t\t} else {\n\t\t\t\t\t\tpc.onUpdate?.({\n\t\t\t\t\t\t\tcontent: [{ type: \"text\", text: \"All searches complete — waiting for summary approval in browser...\" }],\n\t\t\t\t\t\t\tdetails: {\n\t\t\t\t\t\t\t\tphase: \"curating\",\n\t\t\t\t\t\t\t\tprogress: 1,\n\t\t\t\t\t\t\t\tcuratorUrl: pc.curatorUrl,\n\t\t\t\t\t\t\t\ttimeoutSeconds: pc.timeoutSeconds,\n\t\t\t\t\t\t\t\tshortcut: curateKey,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t});\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\treturn promise;\n\t\t\t}\n\n\t\t\tconst searchResults: QueryResultData[] = [];\n\t\t\tconst allUrls: string[] = [];\n\t\t\tconst allInlineContent: ExtractedContent[] = [];\n\t\t\tconst resolvedProvider = normalizeProviderInput(params.provider ?? loadConfig().provider);\n\n\t\t\tfor (let i = 0; i < queryList.length; i++) {\n\t\t\t\tconst query = queryList[i];\n\n\t\t\t\tonUpdate?.({\n\t\t\t\t\tcontent: [{ type: \"text\", text: `Searching ${i + 1}/${queryList.length}: \"${query}\"...` }],\n\t\t\t\t\tdetails: { phase: \"search\", progress: i / queryList.length, currentQuery: query },\n\t\t\t\t});\n\n\t\t\t\ttry {\n\t\t\t\t\tconst { answer, results, inlineContent, provider } = await search(query, {\n\t\t\t\t\t\tprovider: resolvedProvider,\n\t\t\t\t\t\tnumResults: params.numResults,\n\t\t\t\t\t\trecencyFilter: params.recencyFilter,\n\t\t\t\t\t\tdomainFilter: params.domainFilter,\n\t\t\t\t\t\tincludeContent: params.includeContent,\n\t\t\t\t\t\tsignal,\n\t\t\t\t\t\textensionContext: ctx,\n\t\t\t\t\t});\n\n\t\t\t\t\tsearchResults.push({ query, answer, results, error: null, provider });\n\t\t\t\t\tfor (const r of results) {\n\t\t\t\t\t\tif (!allUrls.includes(r.url)) {\n\t\t\t\t\t\t\tallUrls.push(r.url);\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tif (inlineContent) allInlineContent.push(...inlineContent);\n\t\t\t\t} catch (err) {\n\t\t\t\t\tconst message = err instanceof Error ? err.message : String(err);\n\t\t\t\t\tconst requestedProvider = typeof resolvedProvider === \"string\" && resolvedProvider !== \"auto\"\n\t\t\t\t\t\t? resolvedProvider\n\t\t\t\t\t\t: undefined;\n\t\t\t\t\tsearchResults.push({ query, answer: \"\", results: [], error: message, provider: requestedProvider });\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tlet approvedSummary: string | undefined;\n\t\t\tlet summaryMeta: SummaryMeta | undefined;\n\t\t\tif (workflow === \"auto-summary\") {\n\t\t\t\tif (!ctx) {\n\t\t\t\t\treturn {\n\t\t\t\t\t\tcontent: [{ type: \"text\", text: \"Error: Auto-summary requires an active extension context.\" }],\n\t\t\t\t\t\tdetails: { error: \"Missing extension context\" },\n\t\t\t\t\t};\n\t\t\t\t}\n\t\t\t\tonUpdate?.({\n\t\t\t\t\tcontent: [{ type: \"text\", text: \"Generating summary...\" }],\n\t\t\t\t\tdetails: { phase: \"generating-summary\", progress: 1 },\n\t\t\t\t});\n\t\t\t\tconst summaryContext: SummaryGenerationContext = {\n\t\t\t\t\tmodel: ctx.model,\n\t\t\t\t\tmodelRegistry: ctx.modelRegistry,\n\t\t\t\t\tcwd: ctx.cwd,\n\t\t\t\t\tisProjectTrusted: () => ctx.isProjectTrusted(),\n\t\t\t\t};\n\t\t\t\tconst summaryModelChoices = await loadSummaryModelChoices(summaryContext);\n\t\t\t\tconst generated = await generateSummaryDraft(searchResults, summaryContext, signal, summaryModelChoices.defaultSummaryModel ?? undefined);\n\t\t\t\tapprovedSummary = generated.summary;\n\t\t\t\tsummaryMeta = generated.meta;\n\t\t\t}\n\n\t\t\treturn buildSearchReturn({\n\t\t\t\tqueryList,\n\t\t\t\tresults: searchResults,\n\t\t\t\turls: allUrls,\n\t\t\t\tincludeContent: params.includeContent ?? false,\n\t\t\t\tinlineContent: allInlineContent.length > 0 ? allInlineContent : undefined,\n\t\t\t\tworkflow: workflow === \"auto-summary\" ? \"auto-summary\" : undefined,\n\t\t\t\tapprovedSummary,\n\t\t\t\tsummaryMeta,\n\t\t\t});\n\t\t},\n\n\t\trenderCall(args, theme, context) {\n\t\t\t// lunr: collapsed header is one line (title · count). Query list lives in expanded renderResult.\n\t\t\tconst queryList = collectSearchQueries(args as { query?: unknown; queries?: unknown });\n\t\t\tconst compact = Boolean(context && !context.isPartial && !context.expanded && !context.isError);\n\t\t\tconst call = formatSearchCallTitle(queryList);\n\t\t\tif (call.empty) {\n\t\t\t\treturn new Text(theme.fg(\"toolTitle\", theme.bold(\"search \")) + theme.fg(\"error\", \"(no query)\"), 0, 0);\n\t\t\t}\n\t\t\tconst detail = formatSearchDetail(queryList, compact ? context?.result?.details : undefined);\n\t\t\treturn new Text(\n\t\t\t\tformatGroupedCall({\n\t\t\t\t\trole: context?.groupRole ?? \"singleton\",\n\t\t\t\t\tcompact,\n\t\t\t\t\tdot: context ? toolStatusDotFromContext(context, theme) : theme.fg(\"success\", \"●\"),\n\t\t\t\t\ttitle: theme.fg(\"toolTitle\", theme.bold(\"search\")),\n\t\t\t\t\tdetail: theme.fg(\"accent\", detail),\n\t\t\t\t}),\n\t\t\t\t0,\n\t\t\t\t0,\n\t\t\t);\n\t\t},\n\n\t\trenderResult(result, { expanded, isPartial }, theme, context) {\n\t\t\ttype QueryDetail = {\n\t\t\t\tquery: string;\n\t\t\t\tprovider: string | null;\n\t\t\t\tanswer: string | null;\n\t\t\t\tsources: Array<{ title: string; url: string }>;\n\t\t\t\terror: string | null;\n\t\t\t};\n\t\t\tconst details = result.details as {\n\t\t\t\tqueryCount?: number;\n\t\t\t\tsuccessfulQueries?: number;\n\t\t\t\ttotalResults?: number;\n\t\t\t\terror?: string;\n\t\t\t\tfetchId?: string;\n\t\t\t\tfetchUrls?: string[];\n\t\t\t\tphase?: string;\n\t\t\t\tprogress?: number;\n\t\t\t\tcurrentQuery?: string;\n\t\t\t\tcurated?: boolean;\n\t\t\t\tcuratedFrom?: number;\n\t\t\t\tcuratedQueries?: QueryDetail[];\n\t\t\t\tcancelled?: boolean;\n\t\t\t\tcancelReason?: string;\n\t\t\t\tbrowserConnected?: boolean;\n\t\t\t\tlastHeartbeatAgeMs?: number | null;\n\t\t\t\tcancelledQueries?: import(\"./render-search-error.ts\").CancelledQueryDetail[];\n\t\t\t\tcuratorUrl?: string;\n\t\t\t\tbrowserOpenError?: string;\n\t\t\t\ttimeoutSeconds?: number;\n\t\t\t\tshortcut?: string;\n\t\t\t\tsummary?: {\n\t\t\t\t\ttext: string;\n\t\t\t\t\tworkflow: SummaryWorkflow;\n\t\t\t\t\tmodel: string | null;\n\t\t\t\t\tdurationMs: number;\n\t\t\t\t\ttokenEstimate: number;\n\t\t\t\t\tfallbackUsed: boolean;\n\t\t\t\t\tfallbackReason?: string;\n\t\t\t\t\tedited?: boolean;\n\t\t\t\t};\n\t\t\t};\n\n\t\t\tif (isPartial) {\n\t\t\t\tif (details?.phase === \"curator-fallback\") {\n\t\t\t\t\tconst lines = [theme.fg(\"warning\", \"Open the search curator manually:\")];\n\t\t\t\t\tif (details?.curatorUrl) lines.push(theme.fg(\"muted\", ` ${details.curatorUrl}`));\n\t\t\t\t\tif (details?.browserOpenError) lines.push(theme.fg(\"dim\", ` auto-open failed: ${details.browserOpenError}`));\n\t\t\t\t\tconst timeout = typeof details?.timeoutSeconds === \"number\" ? details.timeoutSeconds : undefined;\n\t\t\t\t\tconst shortcut = typeof details?.shortcut === \"string\" ? details.shortcut : curateKey;\n\t\t\t\t\tlines.push(theme.fg(\"dim\", timeout ? ` auto-submits after ${timeout}s idle; ${shortcut} reopens` : ` ${shortcut} reopens`));\n\t\t\t\t\treturn new Text(lines.join(\"\\n\"), 0, 0);\n\t\t\t\t}\n\t\t\t\tif (details?.phase === \"curating\" || details?.phase === \"waiting-for-approval\" || details?.phase === \"generating-summary\") {\n\t\t\t\t\tconst phaseText = details?.phase === \"generating-summary\"\n\t\t\t\t\t\t? \"generating summary draft...\"\n\t\t\t\t\t\t: details?.phase === \"waiting-for-approval\"\n\t\t\t\t\t\t\t? \"summary draft ready; approve in browser...\"\n\t\t\t\t\t\t\t: \"waiting for summary approval in browser...\";\n\t\t\t\t\tconst lines = [theme.fg(\"accent\", phaseText)];\n\t\t\t\t\tif (details?.curatorUrl) {\n\t\t\t\t\t\tlines.push(theme.fg(\"muted\", ` ${details.curatorUrl}`));\n\t\t\t\t\t}\n\t\t\t\t\tconst timeout = typeof details?.timeoutSeconds === \"number\" ? details.timeoutSeconds : undefined;\n\t\t\t\t\tconst shortcut = typeof details?.shortcut === \"string\" ? details.shortcut : curateKey;\n\t\t\t\t\tif (timeout) {\n\t\t\t\t\t\tlines.push(theme.fg(\"dim\", ` auto-submits after ${timeout}s idle; ${shortcut} reopens`));\n\t\t\t\t\t} else {\n\t\t\t\t\t\tlines.push(theme.fg(\"dim\", ` ${shortcut} reopens`));\n\t\t\t\t\t}\n\t\t\t\t\treturn new Text(lines.join(\"\\n\"), 0, 0);\n\t\t\t\t}\n\t\t\t\tif (details?.phase === \"searching\") {\n\t\t\t\t\tconst progress = details?.progress ?? 0;\n\t\t\t\t\tconst bar = \"\\u2588\".repeat(Math.floor(progress * 10)) + \"\\u2591\".repeat(10 - Math.floor(progress * 10));\n\t\t\t\t\tconst query = details?.currentQuery || \"\";\n\t\t\t\t\tconst display = query.length > 40 ? query.slice(0, 37) + \"...\" : query;\n\t\t\t\t\treturn new Text(theme.fg(\"accent\", `[${bar}] ${display}`), 0, 0);\n\t\t\t\t}\n\t\t\t\tconst progress = details?.progress ?? 0;\n\t\t\t\tconst bar = \"\\u2588\".repeat(Math.floor(progress * 10)) + \"\\u2591\".repeat(10 - Math.floor(progress * 10));\n\t\t\t\treturn new Text(theme.fg(\"accent\", `[${bar}] ${details?.phase || \"searching\"}`), 0, 0);\n\t\t\t}\n\n\t\t\tif (details?.error) {\n\t\t\t\t// Expandable Ctrl+O diagnostics: which queries completed, per-query errors,\n\t\t\t\t// browser connection state, cancel reason. See render-search-error.ts.\n\t\t\t\tconst plan = buildSearchErrorPlan(details as SearchErrorDetails);\n\t\t\t\tif (plan) return renderSearchErrorPlan(plan, expanded, theme);\n\t\t\t\treturn new Text(theme.fg(\"error\", `Error: ${details.error}`), 0, 0);\n\t\t\t}\n\n\t\t\t// lunr: collapsed = status/count only. Expanded = query list, no answers/sources/summary.\n\t\t\tconst queryList = collectSearchQueries(context?.args);\n\t\t\tconst fallbackQueries = (details?.curatedQueries ?? []).map((cq) => cq.query).filter(Boolean);\n\t\t\tconst chrome = formatSearchChrome({\n\t\t\t\tqueries: queryList.length > 0 ? queryList : fallbackQueries,\n\t\t\t\tdetails,\n\t\t\t\texpanded,\n\t\t\t});\n\t\t\tconst statusColor = expanded ? \"accent\" : \"success\";\n\t\t\treturn new Text(chrome.result.map((line) => theme.fg(statusColor, line)).join(\"\\n\"), 0, 0);\n\t\t},\n\t});\n\n\tpi.registerTool({\n\t\tname: \"fetch_content\",\n\t\tlabel: \"Fetch Content\",\n\t\tdescription: \"Fetch URL(s) and extract readable content as markdown. Supports YouTube video transcripts (with thumbnail), GitHub repository contents, and local video files (with frame thumbnail). Video frames can be extracted via timestamp/range or sampled across the entire video with frames alone. Falls back to Gemini for pages that block bots or fail Readability extraction. For YouTube and video files: ALWAYS pass the user's specific question via the prompt parameter — this directs the AI to focus on that aspect of the video, producing much better results than a generic extraction. Content is always stored and can be retrieved with get_search_content.\",\n\t\tpromptSnippet:\n\t\t\t\"Use to extract readable content from URL(s), YouTube, GitHub repos, or local videos. For video questions, pass the user's exact question in prompt.\",\n\t\tparameters: Type.Object({\n\t\t\turl: Type.Optional(Type.String({ description: \"Single URL to fetch\" })),\n\t\t\turls: Type.Optional(Type.Array(Type.String(), { description: \"Multiple URLs (parallel)\" })),\n\t\t\tforceClone: Type.Optional(Type.Boolean({\n\t\t\t\tdescription: \"Force cloning large GitHub repositories that exceed the size threshold\",\n\t\t\t})),\n\t\t\tprompt: Type.Optional(Type.String({\n\t\t\t\tdescription: \"Question or instruction for video analysis (YouTube and video files). Pass the user's specific question here — e.g. 'describe the book shown at the advice for beginners section'. Without this, a generic transcript extraction is used which may miss what the user is asking about.\",\n\t\t\t})),\n\t\t\ttimestamp: Type.Optional(Type.String({\n\t\t\t\tdescription: \"Extract video frame(s) at a timestamp or time range. Single: '1:23:45', '23:45', or '85' (seconds). Range: '23:41-25:00' extracts evenly-spaced frames across that span (default 6). Use frames with ranges to control density; single+frames uses a fixed 5s interval. YouTube requires yt-dlp + ffmpeg; local videos require ffmpeg. Use a range when you know the approximate area but not the exact moment — you'll get a contact sheet to visually identify the right frame.\",\n\t\t\t})),\n\t\t\tframes: Type.Optional(Type.Integer({\n\t\t\t\tminimum: 1,\n\t\t\t\tmaximum: 12,\n\t\t\t\tdescription: \"Number of frames to extract. Use with timestamp range for custom density, with single timestamp to get N frames at 5s intervals, or alone to sample across the entire video. Requires yt-dlp + ffmpeg for YouTube, ffmpeg for local video.\",\n\t\t\t})),\n\t\t\tmodel: Type.Optional(Type.String({\n\t\t\t\tdescription: \"Override the Gemini model for video/YouTube analysis (e.g. 'gemini-2.5-flash', 'gemini-3-flash-preview'). Defaults to config or gemini-3-flash-preview.\",\n\t\t\t})),\n\t\t}),\n\n\t\tasync execute(_toolCallId, params, signal, onUpdate) {\n\t\t\tconst { urlList, options } = normalizeFetchContentParams(params);\n\t\t\tif (urlList.length === 0) {\n\t\t\t\treturn {\n\t\t\t\t\tcontent: [{ type: \"text\", text: \"Error: No URL provided.\" }],\n\t\t\t\t\tdetails: { error: \"No URL provided\" },\n\t\t\t\t};\n\t\t\t}\n\n\t\t\tonUpdate?.({\n\t\t\t\tcontent: [{ type: \"text\", text: `Fetching ${urlList.length} URL(s)...` }],\n\t\t\t\tdetails: { phase: \"fetch\", progress: 0 },\n\t\t\t});\n\n\t\t\tconst fetchResults = await fetchAllContent(urlList, signal, options);\n\t\t\tconst successful = fetchResults.filter((r) => !r.error).length;\n\t\t\tconst totalChars = fetchResults.reduce((sum, r) => sum + r.content.length, 0);\n\n\t\t\t// ALWAYS store results (even for single URL)\n\t\t\tconst responseId = generateId();\n\t\t\tconst data: StoredSearchData = {\n\t\t\t\tid: responseId,\n\t\t\t\ttype: \"fetch\",\n\t\t\t\ttimestamp: Date.now(),\n\t\t\t\turls: stripThumbnails(fetchResults),\n\t\t\t};\n\t\t\tstoreResult(responseId, data);\n\t\t\tpi.appendEntry(\"web-search-results\", data);\n\n\t\t\t// Single URL: return content directly (possibly truncated) with responseId\n\t\t\tif (urlList.length === 1) {\n\t\t\t\tconst result = fetchResults[0];\n\t\t\t\tif (result.error) {\n\t\t\t\t\treturn {\n\t\t\t\t\t\tcontent: [{ type: \"text\", text: `Error: ${result.error}` }],\n\t\t\t\t\t\tdetails: { urls: urlList, urlCount: 1, successful: 0, error: result.error, responseId, prompt: params.prompt, timestamp: params.timestamp, frames: params.frames },\n\t\t\t\t\t};\n\t\t\t\t}\n\n\t\t\t\tconst fullLength = result.content.length;\n\t\t\t\tconst truncated = fullLength > MAX_INLINE_CONTENT;\n\t\t\t\tlet output = truncated\n\t\t\t\t\t? result.content.slice(0, MAX_INLINE_CONTENT) + \"\\n\\n[Content truncated...]\"\n\t\t\t\t\t: result.content;\n\n\t\t\t\tif (truncated) {\n\t\t\t\t\toutput += `\\n\\n---\\nShowing ${MAX_INLINE_CONTENT} of ${fullLength} chars. ` +\n\t\t\t\t\t\t`Use get_search_content({ responseId: \"${responseId}\", urlIndex: 0 }) for full content.`;\n\t\t\t\t}\n\n\t\t\t\tconst content: Array<{ type: string; text?: string; data?: string; mimeType?: string }> = [];\n\t\t\t\tif (result.frames?.length) {\n\t\t\t\t\tfor (const frame of result.frames) {\n\t\t\t\t\t\tcontent.push({ type: \"image\", data: frame.data, mimeType: frame.mimeType });\n\t\t\t\t\t\tcontent.push({ type: \"text\", text: `Frame at ${frame.timestamp}` });\n\t\t\t\t\t}\n\t\t\t\t} else if (result.thumbnail) {\n\t\t\t\t\tcontent.push({ type: \"image\", data: result.thumbnail.data, mimeType: result.thumbnail.mimeType });\n\t\t\t\t}\n\t\t\t\tcontent.push({ type: \"text\", text: output });\n\n\t\t\t\tconst imageCount = (result.frames?.length ?? 0) + (result.thumbnail ? 1 : 0);\n\t\t\t\treturn {\n\t\t\t\t\tcontent,\n\t\t\t\t\tdetails: {\n\t\t\t\t\t\turls: urlList,\n\t\t\t\t\t\turlCount: 1,\n\t\t\t\t\t\tsuccessful: 1,\n\t\t\t\t\t\ttotalChars: fullLength,\n\t\t\t\t\t\ttitle: result.title,\n\t\t\t\t\t\tresponseId,\n\t\t\t\t\t\ttruncated,\n\t\t\t\t\t\thasImage: imageCount > 0,\n\t\t\t\t\t\timageCount,\n\t\t\t\t\t\tprompt: params.prompt,\n\t\t\t\t\t\ttimestamp: params.timestamp,\n\t\t\t\t\t\tframes: params.frames,\n\t\t\t\t\t\tduration: result.duration,\n\t\t\t\t\t},\n\t\t\t\t};\n\t\t\t}\n\n\t\t\t// Multi-URL: existing behavior (summary + responseId)\n\t\t\tlet output = \"## Fetched URLs\\n\\n\";\n\t\t\tfor (const { url, title, content, error } of fetchResults) {\n\t\t\t\tif (error) {\n\t\t\t\t\toutput += `- ${url}: Error - ${error}\\n`;\n\t\t\t\t} else {\n\t\t\t\t\toutput += `- ${title || url} (${content.length} chars)\\n`;\n\t\t\t\t}\n\t\t\t}\n\t\t\toutput += `\\n---\\nUse get_search_content({ responseId: \"${responseId}\", urlIndex: 0 }) to retrieve full content.`;\n\n\t\t\treturn {\n\t\t\t\tcontent: [{ type: \"text\", text: output }],\n\t\t\t\tdetails: { urls: urlList, urlCount: urlList.length, successful, totalChars, responseId },\n\t\t\t};\n\t\t},\n\n\t\trenderCall(args, theme, context) {\n\t\t\t// lunr: collapsed header is one line (title · count). URL list lives in expanded renderResult.\n\t\t\tconst urlList = collectFetchUrls(args as { url?: string; urls?: string[] });\n\t\t\tconst compact = Boolean(context && !context.isPartial && !context.expanded && !context.isError);\n\t\t\tconst call = formatFetchCallTitle(urlList);\n\t\t\tif (call.empty) {\n\t\t\t\treturn new Text(theme.fg(\"toolTitle\", theme.bold(\"fetch \")) + theme.fg(\"error\", \"(no URL)\"), 0, 0);\n\t\t\t}\n\t\t\tconst detail = formatFetchDetail(urlList, compact ? context?.result?.details : undefined);\n\t\t\treturn new Text(\n\t\t\t\tformatGroupedCall({\n\t\t\t\t\trole: context?.groupRole ?? \"singleton\",\n\t\t\t\t\tcompact,\n\t\t\t\t\tdot: context ? toolStatusDotFromContext(context, theme) : theme.fg(\"success\", \"●\"),\n\t\t\t\t\ttitle: theme.fg(\"toolTitle\", theme.bold(\"fetch\")),\n\t\t\t\t\tdetail: theme.fg(\"accent\", detail),\n\t\t\t\t}),\n\t\t\t\t0,\n\t\t\t\t0,\n\t\t\t);\n\t\t},\n\n\t\trenderResult(result, { expanded, isPartial }, theme, context) {\n\t\t\tconst details = result.details as {\n\t\t\t\turlCount?: number;\n\t\t\t\tsuccessful?: number;\n\t\t\t\ttotalChars?: number;\n\t\t\t\terror?: string;\n\t\t\t\ttitle?: string;\n\t\t\t\ttruncated?: boolean;\n\t\t\t\tresponseId?: string;\n\t\t\t\tphase?: string;\n\t\t\t\tprogress?: number;\n\t\t\t\thasImage?: boolean;\n\t\t\t\timageCount?: number;\n\t\t\t\tprompt?: string;\n\t\t\t\ttimestamp?: string;\n\t\t\t\tframes?: number;\n\t\t\t\tduration?: number;\n\t\t\t};\n\n\t\t\tif (isPartial) {\n\t\t\t\tconst progress = details?.progress ?? 0;\n\t\t\t\tconst bar = \"\\u2588\".repeat(Math.floor(progress * 10)) + \"\\u2591\".repeat(10 - Math.floor(progress * 10));\n\t\t\t\treturn new Text(theme.fg(\"accent\", `[${bar}] ${details?.phase || \"fetching\"}`), 0, 0);\n\t\t\t}\n\n\t\t\tif (details?.error) {\n\t\t\t\tconst fd = details as typeof details & { urls?: string[] };\n\t\t\t\tconst extras: string[] = [];\n\t\t\t\tif (typeof fd.urlCount === \"number\" || typeof fd.successful === \"number\") {\n\t\t\t\t\textras.push(`urls: ${fd.successful ?? 0}/${fd.urlCount ?? 0} succeeded`);\n\t\t\t\t}\n\t\t\t\tif (fd.responseId) extras.push(`response id: ${fd.responseId}`);\n\t\t\t\tif (fd.urls && fd.urls.length > 0) {\n\t\t\t\t\tfor (const u of fd.urls.slice(0, 8)) extras.push(` \\u25b8 ${u}`);\n\t\t\t\t\tif (fd.urls.length > 8) extras.push(` ... and ${fd.urls.length - 8} more`);\n\t\t\t\t}\n\t\t\t\tconst plan = buildSearchErrorPlan({ error: details.error, extraLines: extras });\n\t\t\t\tif (plan) return renderSearchErrorPlan(plan, expanded, theme);\n\t\t\t\treturn new Text(theme.fg(\"error\", `Error: ${details.error}`), 0, 0);\n\t\t\t}\n\n\t\t\t// lunr: collapsed = count/status only. Expanded = URL list, no markdown/prompt/frames.\n\t\t\tconst urlList = collectFetchUrls(context?.args);\n\t\t\tconst fallbackUrls = Array.isArray((details as { urls?: string[] })?.urls)\n\t\t\t\t? (details as { urls?: string[] }).urls\n\t\t\t\t: [];\n\t\t\tconst chrome = formatFetchChrome({\n\t\t\t\turls: urlList.length > 0 ? urlList : fallbackUrls,\n\t\t\t\tdetails,\n\t\t\t\texpanded,\n\t\t\t});\n\t\t\tconst statusColor = expanded ? \"accent\" : ((details?.successful ?? 0) > 0 || details?.urlCount === 1 ? \"success\" : \"error\");\n\t\t\treturn new Text(chrome.result.map((line) => theme.fg(statusColor, line)).join(\"\\n\"), 0, 0);\n\t\t},\n\t});\n\n\tpi.registerTool({\n\t\tname: \"get_search_content\",\n\t\tlabel: \"Get Search Content\",\n\t\tdescription: \"Retrieve full content from a previous web_search or fetch_content call.\",\n\t\tpromptSnippet:\n\t\t\t\"Use after web_search/fetch_content when full stored content is needed via responseId plus query/url selectors.\",\n\t\tparameters: Type.Object({\n\t\t\tresponseId: Type.String({ description: \"The responseId from web_search or fetch_content\" }),\n\t\t\tquery: Type.Optional(Type.String({ description: \"Get content for this query (web_search)\" })),\n\t\t\tqueryIndex: Type.Optional(Type.Number({ description: \"Get content for query at index\" })),\n\t\t\turl: Type.Optional(Type.String({ description: \"Get content for this URL\" })),\n\t\t\turlIndex: Type.Optional(Type.Number({ description: \"Get content for URL at index\" })),\n\t\t}),\n\n\t\tasync execute(_toolCallId, params) {\n\t\t\tconst data = getResult(params.responseId);\n\t\t\tif (!data) {\n\t\t\t\treturn {\n\t\t\t\t\tcontent: [{ type: \"text\", text: `Error: No stored results for \"${params.responseId}\"` }],\n\t\t\t\t\tdetails: { error: \"Not found\", responseId: params.responseId },\n\t\t\t\t};\n\t\t\t}\n\n\t\t\tif (data.type === \"search\" && data.queries) {\n\t\t\t\tlet queryData: QueryResultData | undefined;\n\n\t\t\t\tif (params.query !== undefined) {\n\t\t\t\t\tqueryData = data.queries.find((q) => q.query === params.query);\n\t\t\t\t\tif (!queryData) {\n\t\t\t\t\t\tconst available = data.queries.map((q) => `\"${q.query}\"`).join(\", \");\n\t\t\t\t\t\treturn {\n\t\t\t\t\t\t\tcontent: [{ type: \"text\", text: `Query \"${params.query}\" not found. Available: ${available}` }],\n\t\t\t\t\t\t\tdetails: { error: \"Query not found\" },\n\t\t\t\t\t\t};\n\t\t\t\t\t}\n\t\t\t\t} else if (params.queryIndex !== undefined) {\n\t\t\t\t\tqueryData = data.queries[params.queryIndex];\n\t\t\t\t\tif (!queryData) {\n\t\t\t\t\t\treturn {\n\t\t\t\t\t\t\tcontent: [{ type: \"text\", text: `Index ${params.queryIndex} out of range (0-${data.queries.length - 1})` }],\n\t\t\t\t\t\t\tdetails: { error: \"Index out of range\" },\n\t\t\t\t\t\t};\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tconst available = data.queries.map((q, i) => `${i}: \"${q.query}\"`).join(\", \");\n\t\t\t\t\treturn {\n\t\t\t\t\t\tcontent: [{ type: \"text\", text: `Specify query or queryIndex. Available: ${available}` }],\n\t\t\t\t\t\tdetails: { error: \"No query specified\" },\n\t\t\t\t\t};\n\t\t\t\t}\n\n\t\t\t\tif (queryData.error) {\n\t\t\t\t\treturn {\n\t\t\t\t\t\tcontent: [{ type: \"text\", text: `Error for \"${queryData.query}\": ${queryData.error}` }],\n\t\t\t\t\t\tdetails: { error: queryData.error, query: queryData.query },\n\t\t\t\t\t};\n\t\t\t\t}\n\n\t\t\t\treturn {\n\t\t\t\t\tcontent: [{ type: \"text\", text: formatFullResults(queryData) }],\n\t\t\t\t\tdetails: { query: queryData.query, resultCount: queryData.results.length },\n\t\t\t\t};\n\t\t\t}\n\n\t\t\tif (data.type === \"fetch\" && data.urls) {\n\t\t\t\tlet urlData: ExtractedContent | undefined;\n\n\t\t\t\tif (params.url !== undefined) {\n\t\t\t\t\turlData = data.urls.find((u) => u.url === params.url);\n\t\t\t\t\tif (!urlData) {\n\t\t\t\t\t\tconst available = data.urls.map((u) => u.url).join(\"\\n \");\n\t\t\t\t\t\treturn {\n\t\t\t\t\t\t\tcontent: [{ type: \"text\", text: `URL not found. Available:\\n ${available}` }],\n\t\t\t\t\t\t\tdetails: { error: \"URL not found\" },\n\t\t\t\t\t\t};\n\t\t\t\t\t}\n\t\t\t\t} else if (params.urlIndex !== undefined) {\n\t\t\t\t\turlData = data.urls[params.urlIndex];\n\t\t\t\t\tif (!urlData) {\n\t\t\t\t\t\treturn {\n\t\t\t\t\t\t\tcontent: [{ type: \"text\", text: `Index ${params.urlIndex} out of range (0-${data.urls.length - 1})` }],\n\t\t\t\t\t\t\tdetails: { error: \"Index out of range\" },\n\t\t\t\t\t\t};\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tconst available = data.urls.map((u, i) => `${i}: ${u.url}`).join(\"\\n \");\n\t\t\t\t\treturn {\n\t\t\t\t\t\tcontent: [{ type: \"text\", text: `Specify url or urlIndex. Available:\\n ${available}` }],\n\t\t\t\t\t\tdetails: { error: \"No URL specified\" },\n\t\t\t\t\t};\n\t\t\t\t}\n\n\t\t\t\tif (urlData.error) {\n\t\t\t\t\treturn {\n\t\t\t\t\t\tcontent: [{ type: \"text\", text: `Error for ${urlData.url}: ${urlData.error}` }],\n\t\t\t\t\t\tdetails: { error: urlData.error, url: urlData.url },\n\t\t\t\t\t};\n\t\t\t\t}\n\n\t\t\t\treturn {\n\t\t\t\t\tcontent: [{ type: \"text\", text: `# ${urlData.title}\\n\\n${urlData.content}` }],\n\t\t\t\t\tdetails: { url: urlData.url, title: urlData.title, contentLength: urlData.content.length },\n\t\t\t\t};\n\t\t\t}\n\n\t\t\treturn {\n\t\t\t\tcontent: [{ type: \"text\", text: \"Invalid stored data format\" }],\n\t\t\t\tdetails: { error: \"Invalid data\" },\n\t\t\t};\n\t\t},\n\n\t\trenderCall(args, theme) {\n\t\t\tconst { responseId, query, queryIndex, url, urlIndex } = args as {\n\t\t\t\tresponseId: string;\n\t\t\t\tquery?: string;\n\t\t\t\tqueryIndex?: number;\n\t\t\t\turl?: string;\n\t\t\t\turlIndex?: number;\n\t\t\t};\n\t\t\tlet target = \"\";\n\t\t\tif (query) target = `query=\"${query}\"`;\n\t\t\telse if (queryIndex !== undefined) target = `queryIndex=${queryIndex}`;\n\t\t\telse if (url) target = url.length > 30 ? url.slice(0, 27) + \"...\" : url;\n\t\t\telse if (urlIndex !== undefined) target = `urlIndex=${urlIndex}`;\n\t\t\treturn new Text(theme.fg(\"toolTitle\", theme.bold(\"get_content \")) + theme.fg(\"accent\", target || responseId.slice(0, 8)), 0, 0);\n\t\t},\n\n\t\trenderResult(result, { expanded }, theme) {\n\t\t\tconst details = result.details as {\n\t\t\t\terror?: string;\n\t\t\t\tquery?: string;\n\t\t\t\turl?: string;\n\t\t\t\ttitle?: string;\n\t\t\t\tresultCount?: number;\n\t\t\t\tcontentLength?: number;\n\t\t\t};\n\n\t\t\tif (details?.error) {\n\t\t\t\tconst extras: string[] = [];\n\t\t\t\tif (details.query) extras.push(`query: ${details.query}`);\n\t\t\t\tif (details.url) extras.push(`url: ${details.url}`);\n\t\t\t\telse if (details.title) extras.push(`resource: ${details.title}`);\n\t\t\t\tconst plan = buildSearchErrorPlan({ error: details.error, extraLines: extras });\n\t\t\t\tif (plan) return renderSearchErrorPlan(plan, expanded, theme);\n\t\t\t\treturn new Text(theme.fg(\"error\", `Error: ${details.error}`), 0, 0);\n\t\t\t}\n\n\t\t\tlet statusLine: string;\n\t\t\tif (details?.query) {\n\t\t\t\tstatusLine = theme.fg(\"success\", `\"${details.query}\"`) + theme.fg(\"muted\", ` (${details.resultCount} results)`);\n\t\t\t} else {\n\t\t\t\tstatusLine = theme.fg(\"success\", details?.title || \"Content\") + theme.fg(\"muted\", ` (${details?.contentLength ?? 0} chars)`);\n\t\t\t}\n\n\t\t\tif (!expanded) {\n\t\t\t\treturn new Text(statusLine, 0, 0);\n\t\t\t}\n\n\t\t\tconst textContent = result.content.find((c) => c.type === \"text\")?.text || \"\";\n\t\t\tconst preview = textContent.length > 500 ? textContent.slice(0, 500) + \"...\" : textContent;\n\t\t\treturn new Text(statusLine + \"\\n\" + theme.fg(\"dim\", preview), 0, 0);\n\t\t},\n\t});\n\n\tpi.registerCommand(\"websearch\", {\n\t\tdescription: \"Open web search curator\",\n\t\thandler: async (args, ctx) => {\n\t\t\tconst sessionToken = randomUUID();\n\t\t\tconst commandCallId = `cmd:${sessionToken}`;\n\t\t\tcloseCurator(commandCallId);\n\n\t\t\tconst raw = args.trim();\n\t\t\tconst queries = raw.length > 0\n\t\t\t\t? normalizeQueryList(raw.split(\",\"))\n\t\t\t\t: [];\n\n\t\t\tlet bootstrap: CuratorBootstrap;\n\t\t\ttry {\n\t\t\t\tbootstrap = await loadCuratorBootstrap(undefined, ctx);\n\t\t\t} catch (err) {\n\t\t\t\tconst message = err instanceof Error ? err.message : String(err);\n\t\t\t\tctx.ui.notify(`Failed to load web search config: ${message}`, \"error\");\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tconst availableProviders = bootstrap.availableProviders;\n\t\t\tconst initialProvider = bootstrap.defaultProvider;\n\t\t\tconst curatorTimeoutSeconds = bootstrap.timeoutSeconds;\n\t\t\tlet currentProvider = initialProvider;\n\t\t\tconst rawSearchProvider = normalizeProviderInput(loadConfig().provider ?? \"auto\") ?? \"auto\";\n\t\t\tlet currentSearchProvider = rawSearchProvider === \"auto\" ? \"auto\" : initialProvider;\n\t\t\tconst summaryContext: SummaryGenerationContext = {\n\t\t\t\tmodel: ctx.model,\n\t\t\t\tmodelRegistry: ctx.modelRegistry,\n\t\t\t\tcwd: ctx.cwd,\n\t\t\t\tisProjectTrusted: () => ctx.isProjectTrusted(),\n\t\t\t};\n\t\t\tconst summaryModelChoices = await loadSummaryModelChoices(summaryContext);\n\n\t\t\tctx.ui.notify(\"Opening web search curator...\", \"info\");\n\n\t\t\tconst collected = new Map<number, QueryResultData>();\n\t\t\tconst searchAbort = new AbortController();\n\t\t\tlet aborted = false;\n\t\t\tlet commandHandle: CuratorServerHandle | null = null;\n\t\t\tconst isCommandActive = () => commandHandle !== null && activeCurators.get(commandCallId) === commandHandle;\n\n\t\t\tfunction sendFollowUpFromReturn(payload: ReturnType<typeof buildSearchReturn>) {\n\t\t\t\tpi.sendMessage({\n\t\t\t\t\tcustomType: \"web-search-results\",\n\t\t\t\t\tcontent: payload.content,\n\t\t\t\t\tdisplay: true,\n\t\t\t\t\tdetails: payload.details,\n\t\t\t\t}, { triggerTurn: true, deliverAs: \"followUp\" });\n\t\t\t}\n\n\t\t\ttry {\n\t\t\t\tconst handle = await startCuratorServer(\n\t\t\t\t\t{\n\t\t\t\t\t\tqueries,\n\t\t\t\t\t\tsessionToken,\n\t\t\t\t\t\ttimeout: curatorTimeoutSeconds,\n\t\t\t\t\t\tavailableProviders,\n\t\t\t\t\t\tdefaultProvider: initialProvider,\n\t\t\t\t\t\tsearchProvider: currentSearchProvider,\n\t\t\t\t\t\tsummaryModels: summaryModelChoices.summaryModels,\n\t\t\t\t\t\tdefaultSummaryModel: summaryModelChoices.defaultSummaryModel,\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tasync onSummarize(selectedQueryIndices, summarizeSignal, model, feedback) {\n\t\t\t\t\t\t\tif (commandHandle && !isCommandActive()) {\n\t\t\t\t\t\t\t\tthrow new Error(\"Curator session is no longer active.\");\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\treturn generateSummaryForSelectedIndices(\n\t\t\t\t\t\t\t\tselectedQueryIndices,\n\t\t\t\t\t\t\t\tcollected,\n\t\t\t\t\t\t\t\tsummaryContext,\n\t\t\t\t\t\t\t\tsummarizeSignal,\n\t\t\t\t\t\t\t\tmodel,\n\t\t\t\t\t\t\t\tfeedback,\n\t\t\t\t\t\t\t);\n\t\t\t\t\t\t},\n\t\t\t\t\t\tonSubmit(payload) {\n\t\t\t\t\t\t\tif (commandHandle && !isCommandActive()) return;\n\t\t\t\t\t\t\taborted = true;\n\t\t\t\t\t\t\tsearchAbort.abort();\n\t\t\t\t\t\t\tconst filtered = payload.selectedQueryIndices.length > 0\n\t\t\t\t\t\t\t\t? filterByQueryIndices(payload.selectedQueryIndices, collected)\n\t\t\t\t\t\t\t\t: collectAllResultsAndUrls(collected);\n\t\t\t\t\t\t\tconst base: SearchReturnOptions = {\n\t\t\t\t\t\t\t\tqueryList: filtered.results.map(r => r.query),\n\t\t\t\t\t\t\t\tresults: filtered.results,\n\t\t\t\t\t\t\t\turls: filtered.urls,\n\t\t\t\t\t\t\t\tincludeContent: false,\n\t\t\t\t\t\t\t\tcurated: true,\n\t\t\t\t\t\t\t\tcuratedFrom: collected.size,\n\t\t\t\t\t\t\t};\n\t\t\t\t\t\t\tif (!payload.rawResults) {\n\t\t\t\t\t\t\t\tconst resolvedSummary = resolveSummaryForSubmit(payload, collected);\n\t\t\t\t\t\t\t\tbase.workflow = \"summary-review\";\n\t\t\t\t\t\t\t\tbase.approvedSummary = resolvedSummary.approvedSummary;\n\t\t\t\t\t\t\t\tbase.summaryMeta = resolvedSummary.summaryMeta;\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tsendFollowUpFromReturn(buildSearchReturn(base));\n\t\t\t\t\t\t\tcloseCurator(commandCallId);\n\t\t\t\t\t\t},\n\t\t\t\t\t\tonCancel(reason) {\n\t\t\t\t\t\t\tif (commandHandle && !isCommandActive()) return;\n\t\t\t\t\t\t\taborted = true;\n\t\t\t\t\t\t\tsearchAbort.abort();\n\t\t\t\t\t\t\tif (reason === \"timeout\") {\n\t\t\t\t\t\t\t\tconst all = collectAllResultsAndUrls(collected);\n\t\t\t\t\t\t\t\tconst resolvedSummary = resolveSummaryForSubmit({ selectedQueryIndices: [], summary: undefined, summaryMeta: undefined }, collected);\n\t\t\t\t\t\t\t\tsendFollowUpFromReturn(buildSearchReturn({\n\t\t\t\t\t\t\t\t\tqueryList: all.results.map(r => r.query),\n\t\t\t\t\t\t\t\t\tresults: all.results,\n\t\t\t\t\t\t\t\t\turls: all.urls,\n\t\t\t\t\t\t\t\t\tincludeContent: false,\n\t\t\t\t\t\t\t\t\tcurated: true,\n\t\t\t\t\t\t\t\t\tcuratedFrom: collected.size,\n\t\t\t\t\t\t\t\t\tworkflow: \"summary-review\",\n\t\t\t\t\t\t\t\t\tapprovedSummary: resolvedSummary.approvedSummary,\n\t\t\t\t\t\t\t\t\tsummaryMeta: resolvedSummary.summaryMeta,\n\t\t\t\t\t\t\t\t}));\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tcloseCurator(commandCallId);\n\t\t\t\t\t\t},\n\t\t\t\t\t\tonProviderChange(provider) {\n\t\t\t\t\t\t\tif (commandHandle && !isCommandActive()) return;\n\t\t\t\t\t\t\tconst normalized = normalizeProviderInput(provider);\n\t\t\t\t\t\t\tif (!normalized || normalized === \"auto\") return;\n\t\t\t\t\t\t\tcurrentProvider = normalized;\n\t\t\t\t\t\t\tcurrentSearchProvider = normalized;\n\t\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\t\tsaveConfig({ provider: normalized });\n\t\t\t\t\t\t\t} catch (err) {\n\t\t\t\t\t\t\t\tconst message = err instanceof Error ? err.message : String(err);\n\t\t\t\t\t\t\t\tconsole.error(`Failed to persist default provider: ${message}`);\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t},\n\t\t\t\t\t\tasync onAddSearch(query, queryIndex, provider) {\n\t\t\t\t\t\t\tif (commandHandle && !isCommandActive()) {\n\t\t\t\t\t\t\t\tthrow new Error(\"Curator session is no longer active.\");\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tconst normalizedProvider = normalizeProviderInput(provider);\n\t\t\t\t\t\t\tconst requestedProvider = !normalizedProvider || normalizedProvider === \"auto\"\n\t\t\t\t\t\t\t\t? currentSearchProvider\n\t\t\t\t\t\t\t\t: normalizedProvider;\n\t\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\t\tconst { answer, results, provider: actualProvider } = await search(query, {\n\t\t\t\t\t\t\t\t\tprovider: requestedProvider,\n\t\t\t\t\t\t\t\t\tsignal: searchAbort.signal,\n\t\t\t\t\t\t\t\t\textensionContext: ctx,\n\t\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\t\tif (commandHandle && !isCommandActive()) {\n\t\t\t\t\t\t\t\t\tthrow new Error(\"Curator session is no longer active.\");\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\tcollected.set(queryIndex, { query, answer, results, error: null, provider: actualProvider });\n\t\t\t\t\t\t\t\treturn {\n\t\t\t\t\t\t\t\t\tanswer,\n\t\t\t\t\t\t\t\t\tresults: results.map(r => ({ title: r.title, url: r.url, domain: extractDomain(r.url) })),\n\t\t\t\t\t\t\t\t\tprovider: actualProvider,\n\t\t\t\t\t\t\t\t};\n\t\t\t\t\t\t\t} catch (err) {\n\t\t\t\t\t\t\t\tconst message = err instanceof Error ? err.message : String(err);\n\t\t\t\t\t\t\t\tif (!commandHandle || isCommandActive()) {\n\t\t\t\t\t\t\t\t\tcollected.set(queryIndex, { query, answer: \"\", results: [], error: message, provider: requestedProvider });\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\tthrow err;\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t},\n\t\t\t\t\t\tasync onRewriteQuery(query, rewriteSignal) {\n\t\t\t\t\t\t\tif (commandHandle && !isCommandActive()) {\n\t\t\t\t\t\t\t\tthrow new Error(\"Curator session is no longer active.\");\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\treturn rewriteSearchQuery(query, summaryContext, rewriteSignal);\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t);\n\n\t\t\t\tcommandHandle = handle;\n\t\t\t\tactiveCurators.set(commandCallId, handle);\n\t\t\t\tconst open = platform() === \"darwin\" ? await getGlimpseOpen() : null;\n\t\t\t\tlet browserOpenError: string | null = null;\n\t\t\t\tif (open) {\n\t\t\t\t\ttry {\n\t\t\t\t\t\tconst win = openInGlimpse(open, handle.url, \"Search Curator\");\n\t\t\t\t\t\tglimpseWins.set(commandCallId, win);\n\t\t\t\t\t\twin.on(\"closed\", () => {\n\t\t\t\t\t\t\tif (glimpseWins.get(commandCallId) === win) {\n\t\t\t\t\t\t\t\tglimpseWins.delete(commandCallId);\n\t\t\t\t\t\t\t\tcloseCurator(commandCallId);\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t});\n\t\t\t\t\t} catch (err) {\n\t\t\t\t\t\tconst message = err instanceof Error ? err.message : String(err);\n\t\t\t\t\t\tconsole.error(`Failed to open Glimpse curator window: ${message}`);\n\t\t\t\t\t\tglimpseWins.delete(commandCallId);\n\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\tawait openInBrowser(pi, handle.url);\n\t\t\t\t\t\t} catch (browserErr) {\n\t\t\t\t\t\t\tbrowserOpenError = browserErr instanceof Error ? browserErr.message : String(browserErr);\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\ttry {\n\t\t\t\t\t\tawait openInBrowser(pi, handle.url);\n\t\t\t\t\t} catch (browserErr) {\n\t\t\t\t\t\tbrowserOpenError = browserErr instanceof Error ? browserErr.message : String(browserErr);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif (browserOpenError) {\n\t\t\t\t\tconsole.error(`Failed to open curator UI: ${browserOpenError}`);\n\t\t\t\t\tctx.ui.notify(`Search curator is running, but the browser did not open automatically. Open manually: ${handle.url}`, \"info\");\n\t\t\t\t}\n\n\t\t\t\tif (queries.length > 0) {\n\t\t\t\t\t(async () => {\n\t\t\t\t\t\tfor (let qi = 0; qi < queries.length; qi++) {\n\t\t\t\t\t\t\tif (aborted || !isCommandActive()) break;\n\t\t\t\t\t\t\tconst requestedProvider = currentSearchProvider;\n\t\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\t\tconst { answer, results, provider } = await search(queries[qi], {\n\t\t\t\t\t\t\t\t\tprovider: requestedProvider,\n\t\t\t\t\t\t\t\t\tsignal: searchAbort.signal,\n\t\t\t\t\t\t\t\t\textensionContext: ctx,\n\t\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\t\tif (aborted || !isCommandActive()) break;\n\t\t\t\t\t\t\t\thandle.pushResult(qi, {\n\t\t\t\t\t\t\t\t\tanswer,\n\t\t\t\t\t\t\t\t\tresults: results.map(r => ({ title: r.title, url: r.url, domain: extractDomain(r.url) })),\n\t\t\t\t\t\t\t\t\tprovider,\n\t\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\t\tcollected.set(qi, { query: queries[qi], answer, results, error: null, provider });\n\t\t\t\t\t\t\t} catch (err) {\n\t\t\t\t\t\t\t\tif (aborted || !isCommandActive()) break;\n\t\t\t\t\t\t\t\tconst message = err instanceof Error ? err.message : String(err);\n\t\t\t\t\t\t\t\thandle.pushError(qi, message, requestedProvider);\n\t\t\t\t\t\t\t\tcollected.set(qi, { query: queries[qi], answer: \"\", results: [], error: message, provider: requestedProvider });\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t\tif (!aborted && isCommandActive()) handle.searchesDone();\n\t\t\t\t\t})();\n\t\t\t\t} else {\n\t\t\t\t\tif (isCommandActive()) handle.searchesDone();\n\t\t\t\t}\n\t\t\t} catch (err) {\n\t\t\t\tcloseCurator(commandCallId);\n\t\t\t\tconst message = err instanceof Error ? err.message : String(err);\n\t\t\t\tctx.ui.notify(`Failed to open curator: ${message}`, \"error\");\n\t\t\t}\n\t\t},\n\t});\n\n\tpi.registerCommand(\"curator\", {\n\t\tdescription: \"Toggle or configure the search curator workflow\",\n\t\thandler: async (args, ctx) => {\n\t\t\tconst arg = args.trim().toLowerCase();\n\n\t\t\tlet newWorkflow: WebSearchWorkflow;\n\t\t\tif (arg.length === 0) {\n\t\t\t\tconst current = resolveWorkflow(loadConfigForExtensionInit().workflow, true);\n\t\t\t\tnewWorkflow = current === \"none\" ? \"summary-review\" : \"none\";\n\t\t\t} else if (arg === \"on\") {\n\t\t\t\tnewWorkflow = \"summary-review\";\n\t\t\t} else if (arg === \"off\") {\n\t\t\t\tnewWorkflow = \"none\";\n\t\t\t} else if (arg === \"none\" || arg === \"summary-review\" || arg === \"auto-summary\") {\n\t\t\t\tnewWorkflow = arg;\n\t\t\t} else {\n\t\t\t\tctx.ui.notify(`Unknown option: ${arg}. Use on, off, summary-review, or auto-summary.`, \"error\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\ttry {\n\t\t\t\tsaveConfig({ workflow: newWorkflow });\n\t\t\t} catch (err) {\n\t\t\t\tconst message = err instanceof Error ? err.message : String(err);\n\t\t\t\tctx.ui.notify(`Failed to save config: ${message}`, \"error\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst label = newWorkflow === \"none\"\n\t\t\t\t? \"Curator disabled — web_search will return raw results\"\n\t\t\t\t: newWorkflow === \"auto-summary\"\n\t\t\t\t\t? \"Auto-summary enabled — web_search will generate a summary without opening the curator\"\n\t\t\t\t\t: \"Curator enabled — web_search will open curator and auto-generate a summary draft\";\n\t\t\tpi.sendMessage({\n\t\t\t\tcustomType: \"curator-config\",\n\t\t\t\tcontent: [{ type: \"text\", text: label }],\n\t\t\t\tdisplay: true,\n\t\t\t\tdetails: { workflow: newWorkflow },\n\t\t\t}, { triggerTurn: false, deliverAs: \"followUp\" });\n\t\t},\n\t});\n\n\tpi.registerCommand(\"google-account\", {\n\t\tdescription: \"Show the active Google account for Gemini Web\",\n\t\thandler: async () => {\n\t\t\tif (!isBrowserCookieAccessAllowed()) {\n\t\t\t\tpi.sendMessage({\n\t\t\t\t\tcustomType: \"google-account\",\n\t\t\t\t\tcontent: [{ type: \"text\", text: `Gemini Web browser cookie access is disabled. Set allowBrowserCookies: true in ${WEB_SEARCH_CONFIG_PATH} to enable it.` }],\n\t\t\t\t\tdisplay: true,\n\t\t\t\t\tdetails: { available: false, cookieAccessAllowed: false },\n\t\t\t\t}, { triggerTurn: true, deliverAs: \"followUp\" });\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst cookies = await isGeminiWebAvailable();\n\t\t\tif (!cookies) {\n\t\t\t\tpi.sendMessage({\n\t\t\t\t\tcustomType: \"google-account\",\n\t\t\t\t\tcontent: [{ type: \"text\", text: \"Gemini Web is unavailable. Sign into gemini.google.com in a supported Chromium-based browser.\" }],\n\t\t\t\t\tdisplay: true,\n\t\t\t\t\tdetails: { available: false, cookieAccessAllowed: true },\n\t\t\t\t}, { triggerTurn: true, deliverAs: \"followUp\" });\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst email = await getActiveGoogleEmail(cookies);\n\t\t\tconst text = email\n\t\t\t\t? `Active Google account: ${email}`\n\t\t\t\t: \"Gemini Web is available, but the active Google account could not be determined.\";\n\n\t\t\tpi.sendMessage({\n\t\t\t\tcustomType: \"google-account\",\n\t\t\t\tcontent: [{ type: \"text\", text }],\n\t\t\t\tdisplay: true,\n\t\t\t\tdetails: { available: true, email: email ?? null },\n\t\t\t}, { triggerTurn: true, deliverAs: \"followUp\" });\n\t\t},\n\t});\n\n\tpi.registerCommand(\"search\", {\n\t\tdescription: \"Browse stored web search results\",\n\t\thandler: async (_args, ctx) => {\n\t\t\tconst results = getAllResults();\n\n\t\t\tif (results.length === 0) {\n\t\t\t\tctx.ui.notify(\"No stored search results\", \"info\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst options = results.map((r) => {\n\t\t\t\tconst age = Math.floor((Date.now() - r.timestamp) / 60000);\n\t\t\t\tconst ageStr = age < 60 ? `${age}m ago` : `${Math.floor(age / 60)}h ago`;\n\t\t\t\tif (r.type === \"search\" && r.queries) {\n\t\t\t\t\tconst query = r.queries[0]?.query || \"unknown\";\n\t\t\t\t\treturn `[${r.id.slice(0, 6)}] \"${query}\" (${r.queries.length} queries) - ${ageStr}`;\n\t\t\t\t}\n\t\t\t\tif (r.type === \"fetch\" && r.urls) {\n\t\t\t\t\treturn `[${r.id.slice(0, 6)}] ${r.urls.length} URLs fetched - ${ageStr}`;\n\t\t\t\t}\n\t\t\t\treturn `[${r.id.slice(0, 6)}] ${r.type} - ${ageStr}`;\n\t\t\t});\n\n\t\t\tconst choice = await ctx.ui.select(\"Stored Search Results\", options);\n\t\t\tif (!choice) return;\n\n\t\t\tconst match = choice.match(/^\\[([a-z0-9]+)\\]/);\n\t\t\tif (!match) return;\n\n\t\t\tconst selected = results.find((r) => r.id.startsWith(match[1]));\n\t\t\tif (!selected) return;\n\n\t\t\tconst actions = [\"View details\", \"Delete\"];\n\t\t\tconst action = await ctx.ui.select(`Result ${selected.id.slice(0, 6)}`, actions);\n\n\t\t\tif (action === \"Delete\") {\n\t\t\t\tdeleteResult(selected.id);\n\t\t\t\tctx.ui.notify(`Deleted ${selected.id.slice(0, 6)}`, \"info\");\n\t\t\t} else if (action === \"View details\") {\n\t\t\t\tlet info = `ID: ${selected.id}\\nType: ${selected.type}\\nAge: ${Math.floor((Date.now() - selected.timestamp) / 60000)}m\\n\\n`;\n\t\t\t\tif (selected.type === \"search\" && selected.queries) {\n\t\t\t\t\tinfo += \"Queries:\\n\";\n\t\t\t\t\tconst queries = selected.queries.slice(0, 10);\n\t\t\t\t\tfor (const q of queries) {\n\t\t\t\t\t\tinfo += `- \"${q.query}\" (${q.results.length} results)\\n`;\n\t\t\t\t\t}\n\t\t\t\t\tif (selected.queries.length > 10) {\n\t\t\t\t\t\tinfo += `... and ${selected.queries.length - 10} more\\n`;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif (selected.type === \"fetch\" && selected.urls) {\n\t\t\t\t\tinfo += \"URLs:\\n\";\n\t\t\t\t\tconst urls = selected.urls.slice(0, 10);\n\t\t\t\t\tfor (const u of urls) {\n\t\t\t\t\t\tconst urlDisplay = u.url.length > 50 ? u.url.slice(0, 47) + \"...\" : u.url;\n\t\t\t\t\t\tinfo += `- ${urlDisplay} (${u.error || `${u.content.length} chars`})\\n`;\n\t\t\t\t\t}\n\t\t\t\t\tif (selected.urls.length > 10) {\n\t\t\t\t\t\tinfo += `... and ${selected.urls.length - 10} more\\n`;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tctx.ui.notify(info, \"info\");\n\t\t\t}\n\t\t},\n\t});\n}\n"]}
|
|
@@ -34,7 +34,8 @@ import { isOpenAISearchAvailable } from "./openai-search.js";
|
|
|
34
34
|
import { isParallelAvailable } from "./parallel.js";
|
|
35
35
|
import { isTavilyAvailable } from "./tavily.js";
|
|
36
36
|
import { buildSearchErrorPlan } from "./render-search-error.js";
|
|
37
|
-
import { collectFetchUrls, collectSearchQueries, formatFetchCallTitle, formatFetchChrome, formatSearchCallTitle, formatSearchChrome, } from "./render-search-chrome.js";
|
|
37
|
+
import { collectFetchUrls, collectSearchQueries, formatFetchCallTitle, formatFetchChrome, formatFetchDetail, formatSearchCallTitle, formatSearchChrome, formatSearchDetail, } from "./render-search-chrome.js";
|
|
38
|
+
import { formatGroupedCall, toolStatusDotFromContext } from "../../core/tools/render-utils.js";
|
|
38
39
|
import { loadEnabledModelPatterns, modelMatchesEnabledPatterns } from "./summary-model-scope.js";
|
|
39
40
|
const WEB_SEARCH_CONFIG_PATH = getWebSearchConfigPath();
|
|
40
41
|
/** Shared collapsed/expanded renderer for an error/cancel plan produced by
|
|
@@ -1373,16 +1374,18 @@ export default function (pi) {
|
|
|
1373
1374
|
// lunr: collapsed header is one line (title · count). Query list lives in expanded renderResult.
|
|
1374
1375
|
const queryList = collectSearchQueries(args);
|
|
1375
1376
|
const compact = Boolean(context && !context.isPartial && !context.expanded && !context.isError);
|
|
1376
|
-
const chrome = formatSearchChrome({
|
|
1377
|
-
queries: queryList,
|
|
1378
|
-
details: compact ? context?.result?.details : undefined,
|
|
1379
|
-
expanded: false,
|
|
1380
|
-
});
|
|
1381
1377
|
const call = formatSearchCallTitle(queryList);
|
|
1382
1378
|
if (call.empty) {
|
|
1383
1379
|
return new Text(theme.fg("toolTitle", theme.bold("search ")) + theme.fg("error", "(no query)"), 0, 0);
|
|
1384
1380
|
}
|
|
1385
|
-
|
|
1381
|
+
const detail = formatSearchDetail(queryList, compact ? context?.result?.details : undefined);
|
|
1382
|
+
return new Text(formatGroupedCall({
|
|
1383
|
+
role: context?.groupRole ?? "singleton",
|
|
1384
|
+
compact,
|
|
1385
|
+
dot: context ? toolStatusDotFromContext(context, theme) : theme.fg("success", "●"),
|
|
1386
|
+
title: theme.fg("toolTitle", theme.bold("search")),
|
|
1387
|
+
detail: theme.fg("accent", detail),
|
|
1388
|
+
}), 0, 0);
|
|
1386
1389
|
},
|
|
1387
1390
|
renderResult(result, { expanded, isPartial }, theme, context) {
|
|
1388
1391
|
const details = result.details;
|
|
@@ -1569,16 +1572,18 @@ export default function (pi) {
|
|
|
1569
1572
|
// lunr: collapsed header is one line (title · count). URL list lives in expanded renderResult.
|
|
1570
1573
|
const urlList = collectFetchUrls(args);
|
|
1571
1574
|
const compact = Boolean(context && !context.isPartial && !context.expanded && !context.isError);
|
|
1572
|
-
const chrome = formatFetchChrome({
|
|
1573
|
-
urls: urlList,
|
|
1574
|
-
details: compact ? context?.result?.details : undefined,
|
|
1575
|
-
expanded: false,
|
|
1576
|
-
});
|
|
1577
1575
|
const call = formatFetchCallTitle(urlList);
|
|
1578
1576
|
if (call.empty) {
|
|
1579
1577
|
return new Text(theme.fg("toolTitle", theme.bold("fetch ")) + theme.fg("error", "(no URL)"), 0, 0);
|
|
1580
1578
|
}
|
|
1581
|
-
|
|
1579
|
+
const detail = formatFetchDetail(urlList, compact ? context?.result?.details : undefined);
|
|
1580
|
+
return new Text(formatGroupedCall({
|
|
1581
|
+
role: context?.groupRole ?? "singleton",
|
|
1582
|
+
compact,
|
|
1583
|
+
dot: context ? toolStatusDotFromContext(context, theme) : theme.fg("success", "●"),
|
|
1584
|
+
title: theme.fg("toolTitle", theme.bold("fetch")),
|
|
1585
|
+
detail: theme.fg("accent", detail),
|
|
1586
|
+
}), 0, 0);
|
|
1582
1587
|
},
|
|
1583
1588
|
renderResult(result, { expanded, isPartial }, theme, context) {
|
|
1584
1589
|
const details = result.details;
|