diff --git a/.feynman/vendor-overrides/pi-web-access/curator-page.ts b/.feynman/vendor-overrides/pi-web-access/curator-page.ts deleted file mode 100644 index 7c17116..0000000 --- a/.feynman/vendor-overrides/pi-web-access/curator-page.ts +++ /dev/null @@ -1,1210 +0,0 @@ -function escapeHtml(str: string): string { - return str - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """); -} - -function safeInlineJSON(data: unknown): string { - return JSON.stringify(data) - .replace(//g, "\\u003e") - .replace(/&/g, "\\u0026"); -} - -function buildProviderOptions( - available: { perplexity: boolean; exa: boolean; gemini: boolean }, - selected: string, -): string { - const options = [ - { value: "perplexity", label: "Perplexity", disabled: !available.perplexity }, - { value: "exa", label: "Exa", disabled: !available.exa }, - { value: "gemini", label: "Gemini", disabled: !available.gemini }, - ]; - - return options - .map(o => ``) - .join(""); -} - -export function generateCuratorPage( - queries: string[], - sessionToken: string, - timeout: number, - availableProviders: { perplexity: boolean; exa: boolean; gemini: boolean }, - defaultProvider: string, -): string { - const providerOptionsHtml = buildProviderOptions(availableProviders, defaultProvider); - const inlineData = safeInlineJSON({ queries, sessionToken, timeout }); - - return ` - - - - -Curate Search Results - - - - - -`; -} - -const CSS = ` -*,*::before,*::after{box-sizing:border-box;margin:0;padding:0} - -:root { - --bg: #18181e; - --bg-card: #1e1e24; - --bg-elevated: #252530; - --bg-hover: #2b2b37; - --fg: #e0e0e0; - --fg-muted: #909098; - --fg-dim: #606068; - --accent: #8abeb7; - --accent-hover: #9dcec7; - --accent-muted: rgba(138, 190, 183, 0.15); - --accent-subtle: rgba(138, 190, 183, 0.08); - --border: #2a2a34; - --border-muted: #353540; - --border-checked: #8abeb7; - --check-bg: #8abeb7; - --btn-primary: #8abeb7; - --btn-primary-hover: #9dcec7; - --btn-primary-fg: #18181e; - --btn-secondary: #252530; - --btn-secondary-hover: #2b2b37; - --timer-bg: #252530; - --timer-fg: #909098; - --timer-warn-bg: rgba(240, 198, 116, 0.15); - --timer-warn-fg: #f0c674; - --timer-urgent-bg: rgba(204, 102, 102, 0.15); - --timer-urgent-fg: #cc6666; - --overlay-bg: rgba(24, 24, 30, 0.92); - --success: #b5bd68; - --warning: #f0c674; - --font: 'Outfit', system-ui, -apple-system, sans-serif; - --font-display: 'Instrument Serif', Georgia, 'Times New Roman', serif; - --font-mono: 'SF Mono', Consolas, monospace; - --radius: 10px; - --radius-sm: 6px; -} - -@media (prefers-color-scheme: light) { - :root { - --bg: #f5f5f7; - --bg-card: #ffffff; - --bg-elevated: #eeeef0; - --bg-hover: #e4e4e8; - --fg: #1a1a1e; - --fg-muted: #6c6c74; - --fg-dim: #9a9aa2; - --accent: #5f8787; - --accent-hover: #4a7272; - --accent-muted: rgba(95, 135, 135, 0.12); - --accent-subtle: rgba(95, 135, 135, 0.06); - --border: #dcdce0; - --border-muted: #c8c8d0; - --border-checked: #5f8787; - --check-bg: #5f8787; - --btn-primary: #5f8787; - --btn-primary-hover: #4a7272; - --btn-primary-fg: #ffffff; - --btn-secondary: #e4e4e8; - --btn-secondary-hover: #d4d4d8; - --timer-bg: #e4e4e8; - --timer-fg: #6c6c74; - --timer-warn-bg: rgba(217, 119, 6, 0.10); - --timer-warn-fg: #92400e; - --timer-urgent-bg: rgba(175, 95, 95, 0.10); - --timer-urgent-fg: #991b1b; - --overlay-bg: rgba(255, 255, 255, 0.92); - --success: #4d7c0f; - --warning: #b45309; - } -} - -body { - font-family: var(--font); - background: var(--bg); - background-image: radial-gradient(ellipse at 50% 0%, var(--accent-muted) 0%, transparent 60%); - color: var(--fg); - line-height: 1.5; - min-height: 100dvh; - padding-bottom: 72px; -} - -.timer-badge { - position: fixed; - top: 20px; - right: 24px; - z-index: 50; - font-family: var(--font); - font-size: 12px; - font-weight: 600; - font-variant-numeric: tabular-nums; - padding: 5px 14px; - border-radius: 999px; - background: var(--bg-elevated); - color: var(--timer-fg); - border: 1px solid var(--border); - transition: background 0.3s, color 0.3s, border-color 0.3s, opacity 0.3s; - box-shadow: 0 2px 8px rgba(0,0,0,0.2); - cursor: pointer; - user-select: none; - opacity: 0.5; -} -.timer-badge:hover { opacity: 1; } -.timer-badge.active { opacity: 1; } -.timer-badge.warn { - opacity: 1; - background: var(--timer-warn-bg); - color: var(--timer-warn-fg); - border-color: color-mix(in srgb, var(--timer-warn-fg) 30%, transparent); -} -.timer-badge.urgent { - opacity: 1; - background: var(--timer-urgent-bg); - color: var(--timer-urgent-fg); - border-color: color-mix(in srgb, var(--timer-urgent-fg) 30%, transparent); -} -.timer-adjust { - position: fixed; - top: 20px; - right: 24px; - z-index: 51; - display: none; - align-items: center; - gap: 6px; - padding: 4px 6px 4px 12px; - background: var(--bg-elevated); - border: 1px solid var(--accent); - border-radius: 999px; - box-shadow: 0 2px 12px rgba(0,0,0,0.3); -} -.timer-adjust.visible { display: flex; } -.timer-adjust input { - width: 48px; - background: transparent; - border: none; - outline: none; - color: var(--fg); - font-family: var(--font); - font-size: 13px; - font-weight: 600; - font-variant-numeric: tabular-nums; - text-align: center; -} -.timer-adjust-label { font-size: 11px; color: var(--fg-dim); } -.timer-adjust-btn { - font-family: var(--font); - font-size: 11px; - font-weight: 600; - padding: 3px 10px; - border-radius: 999px; - border: none; - background: var(--accent); - color: var(--btn-primary-fg); - cursor: pointer; -} -.timer-adjust-btn:hover { background: var(--accent-hover); } - -main { - max-width: 640px; - margin: 0 auto; - padding: 56px 24px 16px; -} - -.hero { margin-bottom: 28px; } -.hero-kicker { - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.1em; - color: var(--accent); - margin-bottom: 8px; -} -.hero-title { - font-family: var(--font-display); - font-size: 40px; - font-weight: 400; - font-style: italic; - letter-spacing: -0.01em; - line-height: 1.1; - color: var(--fg); - margin-bottom: 10px; - text-wrap: balance; -} -.hero-desc { - font-size: 14px; - color: var(--fg-muted); - line-height: 1.5; - margin-bottom: 12px; - max-width: 480px; -} -.hero-meta { - display: flex; - align-items: center; - gap: 10px; - font-size: 13px; - color: var(--fg-dim); -} -.hero-meta-sep { - width: 3px; - height: 3px; - border-radius: 50%; - background: var(--fg-dim); - flex-shrink: 0; -} -#hero-status:empty + .hero-meta-sep { display: none; } -.hero-meta select { - font-family: var(--font); - font-size: 13px; - padding: 3px 8px; - background: transparent; - border: 1px solid transparent; - color: var(--fg-muted); - border-radius: var(--radius-sm); - font-weight: 500; - cursor: pointer; - transition: border-color 0.15s, color 0.15s; -} -.hero-meta select:hover { - border-color: var(--border-muted); - color: var(--fg); -} -.hero-meta select:focus { - outline: none; - border-color: var(--accent); - color: var(--fg); - box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 20%, transparent); -} - -#result-cards { display: flex; flex-direction: column; gap: 8px; } - -.result-card { - background: var(--bg-card); - border: 1px solid var(--border); - border-radius: var(--radius); - overflow: hidden; - transition: border-color 0.12s; - box-shadow: 0 1px 2px rgba(0,0,0,0.06); -} -.result-card.checked { border-color: var(--border-checked); } -.result-card.searching { - opacity: 0.7; - border-style: dashed; -} -.result-card.error { border-color: var(--timer-urgent-fg); } - -.result-card-header { - display: flex; - align-items: flex-start; - gap: 12px; - padding: 14px 16px; - cursor: pointer; - user-select: none; - transition: background 0.12s; -} -.result-card-header:hover { background: var(--bg-hover); } - -.result-card-header input[type="checkbox"] { - appearance: none; - width: 16px; - height: 16px; - min-width: 16px; - border: 1.5px solid var(--border-muted); - border-radius: 4px; - margin-top: 2px; - cursor: pointer; - transition: background 0.12s, border-color 0.12s; - display: grid; - place-content: center; -} -.result-card-header input[type="checkbox"]:checked { - background: var(--check-bg); - border-color: var(--check-bg); -} -.result-card-header input[type="checkbox"]:checked::after { - content: ""; - width: 9px; - height: 6px; - border-left: 2px solid var(--btn-primary-fg); - border-bottom: 2px solid var(--btn-primary-fg); - transform: rotate(-45deg); - margin-top: -1px; -} - -.result-card-info { flex: 1; min-width: 0; } - -.result-card-query { - font-size: 14px; - font-weight: 600; - color: var(--fg); - margin-bottom: 2px; -} -.result-card-meta { - font-size: 12px; - color: var(--fg-dim); -} -.result-card-preview { - font-size: 12.5px; - color: var(--fg-muted); - margin-top: 6px; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; - line-height: 1.45; -} - -.result-card-expand { - color: var(--fg-dim); - font-size: 11px; - margin-top: 2px; - flex-shrink: 0; - padding-top: 3px; - transition: color 0.12s; -} -.result-card-header:hover .result-card-expand { color: var(--fg-muted); } - -.result-card-body { - display: none; - border-top: 1px solid var(--border); -} -.result-card-body.open { display: block; } - -.result-card-answer { - padding: 14px 16px; - font-size: 13.5px; - color: var(--fg-muted); - line-height: 1.6; - max-height: 400px; - overflow-y: auto; -} -.result-card-answer h1, -.result-card-answer h2, -.result-card-answer h3, -.result-card-answer h4 { - color: var(--fg); - font-family: var(--font); - font-weight: 600; - margin: 16px 0 6px; - line-height: 1.3; -} -.result-card-answer h1 { font-size: 16px; } -.result-card-answer h2 { font-size: 14.5px; } -.result-card-answer h3 { font-size: 13.5px; } -.result-card-answer h4 { font-size: 13px; color: var(--fg-muted); } -.result-card-answer p { margin: 0 0 10px; } -.result-card-answer p:last-child { margin-bottom: 0; } -.result-card-answer strong { color: var(--fg); font-weight: 600; } -.result-card-answer a { color: var(--accent); text-decoration: none; } -.result-card-answer a:hover { text-decoration: underline; } -.result-card-answer ul, .result-card-answer ol { - margin: 6px 0 10px; - padding-left: 20px; -} -.result-card-answer li { margin-bottom: 4px; } -.result-card-answer li::marker { color: var(--fg-dim); } -.result-card-answer code { - font-family: var(--font-mono); - font-size: 12px; - padding: 1px 5px; - background: var(--bg-elevated); - border: 1px solid var(--border); - border-radius: 3px; - color: var(--fg); -} -.result-card-answer pre { - margin: 8px 0 12px; - padding: 12px 14px; - background: var(--bg); - border: 1px solid var(--border); - border-radius: var(--radius-sm); - overflow-x: auto; - line-height: 1.45; -} -.result-card-answer pre code { - padding: 0; - background: none; - border: none; - font-size: 12px; - color: var(--fg-muted); -} -.result-card-answer blockquote { - margin: 8px 0; - padding: 8px 14px; - border-left: 3px solid var(--accent); - color: var(--fg-dim); - background: var(--accent-subtle); - border-radius: 0 var(--radius-sm) var(--radius-sm) 0; -} -.result-card-answer table { - width: 100%; - border-collapse: collapse; - margin: 8px 0 12px; - font-size: 12.5px; -} -.result-card-answer th, .result-card-answer td { - padding: 6px 10px; - border: 1px solid var(--border); - text-align: left; -} -.result-card-answer th { - background: var(--bg-elevated); - color: var(--fg); - font-weight: 600; - font-size: 11.5px; - text-transform: uppercase; - letter-spacing: 0.03em; -} -.result-card-answer hr { - border: none; - border-top: 1px solid var(--border); - margin: 14px 0; -} - -.result-card-sources { - padding: 10px 16px 14px; - border-top: 1px solid var(--border); -} -.result-card-sources-title { - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.06em; - color: var(--fg-dim); - margin-bottom: 6px; -} -.source-link { - display: block; - padding: 4px 0; - font-size: 12.5px; - color: var(--fg-muted); - text-decoration: none; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - transition: color 0.12s; -} -.source-link:hover { color: var(--accent); } -.source-domain { - color: var(--fg-dim); - margin-left: 6px; -} - -.result-card-error-msg { - padding: 12px 16px; - font-size: 13px; - color: var(--timer-urgent-fg); -} - -.searching-dots::after { - content: ""; - animation: dots 1.5s steps(4, end) infinite; -} -@keyframes dots { - 0% { content: ""; } - 25% { content: "."; } - 50% { content: ".."; } - 75% { content: "..."; } -} - -.add-search { - display: flex; - align-items: center; - gap: 10px; - margin-top: 12px; - padding: 11px 14px; - border: 1px dashed var(--border); - border-radius: var(--radius); - cursor: text; - transition: border-color 0.15s, background 0.15s; -} -.add-search:hover { - border-color: var(--border-muted); - background: var(--accent-subtle); -} -.add-search:focus-within { - border-color: var(--accent); - border-style: solid; - background: var(--accent-subtle); -} -.add-search-icon { - color: var(--fg-dim); - font-size: 16px; - font-weight: 300; - line-height: 1; - flex-shrink: 0; - transition: color 0.15s; -} -.add-search:focus-within .add-search-icon { color: var(--accent); } -.add-search input { - flex: 1; - background: transparent; - border: none; - outline: none; - color: var(--fg); - font-family: var(--font); - font-size: 13.5px; - font-weight: 500; -} -.add-search input::placeholder { - color: var(--fg-dim); - font-weight: 400; -} -.add-search.loading { - opacity: 0.5; - pointer-events: none; -} - -.action-bar { - position: fixed; - bottom: 0; - left: 0; - right: 0; - z-index: 10; - display: flex; - align-items: center; - justify-content: space-between; - padding: 12px 24px; - background: color-mix(in srgb, var(--bg) 90%, transparent); - backdrop-filter: blur(12px); - -webkit-backdrop-filter: blur(12px); - border-top: 1px solid var(--border); -} -.action-shortcuts { display: flex; align-items: center; gap: 16px; } -.shortcut { display: flex; align-items: center; gap: 5px; font-size: 11px; color: var(--fg-dim); } -.shortcut kbd { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 18px; - height: 18px; - padding: 0 4px; - font-family: var(--font-mono); - font-size: 10px; - font-weight: 500; - background: var(--bg-elevated); - border: 1px solid var(--border-muted); - border-radius: 3px; - color: var(--fg-muted); -} -.action-buttons { display: flex; gap: 8px; } - -.btn { - font-family: var(--font); - font-size: 13px; - font-weight: 500; - padding: 7px 16px; - border: none; - border-radius: var(--radius-sm); - cursor: pointer; - transition: background 0.12s, opacity 0.12s; -} -.btn:disabled { opacity: 0.35; cursor: default; } -.btn-submit { background: var(--btn-primary); color: var(--btn-primary-fg); } -.btn-submit:hover:not(:disabled) { background: var(--btn-primary-hover); } -.btn-secondary { background: var(--btn-secondary); color: var(--fg-muted); border: 1px solid var(--border); } -.btn-secondary:hover:not(:disabled) { background: var(--btn-secondary-hover); color: var(--fg); } - -.success-overlay { - position: fixed; inset: 0; z-index: 200; - background: var(--overlay-bg); - display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; - transition: opacity 200ms; -} -.success-overlay.hidden { display: flex !important; opacity: 0; pointer-events: none; } -.success-icon { - width: 56px; height: 56px; border-radius: 50%; - border: 2px solid var(--success); - display: flex; align-items: center; justify-content: center; - font-size: 18px; font-weight: 700; color: var(--success); -} -.success-overlay p { margin: 0; font-size: 13px; font-weight: 600; color: var(--success); letter-spacing: 0.06em; text-transform: uppercase; } - -.expired-overlay { - position: fixed; inset: 0; - background: var(--overlay-bg); - display: flex; align-items: center; justify-content: center; - opacity: 0; transition: opacity 400ms; pointer-events: none; z-index: 200; -} -.expired-overlay.visible { opacity: 1; pointer-events: auto; } -.expired-overlay.hidden { display: flex !important; opacity: 0; pointer-events: none; } -.expired-content { - text-align: center; max-width: 480px; padding: 48px 56px; - background: var(--bg-card); border: 1px solid var(--border); border-radius: 12px; -} -.expired-overlay.visible .expired-content { animation: slide-up 400ms ease-out; } -@keyframes slide-up { from { transform: translateY(20px); } to { transform: translateY(0); } } -.expired-icon { - width: 72px; height: 72px; border-radius: 50%; border: 2px solid var(--warning); - display: flex; align-items: center; justify-content: center; - font-size: 32px; font-weight: bold; color: var(--warning); margin: 0 auto 24px; -} -.expired-content h2 { color: var(--fg); margin: 0 0 16px; font-size: 22px; font-weight: 600; } -.expired-content p { color: var(--fg-muted); margin: 0 0 24px; font-size: 14px; line-height: 1.6; } -.expired-countdown { font-size: 13px; color: var(--fg-dim); font-variant-numeric: tabular-nums; } -.expired-countdown span { color: var(--warning); font-weight: 600; } - -.error-banner { - position: fixed; bottom: 64px; left: 50%; transform: translateX(-50%); z-index: 50; - padding: 10px 20px; background: var(--timer-urgent-bg); color: var(--timer-urgent-fg); - border-radius: var(--radius); font-size: 13px; font-weight: 500; -} - -@media (max-width: 500px) { - main { padding: 32px 16px 16px; } - .hero-title { font-size: 28px; } - .hero-desc { font-size: 13px; } - .action-bar { padding: 10px 14px; } - .action-shortcuts { display: none; } - .result-card-header { padding: 12px 14px; } - .expired-content { padding: 32px 24px; } - .timer-badge { top: 12px; right: 16px; } -} -`; - -const SCRIPT = `(function() { - var DATA = __INLINE_DATA__; - var token = DATA.sessionToken; - var timeoutSec = DATA.timeout; - var queries = DATA.queries; - var submitted = false; - var timerExpired = false; - var searchesDone = false; - var lastInteraction = Date.now(); - var completedCount = 0; - var es = null; - - var timerEl = document.getElementById("timer"); - var timerAdjustEl = document.getElementById("timer-adjust"); - var timerInput = document.getElementById("timer-input"); - var timerSetBtn = document.getElementById("timer-set"); - var heroTitle = document.querySelector(".hero-title"); - var heroDesc = document.querySelector(".hero-desc"); - var resultCardsEl = document.getElementById("result-cards"); - var btnSendAll = document.getElementById("btn-send-all"); - var btnSend = document.getElementById("btn-send"); - var successOverlay = document.getElementById("success-overlay"); - var successText = document.getElementById("success-text"); - var expiredOverlay = document.getElementById("expired-overlay"); - var expiredText = document.getElementById("expired-text"); - var closeCountdown = document.getElementById("close-countdown"); - var errorBanner = document.getElementById("error-banner"); - var addSearchInput = document.getElementById("add-search-input"); - var addSearchEl = document.getElementById("add-search"); - var heroStatus = document.getElementById("hero-status"); - var globalProvider = document.getElementById("global-provider"); - - function escHtml(s) { - return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); - } - - function post(path, body) { - return fetch(path, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(Object.assign({ token: token }, body)), - }); - } - - function formatTime(sec) { - var m = Math.floor(sec / 60); - var s = sec % 60; - return m + ":" + (s < 10 ? "0" : "") + s; - } - - function populateResultCard(card, data, queryText) { - var sourceCount = data.results ? data.results.length : 0; - var domains = []; - if (data.results) { - for (var i = 0; i < Math.min(data.results.length, 3); i++) { - domains.push(data.results[i].domain); - } - } - var metaText = sourceCount + " source" + (sourceCount !== 1 ? "s" : ""); - if (domains.length > 0) metaText += " \\u00B7 " + domains.join(", "); - if (sourceCount > 3) metaText += ", +" + (sourceCount - 3); - - var preview = ""; - if (data.answer) { - preview = data.answer.substring(0, 200).replace(/\\n+/g, " ").replace(/[#*_\\[\\]]/g, ""); - } - - var bodyHtml = ""; - if (data.answer) { - var rendered = typeof marked !== "undefined" && marked.parse - ? marked.parse(data.answer, { breaks: true }) - : '

' + escHtml(data.answer) + '

'; - bodyHtml += '
' + rendered + '
'; - } - if (data.results && data.results.length > 0) { - bodyHtml += '
Sources
'; - for (var k = 0; k < data.results.length; k++) { - var r = data.results[k]; - var label = r.title && r.title.indexOf("Source ") !== 0 ? r.title : r.url; - bodyHtml += '' + escHtml(label) + '' + escHtml(r.domain) + ''; - } - bodyHtml += '
'; - } - - card.innerHTML = - '
' + - '' + - '
' + - '
' + escHtml(queryText) + '
' + - '
' + escHtml(metaText) + '
' + - (preview ? '
' + escHtml(preview) + '
' : '') + - '
' + - '
\\u25BC
' + - '
' + - '
' + bodyHtml + '
'; - } - - // ── Timer ── - - function resetTimer() { lastInteraction = Date.now(); } - - function updateTimer() { - var idleSec = Math.floor((Date.now() - lastInteraction) / 1000); - var remaining = Math.max(0, timeoutSec - idleSec); - timerEl.textContent = formatTime(remaining); - - timerEl.classList.remove("warn", "urgent", "active"); - if (remaining <= 15) timerEl.classList.add("urgent"); - else if (remaining <= 30) timerEl.classList.add("warn"); - else if (remaining < timeoutSec) timerEl.classList.add("active"); - - if (remaining <= 0 && !submitted && !timerExpired) onTimeout(); - } - - setInterval(updateTimer, 1000); - updateTimer(); - - ["click", "keydown", "input", "change"].forEach(function(evt) { - document.addEventListener(evt, resetTimer, { passive: true }); - }); - document.addEventListener("scroll", resetTimer, { passive: true }); - document.addEventListener("mousemove", resetTimer, { passive: true }); - - // ── Timer adjust ── - - timerEl.addEventListener("click", function(e) { - e.stopPropagation(); - timerInput.value = timeoutSec; - timerAdjustEl.classList.add("visible"); - timerEl.style.display = "none"; - timerInput.focus(); - timerInput.select(); - }); - - function applyTimerAdjust() { - var val = parseInt(timerInput.value, 10); - if (val && val > 0) timeoutSec = Math.min(val, 600); - timerAdjustEl.classList.remove("visible"); - timerEl.style.display = ""; - resetTimer(); - } - - timerSetBtn.addEventListener("click", function(e) { e.stopPropagation(); applyTimerAdjust(); }); - timerInput.addEventListener("keydown", function(e) { - if (e.key === "Enter") { e.preventDefault(); applyTimerAdjust(); } - if (e.key === "Escape") { timerAdjustEl.classList.remove("visible"); timerEl.style.display = ""; } - e.stopPropagation(); - }); - document.addEventListener("click", function() { - if (timerAdjustEl.classList.contains("visible")) { - timerAdjustEl.classList.remove("visible"); - timerEl.style.display = ""; - } - }); - - // ── Provider ── - - if (globalProvider) { - globalProvider.addEventListener("change", function() { - post("/provider", { provider: globalProvider.value }); - }); - } - - // ── Add search ── - - addSearchInput.addEventListener("keydown", function(e) { - if (e.key !== "Enter") return; - var text = addSearchInput.value.trim(); - if (!text || submitted) return; - e.preventDefault(); - e.stopPropagation(); - - addSearchEl.classList.add("loading"); - addSearchInput.value = ""; - - var card = document.createElement("div"); - card.className = "result-card searching"; - card.innerHTML = - '
' + - '' + - '
' + - '
' + escHtml(text) + '
' + - '
Searching
' + - '
' + - '
'; - resultCardsEl.appendChild(card); - resetTimer(); - - post("/search", { query: text }).then(function(res) { - return res.json(); - }).then(function(data) { - addSearchEl.classList.remove("loading"); - if (!data.ok) { card.remove(); return; } - - card.dataset.qi = data.queryIndex; - - if (data.error) { - card.classList.remove("searching"); - card.classList.add("error"); - card.innerHTML = - '
' + - '' + - '
' + - '
' + escHtml(text) + '
' + - '
Failed
' + - '
' + - '
' + - '
' + escHtml(data.error) + '
'; - return; - } - - card.classList.remove("searching"); - card.classList.add("checked"); - completedCount++; - - populateResultCard(card, data, text); - setupCardInteraction(card); - updateSendButton(); - heroTitle.textContent = completedCount + " Search" + (completedCount !== 1 ? "es" : "") + " Complete"; - heroDesc.textContent = "Review the results and send what you want back to your agent."; - if (heroStatus) heroStatus.textContent = completedCount + " completed"; - resetTimer(); - }).catch(function() { - addSearchEl.classList.remove("loading"); - card.remove(); - }); - }); - - // ── Overlays ── - - function showSuccess(text) { - if (es) { es.close(); es = null; } - successText.textContent = text; - successOverlay.classList.remove("hidden"); - setTimeout(function() { window.close(); }, 800); - } - - function showExpired(text) { - if (es) { es.close(); es = null; } - expiredText.textContent = text; - expiredOverlay.classList.remove("hidden"); - requestAnimationFrame(function() { expiredOverlay.classList.add("visible"); }); - } - - function showError(text) { - errorBanner.textContent = text; - errorBanner.hidden = false; - } - - function onTimeout() { - if (submitted || timerExpired) return; - timerExpired = true; - submitted = true; - showExpired("Time\\u2019s up \\u2014 sending all results to your agent."); - post("/cancel", { reason: "timeout" }); - var count = 5; - closeCountdown.textContent = count; - var iv = setInterval(function() { - count--; - closeCountdown.textContent = count; - if (count <= 0) { clearInterval(iv); window.close(); } - }, 1000); - } - - // ── Create placeholder cards for each query ── - - if (queries.length === 0) { - heroTitle.textContent = "What do you need?"; - heroDesc.textContent = "Search for anything below. Results get sent back to your agent."; - if (heroStatus) heroStatus.textContent = ""; - btnSend.textContent = "No results yet"; - } else { - for (var i = 0; i < queries.length; i++) { - var card = document.createElement("div"); - card.className = "result-card searching"; - card.dataset.qi = i; - card.innerHTML = - '
' + - '' + - '
' + - '
' + escHtml(queries[i]) + '
' + - '
Searching
' + - '
' + - '
'; - resultCardsEl.appendChild(card); - } - } - - // ── SSE ── - - es = new EventSource("/events?session=" + encodeURIComponent(token)); - - es.addEventListener("result", function(e) { - var data = JSON.parse(e.data); - var card = resultCardsEl.querySelector('.result-card[data-qi="' + data.queryIndex + '"]'); - if (!card) return; - - card.classList.remove("searching"); - card.classList.add("checked"); - completedCount++; - - populateResultCard(card, data, data.query || queries[data.queryIndex]); - setupCardInteraction(card); - updateSendButton(); - resetTimer(); - }); - - es.addEventListener("search-error", function(e) { - var data = JSON.parse(e.data); - var card = resultCardsEl.querySelector('.result-card[data-qi="' + data.queryIndex + '"]'); - if (!card) return; - - card.classList.remove("searching"); - card.classList.add("error"); - completedCount++; - - card.innerHTML = - '
' + - '' + - '
' + - '
' + escHtml(data.query || queries[data.queryIndex]) + '
' + - '
Failed
' + - '
' + - '
' + - '
' + escHtml(data.error || "Search failed") + '
'; - - updateSendButton(); - resetTimer(); - }); - - es.addEventListener("done", function() { - searchesDone = true; - if (completedCount > 0) { - heroTitle.textContent = completedCount + " Search" + (completedCount !== 1 ? "es" : "") + " Complete"; - heroDesc.textContent = "Review the results and send what you want back to your agent."; - if (heroStatus) heroStatus.textContent = completedCount + " completed"; - } - updateSendButton(); - resetTimer(); - }); - - es.onerror = function() {}; - - // ── Card interaction ── - - function setupCardInteraction(card) { - var header = card.querySelector(".result-card-header"); - var body = card.querySelector(".result-card-body"); - var cb = card.querySelector("input[type=checkbox]"); - var expandEl = card.querySelector(".result-card-expand"); - - header.addEventListener("click", function(e) { - if (e.target.tagName === "A") return; - if (e.target === cb) { - card.classList.toggle("checked", cb.checked); - updateSendButton(); - return; - } - var isExpanded = body && body.classList.contains("open"); - if (body) body.classList.toggle("open"); - if (expandEl) expandEl.textContent = isExpanded ? "\\u25BC" : "\\u25B2"; - }); - - if (body) { - body.addEventListener("click", function(e) { - e.stopPropagation(); - }); - } - } - - // ── Send button ── - - function getSelectedIndices() { - var indices = []; - var cards = resultCardsEl.querySelectorAll(".result-card"); - cards.forEach(function(card) { - var cb = card.querySelector("input[type=checkbox]"); - if (cb && cb.checked && !cb.disabled) { - indices.push(parseInt(card.dataset.qi, 10)); - } - }); - return indices; - } - - function updateSendButton() { - var sel = getSelectedIndices(); - var hasResults = completedCount > 0; - btnSend.disabled = !hasResults || sel.length === 0; - if (!hasResults) { - btnSend.textContent = searchesDone ? "No results yet" : "Waiting for results\\u2026"; - } else { - btnSend.textContent = "Send Selected (" + sel.length + ")"; - } - btnSendAll.hidden = !searchesDone || !hasResults; - } - - // ── Submit / Cancel ── - - function doSubmit(indices) { - if (submitted) return; - submitted = true; - post("/submit", { selected: indices }).then(function(res) { - if (!res.ok) throw new Error("submit failed"); - showSuccess("Results sent"); - }).catch(function() { - submitted = false; - showError("Failed to send \\u2014 the agent may have moved on"); - }); - } - - function doCancel() { - if (submitted) return; - submitted = true; - post("/cancel", { reason: "user" }).then(function(res) { - if (!res.ok) throw new Error("cancel failed"); - showSuccess("Skipped"); - }).catch(function() { - submitted = false; - showError("Failed \\u2014 the agent may have moved on"); - }); - } - - // ── Button handlers ── - - btnSend.addEventListener("click", function() { - var sel = getSelectedIndices(); - if (sel.length > 0) doSubmit(sel); - }); - - btnSendAll.addEventListener("click", function() { - var all = []; - resultCardsEl.querySelectorAll(".result-card").forEach(function(card) { - var cb = card.querySelector("input[type=checkbox]"); - if (cb && !cb.disabled) all.push(parseInt(card.dataset.qi, 10)); - }); - if (all.length > 0) doSubmit(all); - }); - - // ── Keyboard ── - - document.addEventListener("keydown", function(e) { - if (submitted || timerExpired) return; - if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA" || e.target.tagName === "SELECT") return; - - if (e.key === "Enter" && !e.metaKey && !e.ctrlKey) { - e.preventDefault(); - var sel = getSelectedIndices(); - if (sel.length > 0) doSubmit(sel); - } else if (e.key === "Escape") { - e.preventDefault(); - doCancel(); - } else if (e.key === "a" && !e.metaKey && !e.ctrlKey) { - e.preventDefault(); - var boxes = resultCardsEl.querySelectorAll("input[type=checkbox]:not(:disabled)"); - var allChecked = true; - boxes.forEach(function(cb) { if (!cb.checked) allChecked = false; }); - boxes.forEach(function(cb) { - cb.checked = !allChecked; - cb.closest(".result-card").classList.toggle("checked", cb.checked); - }); - updateSendButton(); - resetTimer(); - } - }); - - // ── Heartbeat ── - - setInterval(function() { - if (submitted) return; - post("/heartbeat", {}); - }, 10000); - - // ── Focus add-search input when no initial queries ── - - if (queries.length === 0 && addSearchInput) { - addSearchInput.focus(); - } -})();`; diff --git a/.feynman/vendor-overrides/pi-web-access/curator-server.ts b/.feynman/vendor-overrides/pi-web-access/curator-server.ts deleted file mode 100644 index 725f51a..0000000 --- a/.feynman/vendor-overrides/pi-web-access/curator-server.ts +++ /dev/null @@ -1,325 +0,0 @@ -import http, { type IncomingMessage, type ServerResponse } from "node:http"; -import { generateCuratorPage } from "./curator-page.js"; - -const STALE_THRESHOLD_MS = 30000; -const WATCHDOG_INTERVAL_MS = 5000; -const MAX_BODY_SIZE = 64 * 1024; - -type ServerState = "SEARCHING" | "RESULT_SELECTION" | "COMPLETED"; - -export interface CuratorServerOptions { - queries: string[]; - sessionToken: string; - timeout: number; - availableProviders: { perplexity: boolean; exa: boolean; gemini: boolean }; - defaultProvider: string; -} - -export interface CuratorServerCallbacks { - onSubmit: (selectedQueryIndices: number[]) => void; - onCancel: (reason: "user" | "timeout" | "stale") => void; - onProviderChange: (provider: string) => void; - onAddSearch: (query: string, queryIndex: number) => Promise<{ answer: string; results: Array<{ title: string; url: string; domain: string }> }>; -} - -export interface CuratorServerHandle { - server: http.Server; - url: string; - close: () => void; - pushResult: (queryIndex: number, data: { answer: string; results: Array<{ title: string; url: string; domain: string }> }) => void; - pushError: (queryIndex: number, error: string) => void; - searchesDone: () => void; -} - -function sendJson(res: ServerResponse, status: number, payload: unknown): void { - res.writeHead(status, { - "Content-Type": "application/json", - "Cache-Control": "no-store", - }); - res.end(JSON.stringify(payload)); -} - -function parseJSONBody(req: IncomingMessage): Promise { - return new Promise((resolve, reject) => { - let body = ""; - let size = 0; - req.on("data", (chunk: Buffer) => { - size += chunk.length; - if (size > MAX_BODY_SIZE) { - req.destroy(); - reject(new Error("Request body too large")); - return; - } - body += chunk.toString(); - }); - req.on("end", () => { - try { resolve(JSON.parse(body)); } - catch { reject(new Error("Invalid JSON")); } - }); - req.on("error", reject); - }); -} - -export function startCuratorServer( - options: CuratorServerOptions, - callbacks: CuratorServerCallbacks, -): Promise { - const { queries, sessionToken, timeout, availableProviders, defaultProvider } = options; - let browserConnected = false; - let lastHeartbeatAt = Date.now(); - let completed = false; - let watchdog: NodeJS.Timeout | null = null; - let state: ServerState = "SEARCHING"; - let sseResponse: ServerResponse | null = null; - const sseBuffer: string[] = []; - let nextQueryIndex = queries.length; - - let sseKeepalive: NodeJS.Timeout | null = null; - - const markCompleted = (): boolean => { - if (completed) return false; - completed = true; - state = "COMPLETED"; - if (watchdog) { clearInterval(watchdog); watchdog = null; } - if (sseKeepalive) { clearInterval(sseKeepalive); sseKeepalive = null; } - if (sseResponse) { - try { sseResponse.end(); } catch {} - sseResponse = null; - } - return true; - }; - - const touchHeartbeat = (): void => { - lastHeartbeatAt = Date.now(); - browserConnected = true; - }; - - function validateToken(body: unknown, res: ServerResponse): boolean { - if (!body || typeof body !== "object") { - sendJson(res, 400, { ok: false, error: "Invalid body" }); - return false; - } - if ((body as { token?: string }).token !== sessionToken) { - sendJson(res, 403, { ok: false, error: "Invalid session" }); - return false; - } - return true; - } - - function sendSSE(event: string, data: unknown): void { - const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; - const res = sseResponse; - if (res && !res.writableEnded && res.socket && !res.socket.destroyed) { - try { - const ok = res.write(payload); - if (!ok) res.once("drain", () => {}); - } catch { - sseBuffer.push(payload); - } - } else { - sseBuffer.push(payload); - } - } - - const pageHtml = generateCuratorPage(queries, sessionToken, timeout, availableProviders, defaultProvider); - - const server = http.createServer(async (req, res) => { - try { - const method = req.method || "GET"; - const url = new URL(req.url || "/", `http://${req.headers.host || "127.0.0.1"}`); - - if (method === "GET" && url.pathname === "/") { - const token = url.searchParams.get("session"); - if (token !== sessionToken) { - res.writeHead(403, { "Content-Type": "text/plain" }); - res.end("Invalid session"); - return; - } - touchHeartbeat(); - res.writeHead(200, { - "Content-Type": "text/html; charset=utf-8", - "Cache-Control": "no-store", - }); - res.end(pageHtml); - return; - } - - if (method === "GET" && url.pathname === "/events") { - const token = url.searchParams.get("session"); - if (token !== sessionToken) { - res.writeHead(403, { "Content-Type": "text/plain" }); - res.end("Invalid session"); - return; - } - if (state === "COMPLETED") { - sendJson(res, 409, { ok: false, error: "No events available" }); - return; - } - if (sseResponse) { - try { sseResponse.end(); } catch {} - } - res.writeHead(200, { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", - "Connection": "keep-alive", - "X-Accel-Buffering": "no", - }); - res.flushHeaders(); - if (res.socket) res.socket.setNoDelay(true); - sseResponse = res; - for (const msg of sseBuffer) { - try { res.write(msg); } catch {} - } - sseBuffer.length = 0; - if (sseKeepalive) clearInterval(sseKeepalive); - sseKeepalive = setInterval(() => { - if (sseResponse) { - try { sseResponse.write(":keepalive\n\n"); } catch {} - } - }, 15000); - req.on("close", () => { - if (sseResponse === res) sseResponse = null; - }); - return; - } - - if (method === "POST" && url.pathname === "/heartbeat") { - const body = await parseJSONBody(req).catch(() => null); - if (!body) { sendJson(res, 400, { ok: false, error: "Invalid body" }); return; } - if (!validateToken(body, res)) return; - touchHeartbeat(); - sendJson(res, 200, { ok: true }); - return; - } - - if (method === "POST" && url.pathname === "/provider") { - const body = await parseJSONBody(req).catch(() => null); - if (!body) { sendJson(res, 400, { ok: false, error: "Invalid body" }); return; } - if (!validateToken(body, res)) return; - const { provider } = body as { provider?: string }; - if (typeof provider === "string" && provider.length > 0) { - setImmediate(() => callbacks.onProviderChange(provider)); - } - sendJson(res, 200, { ok: true }); - return; - } - - if (method === "POST" && url.pathname === "/search") { - const body = await parseJSONBody(req).catch(() => null); - if (!body) { sendJson(res, 400, { ok: false, error: "Invalid body" }); return; } - if (!validateToken(body, res)) return; - if (state === "COMPLETED") { - sendJson(res, 409, { ok: false, error: "Session closed" }); - return; - } - const { query } = body as { query?: string }; - if (typeof query !== "string" || query.trim().length === 0) { - sendJson(res, 400, { ok: false, error: "Invalid query" }); - return; - } - const qi = nextQueryIndex++; - touchHeartbeat(); - try { - const result = await callbacks.onAddSearch(query.trim(), qi); - sendJson(res, 200, { ok: true, queryIndex: qi, answer: result.answer, results: result.results }); - } catch (err) { - const message = err instanceof Error ? err.message : "Search failed"; - sendJson(res, 200, { ok: true, queryIndex: qi, error: message }); - } - return; - } - - if (method === "POST" && url.pathname === "/submit") { - const body = await parseJSONBody(req).catch(() => null); - if (!body) { sendJson(res, 400, { ok: false, error: "Invalid body" }); return; } - if (!validateToken(body, res)) return; - const { selected } = body as { selected?: number[] }; - if (!Array.isArray(selected) || !selected.every(n => typeof n === "number")) { - sendJson(res, 400, { ok: false, error: "Invalid selection" }); - return; - } - if (state !== "SEARCHING" && state !== "RESULT_SELECTION") { - sendJson(res, 409, { ok: false, error: "Cannot submit in current state" }); - return; - } - if (!markCompleted()) { - sendJson(res, 409, { ok: false, error: "Session closed" }); - return; - } - sendJson(res, 200, { ok: true }); - setImmediate(() => callbacks.onSubmit(selected)); - return; - } - - if (method === "POST" && url.pathname === "/cancel") { - const body = await parseJSONBody(req).catch(() => null); - if (!body) { sendJson(res, 400, { ok: false, error: "Invalid body" }); return; } - if (!validateToken(body, res)) return; - if (!markCompleted()) { - sendJson(res, 200, { ok: true }); - return; - } - const { reason } = body as { reason?: string }; - sendJson(res, 200, { ok: true }); - const cancelReason = reason === "timeout" ? "timeout" : "user"; - setImmediate(() => callbacks.onCancel(cancelReason)); - return; - } - - res.writeHead(404, { "Content-Type": "text/plain" }); - res.end("Not found"); - } catch (err) { - const message = err instanceof Error ? err.message : "Server error"; - sendJson(res, 500, { ok: false, error: message }); - } - }); - - return new Promise((resolve, reject) => { - const onError = (err: Error) => { - reject(new Error(`Curator server failed to start: ${err.message}`)); - }; - - server.once("error", onError); - server.listen(0, "127.0.0.1", () => { - server.off("error", onError); - const addr = server.address(); - if (!addr || typeof addr === "string") { - reject(new Error("Curator server: invalid address")); - return; - } - const url = `http://localhost:${addr.port}/?session=${sessionToken}`; - - watchdog = setInterval(() => { - if (completed || !browserConnected) return; - if (Date.now() - lastHeartbeatAt <= STALE_THRESHOLD_MS) return; - if (!markCompleted()) return; - setImmediate(() => callbacks.onCancel("stale")); - }, WATCHDOG_INTERVAL_MS); - - resolve({ - server, - url, - close: () => { - const wasOpen = markCompleted(); - try { server.close(); } catch {} - if (wasOpen) { - setImmediate(() => callbacks.onCancel("stale")); - } - }, - pushResult: (queryIndex, data) => { - if (completed) return; - sendSSE("result", { queryIndex, query: queries[queryIndex] ?? "", ...data }); - }, - pushError: (queryIndex, error) => { - if (completed) return; - sendSSE("search-error", { queryIndex, query: queries[queryIndex] ?? "", error }); - }, - searchesDone: () => { - if (completed) return; - sendSSE("done", {}); - state = "RESULT_SELECTION"; - }, - }); - }); - }); -} diff --git a/.feynman/vendor-overrides/pi-web-access/exa.ts b/.feynman/vendor-overrides/pi-web-access/exa.ts deleted file mode 100644 index d9fb870..0000000 --- a/.feynman/vendor-overrides/pi-web-access/exa.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { existsSync, readFileSync } from "node:fs"; -import { homedir } from "node:os"; -import { join } from "node:path"; -import { activityMonitor } from "./activity.js"; - -import type { SearchOptions, SearchResponse, SearchResult } from "./perplexity.js"; - -const EXA_API_URL = "https://api.exa.ai/search"; -const CONFIG_PATH = join(homedir(), ".pi", "web-search.json"); - -interface WebSearchConfig { - exaApiKey?: string; -} - -interface ExaSearchResult { - title?: string; - url?: string; - text?: string; - highlights?: string[]; - summary?: string; -} - -let cachedConfig: WebSearchConfig | null = null; - -function loadConfig(): WebSearchConfig { - if (cachedConfig) return cachedConfig; - - if (existsSync(CONFIG_PATH)) { - try { - cachedConfig = JSON.parse(readFileSync(CONFIG_PATH, "utf-8")) as WebSearchConfig; - return cachedConfig; - } catch { - cachedConfig = {}; - } - } else { - cachedConfig = {}; - } - return cachedConfig; -} - -function getApiKey(): string { - const config = loadConfig(); - const key = process.env.EXA_API_KEY || config.exaApiKey; - if (!key) { - throw new Error( - "Exa API key not found. Either:\n" + - ` 1. Create ${CONFIG_PATH} with { "exaApiKey": "your-key" }\n` + - " 2. Set EXA_API_KEY environment variable\n" + - "Get a key from the Exa dashboard." - ); - } - return key; -} - -function toSnippet(result: ExaSearchResult): string { - if (Array.isArray(result.highlights) && result.highlights.length > 0) { - return result.highlights.join(" "); - } - if (typeof result.summary === "string" && result.summary.trim()) { - return result.summary.trim(); - } - if (typeof result.text === "string" && result.text.trim()) { - return result.text.trim().slice(0, 400); - } - return ""; -} - -function formatAnswer(results: SearchResult[]): string { - return results - .map((result, index) => { - const snippet = result.snippet ? `\n${result.snippet}` : ""; - return `${index + 1}. ${result.title}\n${result.url}${snippet}`; - }) - .join("\n\n"); -} - -export function isExaAvailable(): boolean { - const config = loadConfig(); - return Boolean(process.env.EXA_API_KEY || config.exaApiKey); -} - -export async function searchWithExa(query: string, options: SearchOptions = {}): Promise { - const activityId = activityMonitor.logStart({ type: "api", query }); - const apiKey = getApiKey(); - const numResults = Math.min(options.numResults ?? 5, 20); - const includeDomains = options.domainFilter?.filter((entry) => !entry.startsWith("-")) ?? []; - const excludeDomains = options.domainFilter?.filter((entry) => entry.startsWith("-")).map((entry) => entry.slice(1)) ?? []; - - const requestBody: Record = { - query, - type: "auto", - numResults, - contents: { - highlights: { - numSentences: 3, - }, - }, - }; - - if (includeDomains.length > 0) { - requestBody.includeDomains = includeDomains; - } - if (excludeDomains.length > 0) { - requestBody.excludeDomains = excludeDomains; - } - - try { - const response = await fetch(EXA_API_URL, { - method: "POST", - headers: { - "Content-Type": "application/json", - "x-api-key": apiKey, - }, - body: JSON.stringify(requestBody), - signal: options.signal, - }); - - if (!response.ok) { - activityMonitor.logComplete(activityId, response.status); - throw new Error(`Exa API error ${response.status}: ${(await response.text()).slice(0, 300)}`); - } - - const data = await response.json() as { results?: ExaSearchResult[] }; - const results = (Array.isArray(data.results) ? data.results : []) - .slice(0, numResults) - .map((result, index) => ({ - title: result.title?.trim() || `Source ${index + 1}`, - url: result.url?.trim() || "", - snippet: toSnippet(result), - })) - .filter((result) => result.url.length > 0); - - activityMonitor.logComplete(activityId, response.status); - return { - answer: formatAnswer(results), - results, - }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (message.toLowerCase().includes("abort")) { - activityMonitor.logComplete(activityId, 0); - } else { - activityMonitor.logError(activityId, message); - } - throw error; - } -} diff --git a/.feynman/vendor-overrides/pi-web-access/gemini-search.ts b/.feynman/vendor-overrides/pi-web-access/gemini-search.ts deleted file mode 100644 index 08e76f8..0000000 --- a/.feynman/vendor-overrides/pi-web-access/gemini-search.ts +++ /dev/null @@ -1,256 +0,0 @@ -import { existsSync, readFileSync } from "node:fs"; -import { homedir } from "node:os"; -import { join } from "node:path"; -import { activityMonitor } from "./activity.js"; -import { isExaAvailable, searchWithExa } from "./exa.js"; -import { getApiKey, API_BASE, DEFAULT_MODEL } from "./gemini-api.js"; -import { isGeminiWebAvailable, queryWithCookies } from "./gemini-web.js"; -import { isPerplexityAvailable, searchWithPerplexity, type SearchResult, type SearchResponse, type SearchOptions } from "./perplexity.js"; - -export type SearchProvider = "auto" | "perplexity" | "exa" | "gemini"; - -const CONFIG_PATH = join(homedir(), ".pi", "web-search.json"); - -let cachedSearchConfig: { searchProvider: SearchProvider; searchModel?: string } | null = null; - -function getSearchConfig(): { searchProvider: SearchProvider; searchModel?: string } { - if (cachedSearchConfig) return cachedSearchConfig; - try { - if (existsSync(CONFIG_PATH)) { - const raw = JSON.parse(readFileSync(CONFIG_PATH, "utf-8")) as { - searchProvider?: SearchProvider; - searchModel?: unknown; - }; - cachedSearchConfig = { - searchProvider: raw.searchProvider ?? "auto", - searchModel: normalizeSearchModel(raw.searchModel), - }; - return cachedSearchConfig; - } - } catch {} - cachedSearchConfig = { searchProvider: "auto", searchModel: undefined }; - return cachedSearchConfig; -} - -function normalizeSearchModel(value: unknown): string | undefined { - return typeof value === "string" && value.length > 0 ? value : undefined; -} - -export interface FullSearchOptions extends SearchOptions { - provider?: SearchProvider; -} - -export async function search(query: string, options: FullSearchOptions = {}): Promise { - const config = getSearchConfig(); - const provider = options.provider ?? config.searchProvider; - - if (provider === "perplexity") { - return searchWithPerplexity(query, options); - } - - if (provider === "exa") { - return searchWithExa(query, options); - } - - if (provider === "gemini") { - const result = await searchWithGeminiApi(query, options) - ?? await searchWithGeminiWeb(query, options); - if (result) return result; - throw new Error( - "Gemini search unavailable. Either:\n" + - " 1. Set GEMINI_API_KEY in ~/.pi/web-search.json\n" + - " 2. Sign into gemini.google.com in a supported Chromium-based browser" - ); - } - - if (isPerplexityAvailable()) { - return searchWithPerplexity(query, options); - } - - if (isExaAvailable()) { - return searchWithExa(query, options); - } - - const geminiResult = await searchWithGeminiApi(query, options) - ?? await searchWithGeminiWeb(query, options); - if (geminiResult) return geminiResult; - - throw new Error( - "No search provider available. Either:\n" + - " 1. Set perplexityApiKey in ~/.pi/web-search.json\n" + - " 2. Set exaApiKey or EXA_API_KEY\n" + - " 3. Set GEMINI_API_KEY in ~/.pi/web-search.json\n" + - " 4. Sign into gemini.google.com in a supported Chromium-based browser" - ); -} - -async function searchWithGeminiApi(query: string, options: SearchOptions = {}): Promise { - const apiKey = getApiKey(); - if (!apiKey) return null; - - const activityId = activityMonitor.logStart({ type: "api", query }); - - try { - const model = getSearchConfig().searchModel ?? DEFAULT_MODEL; - const body = { - contents: [{ parts: [{ text: query }] }], - tools: [{ google_search: {} }], - }; - - const res = await fetch(`${API_BASE}/models/${model}:generateContent?key=${apiKey}`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - signal: AbortSignal.any([ - AbortSignal.timeout(60000), - ...(options.signal ? [options.signal] : []), - ]), - }); - - if (!res.ok) { - const errorText = await res.text(); - throw new Error(`Gemini API error ${res.status}: ${errorText.slice(0, 300)}`); - } - - const data = await res.json() as GeminiSearchResponse; - activityMonitor.logComplete(activityId, res.status); - - const answer = data.candidates?.[0]?.content?.parts - ?.map(p => p.text).filter(Boolean).join("\n") ?? ""; - - const metadata = data.candidates?.[0]?.groundingMetadata; - const results = await resolveGroundingChunks(metadata?.groundingChunks, options.signal); - - if (!answer && results.length === 0) return null; - return { answer, results }; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - if (message.toLowerCase().includes("abort")) { - activityMonitor.logComplete(activityId, 0); - } else { - activityMonitor.logError(activityId, message); - } - return null; - } -} - -async function searchWithGeminiWeb(query: string, options: SearchOptions = {}): Promise { - const cookies = await isGeminiWebAvailable(); - if (!cookies) return null; - - const prompt = buildSearchPrompt(query, options); - const activityId = activityMonitor.logStart({ type: "api", query }); - - try { - const text = await queryWithCookies(prompt, cookies, { - model: "gemini-3-flash-preview", - signal: options.signal, - timeoutMs: 60000, - }); - - activityMonitor.logComplete(activityId, 200); - - const results = extractSourceUrls(text); - return { answer: text, results }; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - if (message.toLowerCase().includes("abort")) { - activityMonitor.logComplete(activityId, 0); - } else { - activityMonitor.logError(activityId, message); - } - return null; - } -} - -function buildSearchPrompt(query: string, options: SearchOptions): string { - let prompt = `Search the web and answer the following question. Include source URLs for your claims.\nFormat your response as:\n1. A direct answer to the question\n2. Cited sources as markdown links\n\nQuestion: ${query}`; - - if (options.recencyFilter) { - const labels: Record = { - day: "past 24 hours", - week: "past week", - month: "past month", - year: "past year", - }; - prompt += `\n\nOnly include results from the ${labels[options.recencyFilter]}.`; - } - - if (options.domainFilter?.length) { - const includes = options.domainFilter.filter(d => !d.startsWith("-")); - const excludes = options.domainFilter.filter(d => d.startsWith("-")).map(d => d.slice(1)); - if (includes.length) prompt += `\n\nOnly cite sources from: ${includes.join(", ")}`; - if (excludes.length) prompt += `\n\nDo not cite sources from: ${excludes.join(", ")}`; - } - - return prompt; -} - -function extractSourceUrls(markdown: string): SearchResult[] { - const results: SearchResult[] = []; - const seen = new Set(); - const linkRegex = /\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g; - for (const match of markdown.matchAll(linkRegex)) { - const url = match[2]; - if (seen.has(url)) continue; - seen.add(url); - results.push({ title: match[1], url, snippet: "" }); - } - return results; -} - -async function resolveGroundingChunks( - chunks: GroundingChunk[] | undefined, - signal?: AbortSignal, -): Promise { - if (!chunks?.length) return []; - - const results: SearchResult[] = []; - for (const chunk of chunks) { - if (!chunk.web) continue; - const title = chunk.web.title || ""; - let url = chunk.web.uri || ""; - - if (url.includes("vertexaisearch.cloud.google.com/grounding-api-redirect")) { - const resolved = await resolveRedirect(url, signal); - if (resolved) url = resolved; - } - - if (url) results.push({ title, url, snippet: "" }); - } - return results; -} - -async function resolveRedirect(proxyUrl: string, signal?: AbortSignal): Promise { - try { - const res = await fetch(proxyUrl, { - method: "HEAD", - redirect: "manual", - signal: AbortSignal.any([ - AbortSignal.timeout(5000), - ...(signal ? [signal] : []), - ]), - }); - return res.headers.get("location") || null; - } catch { - return null; - } -} - -interface GeminiSearchResponse { - candidates?: Array<{ - content?: { parts?: Array<{ text?: string }> }; - groundingMetadata?: { - webSearchQueries?: string[]; - groundingChunks?: GroundingChunk[]; - groundingSupports?: Array<{ - segment?: { startIndex?: number; endIndex?: number; text?: string }; - groundingChunkIndices?: number[]; - }>; - }; - }>; -} - -interface GroundingChunk { - web?: { uri?: string; title?: string }; -} diff --git a/.feynman/vendor-overrides/pi-web-access/index.ts b/.feynman/vendor-overrides/pi-web-access/index.ts deleted file mode 100644 index 81191ed..0000000 --- a/.feynman/vendor-overrides/pi-web-access/index.ts +++ /dev/null @@ -1,1658 +0,0 @@ -import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent"; -import { Box, Text, truncateToWidth } from "@mariozechner/pi-tui"; -import { Type } from "@sinclair/typebox"; -import { StringEnum } from "@mariozechner/pi-ai"; -import { fetchAllContent, type ExtractedContent } from "./extract.js"; -import { clearCloneCache } from "./github-extract.js"; -import { isExaAvailable } from "./exa.js"; -import { search, type SearchProvider } from "./gemini-search.js"; -import type { SearchResult } from "./perplexity.js"; -import { formatSeconds } from "./utils.js"; -import { - clearResults, - deleteResult, - generateId, - getAllResults, - getResult, - restoreFromSession, - storeResult, - type QueryResultData, - type StoredSearchData, -} from "./storage.js"; -import { activityMonitor, type ActivityEntry } from "./activity.js"; -import { startCuratorServer, type CuratorServerHandle } from "./curator-server.js"; -import { randomUUID } from "node:crypto"; -import { platform, homedir } from "node:os"; -import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; -import { join } from "node:path"; -import { isPerplexityAvailable } from "./perplexity.js"; -import { isGeminiApiAvailable } from "./gemini-api.js"; -import { getActiveGoogleEmail, isGeminiWebAvailable } from "./gemini-web.js"; -import { - condenseSearchResults, - postProcessCondensed, - preprocessSearchResults, - resolveCondenseConfig, -} from "./search-filter.js"; - -const WEB_SEARCH_CONFIG_PATH = join(homedir(), ".pi", "web-search.json"); -const DEFAULT_CURATE_WINDOW = 10; - -interface WebSearchConfig { - provider?: string; - curateWindow?: number; - autoFilter?: boolean | { - enabled?: boolean; - model?: string; - prompt?: string; - }; - shortcuts?: { - curate?: string; - activity?: string; - }; -} - -function loadConfig(): WebSearchConfig { - try { - if (existsSync(WEB_SEARCH_CONFIG_PATH)) { - return JSON.parse(readFileSync(WEB_SEARCH_CONFIG_PATH, "utf-8")); - } - } catch {} - return {}; -} - -function saveConfig(updates: Partial): void { - try { - let config: Record = {}; - if (existsSync(WEB_SEARCH_CONFIG_PATH)) { - try { config = JSON.parse(readFileSync(WEB_SEARCH_CONFIG_PATH, "utf-8")); } catch {} - } - Object.assign(config, updates); - const dir = join(homedir(), ".pi"); - if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); - writeFileSync(WEB_SEARCH_CONFIG_PATH, JSON.stringify(config, null, 2) + "\n"); - } catch {} -} - -const DEFAULT_SHORTCUTS = { curate: "ctrl+shift+s", activity: "ctrl+shift+w" }; - -function formatShortcut(key: string): string { - return key.split("+").map(p => p[0].toUpperCase() + p.slice(1)).join("+"); -} - -function resolveProvider( - requested: string | undefined, - available: { perplexity: boolean; exa: boolean; gemini: boolean }, -): string { - const provider = requested || loadConfig().provider || "auto"; - if (provider === "auto" || provider === "") { - if (available.perplexity) return "perplexity"; - if (available.exa) return "exa"; - if (available.gemini) return "gemini"; - return "perplexity"; - } - if (provider === "perplexity" && !available.perplexity) { - if (available.exa) return "exa"; - return available.gemini ? "gemini" : "perplexity"; - } - if (provider === "exa" && !available.exa) { - if (available.perplexity) return "perplexity"; - return available.gemini ? "gemini" : "exa"; - } - if (provider === "gemini" && !available.gemini) { - if (available.perplexity) return "perplexity"; - return available.exa ? "exa" : "gemini"; - } - return provider; -} - -const pendingFetches = new Map(); -let sessionActive = false; -let widgetVisible = false; -let widgetUnsubscribe: (() => void) | null = null; -let activeCurator: CuratorServerHandle | null = null; - -interface PendingCurate { - phase: "searching" | "curate-window" | "curating" | "condensing"; - searchResults: Map; - allUrls: string[]; - queryList: string[]; - includeContent: boolean; - numResults?: number; - recencyFilter?: "day" | "week" | "month" | "year"; - domainFilter?: string[]; - availableProviders: { perplexity: boolean; exa: boolean; gemini: boolean }; - defaultProvider: string; - onUpdate: ((update: { content: Array<{ type: string; text: string }>; details?: Record }) => void) | undefined; - signal: AbortSignal | undefined; - timer?: ReturnType; - countdownInterval?: ReturnType; - finish: (value: unknown) => void; - cancel: () => void; - browserPromise?: Promise; - condensePromise?: Promise; -} - -let pendingCurate: PendingCurate | null = null; - -function cancelPendingCurate(): void { - pendingCurate?.cancel(); -} - -const MAX_INLINE_CONTENT = 30000; // Content returned directly to agent - -function stripThumbnails(results: ExtractedContent[]): ExtractedContent[] { - return results.map(({ thumbnail, frames, ...rest }) => rest); -} - -function formatSearchSummary(results: SearchResult[], answer: string): string { - let output = answer ? `${answer}\n\n---\n\n**Sources:**\n` : ""; - output += results.map((r, i) => `${i + 1}. ${r.title}\n ${r.url}`).join("\n\n"); - return output; -} - -function formatFullResults(queryData: QueryResultData): string { - let output = `## Results for: "${queryData.query}"\n\n`; - if (queryData.answer) { - output += `${queryData.answer}\n\n---\n\n`; - } - for (const r of queryData.results) { - output += `### ${r.title}\n${r.url}\n\n`; - } - return output; -} - -function abortPendingFetches(): void { - for (const controller of pendingFetches.values()) { - controller.abort(); - } - pendingFetches.clear(); -} - -function closeCurator(): void { - cancelPendingCurate(); - if (activeCurator) { - activeCurator.close(); - activeCurator = null; - } -} - -async function openInBrowser(pi: ExtensionAPI, url: string): Promise { - const plat = platform(); - const result = plat === "darwin" - ? await pi.exec("open", [url]) - : plat === "win32" - ? await pi.exec("cmd", ["/c", "start", "", url]) - : await pi.exec("xdg-open", [url]); - if (result.code !== 0) { - throw new Error(result.stderr || `Failed to open browser (exit code ${result.code})`); - } -} - -function extractDomain(url: string): string { - try { return new URL(url).hostname; } - catch { return url; } -} - -function updateWidget(ctx: ExtensionContext): void { - const theme = ctx.ui.theme; - const entries = activityMonitor.getEntries(); - const lines: string[] = []; - - lines.push(theme.fg("accent", "─── Web Search Activity " + "─".repeat(36))); - - if (entries.length === 0) { - lines.push(theme.fg("muted", " No activity yet")); - } else { - for (const e of entries) { - lines.push(" " + formatEntryLine(e, theme)); - } - } - - lines.push(theme.fg("accent", "─".repeat(60))); - - const rateInfo = activityMonitor.getRateLimitInfo(); - const resetMs = rateInfo.oldestTimestamp ? Math.max(0, rateInfo.oldestTimestamp + rateInfo.windowMs - Date.now()) : 0; - const resetSec = Math.ceil(resetMs / 1000); - lines.push( - theme.fg("muted", `Rate: ${rateInfo.used}/${rateInfo.max}`) + - (resetMs > 0 ? theme.fg("dim", ` (resets in ${resetSec}s)`) : ""), - ); - - ctx.ui.setWidget("web-activity", new Text(lines.join("\n"), 0, 0)); -} - -function formatEntryLine( - entry: ActivityEntry, - theme: { fg: (color: string, text: string) => string }, -): string { - const typeStr = entry.type === "api" ? "API" : "GET"; - const target = - entry.type === "api" - ? `"${truncateToWidth(entry.query || "", 28, "")}"` - : truncateToWidth(entry.url?.replace(/^https?:\/\//, "") || "", 30, ""); - - const duration = entry.endTime - ? `${((entry.endTime - entry.startTime) / 1000).toFixed(1)}s` - : `${((Date.now() - entry.startTime) / 1000).toFixed(1)}s`; - - let statusStr: string; - let indicator: string; - if (entry.error) { - statusStr = "err"; - indicator = theme.fg("error", "✗"); - } else if (entry.status === null) { - statusStr = "..."; - indicator = theme.fg("warning", "⋯"); - } else if (entry.status === 0) { - statusStr = "abort"; - indicator = theme.fg("muted", "○"); - } else { - statusStr = String(entry.status); - indicator = entry.status >= 200 && entry.status < 300 ? theme.fg("success", "✓") : theme.fg("error", "✗"); - } - - return `${typeStr.padEnd(4)} ${target.padEnd(32)} ${statusStr.padStart(5)} ${duration.padStart(5)} ${indicator}`; -} - -function handleSessionChange(ctx: ExtensionContext): void { - abortPendingFetches(); - closeCurator(); - clearCloneCache(); - sessionActive = true; - restoreFromSession(ctx); - // Unsubscribe before clear() to avoid callback with stale ctx - widgetUnsubscribe?.(); - widgetUnsubscribe = null; - activityMonitor.clear(); - if (widgetVisible) { - // Re-subscribe with new ctx - widgetUnsubscribe = activityMonitor.onUpdate(() => updateWidget(ctx)); - updateWidget(ctx); - } -} - -export default function (pi: ExtensionAPI) { - const initConfig = loadConfig(); - const curateKey = initConfig.shortcuts?.curate || DEFAULT_SHORTCUTS.curate; - const activityKey = initConfig.shortcuts?.activity || DEFAULT_SHORTCUTS.activity; - const curateLabel = formatShortcut(curateKey); - - function startBackgroundFetch(urls: string[]): string | null { - if (urls.length === 0) return null; - const fetchId = generateId(); - const controller = new AbortController(); - pendingFetches.set(fetchId, controller); - fetchAllContent(urls, controller.signal) - .then((fetched) => { - if (!sessionActive || !pendingFetches.has(fetchId)) return; - const data: StoredSearchData = { - id: fetchId, - type: "fetch", - timestamp: Date.now(), - urls: stripThumbnails(fetched), - }; - storeResult(fetchId, data); - pi.appendEntry("web-search-results", data); - const ok = fetched.filter(f => !f.error).length; - pi.sendMessage( - { - customType: "web-search-content-ready", - content: `Content fetched for ${ok}/${fetched.length} URLs [${fetchId}]. Full page content now available.`, - display: true, - }, - { triggerTurn: true }, - ); - }) - .catch((err) => { - if (!sessionActive || !pendingFetches.has(fetchId)) return; - const message = err instanceof Error ? err.message : String(err); - const isAbort = err.name === "AbortError" || message.toLowerCase().includes("abort"); - if (!isAbort) { - pi.sendMessage( - { - customType: "web-search-error", - content: `Content fetch failed [${fetchId}]: ${message}`, - display: true, - }, - { triggerTurn: false }, - ); - } - }) - .finally(() => { pendingFetches.delete(fetchId); }); - return fetchId; - } - - function storeAndPublishSearch(results: QueryResultData[]): string { - const id = generateId(); - const data: StoredSearchData = { - id, type: "search", timestamp: Date.now(), queries: results, - }; - storeResult(id, data); - pi.appendEntry("web-search-results", data); - return id; - } - - interface SearchReturnOptions { - queryList: string[]; - results: QueryResultData[]; - urls: string[]; - includeContent: boolean; - curated?: boolean; - curatedFrom?: number; - } - - function buildSearchReturn(opts: SearchReturnOptions) { - const sc = opts.results.filter(r => !r.error).length; - const tr = opts.results.reduce((sum, r) => sum + r.results.length, 0); - - let output = ""; - if (opts.curated) { - output += "[These results were manually curated by the user in the browser. Use them as-is — do not re-search or discard.]\n\n"; - } - for (const { query, answer, results, error } of opts.results) { - if (opts.queryList.length > 1) output += `## Query: "${query}"\n\n`; - if (error) output += `Error: ${error}\n\n`; - else if (results.length === 0) output += "No results found.\n\n"; - else output += formatSearchSummary(results, answer) + "\n\n"; - } - - const fetchId = opts.includeContent ? startBackgroundFetch(opts.urls) : null; - if (fetchId) output += `---\nContent fetching in background [${fetchId}]. Will notify when ready.`; - - const searchId = storeAndPublishSearch(opts.results); - - return { - content: [{ type: "text", text: output.trim() }], - details: { - queries: opts.queryList, - queryCount: opts.queryList.length, - successfulQueries: sc, - totalResults: tr, - includeContent: opts.includeContent, - fetchId, - fetchUrls: fetchId ? opts.urls : undefined, - searchId, - ...(opts.curated ? { - curated: true, - curatedFrom: opts.curatedFrom, - curatedQueries: opts.results.map(r => ({ - query: r.query, - answer: r.answer || null, - sources: r.results.map(s => ({ title: s.title, url: s.url })), - error: r.error, - })), - } : {}), - }, - }; - } - - function buildCondensedReturn(opts: { - condensed: string; - results: QueryResultData[]; - urls: string[]; - includeContent: boolean; - }) { - const sc = opts.results.filter(r => !r.error).length; - const tr = opts.results.reduce((sum, r) => sum + r.results.length, 0); - const queryList = opts.results.map(r => r.query); - const searchId = storeAndPublishSearch(opts.results); - - let output = `[These results were condensed from ${queryList.length} search queries into key findings.`; - output += ` Full per-query results available via get_search_content with ID "${searchId}"`; - output += " (retrieve by query text or index).]\n\n"; - output += opts.condensed; - - const fetchId = opts.includeContent ? startBackgroundFetch(opts.urls) : null; - if (fetchId) output += `\n\n---\nContent fetching in background [${fetchId}]. Will notify when ready.`; - - return { - content: [{ type: "text", text: output.trim() }], - details: { - queries: queryList, - queryCount: queryList.length, - successfulQueries: sc, - totalResults: tr, - includeContent: opts.includeContent, - fetchId, - fetchUrls: fetchId ? opts.urls : undefined, - searchId, - condensed: true, - condensedFrom: queryList.length, - }, - }; - } - - function filterByQueryIndices(selectedQueryIndices: number[], results: Map) { - const filteredResults: QueryResultData[] = []; - const filteredUrls: string[] = []; - for (const qi of selectedQueryIndices) { - const r = results.get(qi); - if (r) { - filteredResults.push(r); - for (const res of r.results) { - if (!filteredUrls.includes(res.url)) filteredUrls.push(res.url); - } - } - } - return { results: filteredResults, urls: filteredUrls }; - } - - async function openCuratorBrowser(pc: PendingCurate, searchesComplete = true): Promise { - try { - if (pc.timer) clearTimeout(pc.timer); - if (pc.countdownInterval) clearInterval(pc.countdownInterval); - pc.phase = "curating"; - - const searchAbort = new AbortController(); - const addSearchSignal = pc.signal - ? AbortSignal.any([pc.signal, searchAbort.signal]) - : searchAbort.signal; - - const sessionToken = randomUUID(); - const handle = await startCuratorServer( - { - queries: pc.queryList, - sessionToken, - timeout: 120, - availableProviders: pc.availableProviders, - defaultProvider: pc.defaultProvider, - }, - { - onSubmit(selectedQueryIndices) { - searchAbort.abort(); - const filtered = filterByQueryIndices(selectedQueryIndices, pc.searchResults); - pc.finish(buildSearchReturn({ - queryList: filtered.results.map(r => r.query), - results: filtered.results, - urls: filtered.urls, - includeContent: pc.includeContent, - curated: true, - curatedFrom: pc.searchResults.size, - })); - closeCurator(); - }, - onCancel() { - searchAbort.abort(); - pc.cancel(); - closeCurator(); - }, - onProviderChange(provider) { - saveConfig({ provider }); - }, - async onAddSearch(query, queryIndex) { - const { answer, results } = await search(query, { - provider: pc.defaultProvider as SearchProvider | undefined, - numResults: pc.numResults, - recencyFilter: pc.recencyFilter, - domainFilter: pc.domainFilter, - signal: addSearchSignal, - }); - pc.searchResults.set(queryIndex, { query, answer, results, error: null }); - for (const r of results) { - if (!pc.allUrls.includes(r.url)) pc.allUrls.push(r.url); - } - return { - answer, - results: results.map(r => ({ title: r.title, url: r.url, domain: extractDomain(r.url) })), - }; - }, - }, - ); - - if (pendingCurate !== pc) { - handle.close(); - return; - } - - activeCurator = handle; - - for (const [qi, data] of pc.searchResults) { - if (data.error) { - handle.pushError(qi, data.error); - } else { - handle.pushResult(qi, { - answer: data.answer, - results: data.results.map(r => ({ title: r.title, url: r.url, domain: extractDomain(r.url) })), - }); - } - } - if (searchesComplete) handle.searchesDone(); - - pc.onUpdate?.({ - content: [{ type: "text", text: searchesComplete ? "Waiting for user to curate search results in browser..." : "Searches streaming to browser..." }], - details: { phase: "curating", progress: searchesComplete ? 1 : 0.5 }, - }); - - await openInBrowser(pi, handle.url); - } catch { - closeCurator(); - } - } - - pi.registerShortcut(curateKey as any, { - description: "Review search results in browser", - handler: async (ctx) => { - if (!pendingCurate) return; - - if (pendingCurate.phase === "searching") { - pendingCurate.browserPromise = openCuratorBrowser(pendingCurate, false); - ctx.ui.notify("Opening curator — remaining searches will stream in", "info"); - return; - } - - if (pendingCurate.phase === "curate-window") { - await openCuratorBrowser(pendingCurate); - return; - } - }, - }); - - pi.registerShortcut(activityKey as any, { - description: "Toggle web search activity", - handler: async (ctx) => { - widgetVisible = !widgetVisible; - if (widgetVisible) { - widgetUnsubscribe = activityMonitor.onUpdate(() => updateWidget(ctx)); - updateWidget(ctx); - } else { - widgetUnsubscribe?.(); - widgetUnsubscribe = null; - ctx.ui.setWidget("web-activity", null); - } - }, - }); - - pi.on("session_start", async (_event, ctx) => handleSessionChange(ctx)); - pi.on("session_switch", async (_event, ctx) => handleSessionChange(ctx)); - pi.on("session_fork", async (_event, ctx) => handleSessionChange(ctx)); - pi.on("session_tree", async (_event, ctx) => handleSessionChange(ctx)); - - pi.on("session_shutdown", () => { - sessionActive = false; - abortPendingFetches(); - closeCurator(); - clearCloneCache(); - clearResults(); - // Unsubscribe before clear() to avoid callback with stale ctx - widgetUnsubscribe?.(); - widgetUnsubscribe = null; - activityMonitor.clear(); - widgetVisible = false; - }); - - pi.registerTool({ - name: "web_search", - label: "Web Search", - description: - `Search the web using Perplexity, Exa, or Gemini. Returns an answer plus cited sources. For comprehensive research, prefer queries (plural) with 2-4 varied angles over a single query — each query gets its own answer, so varying phrasing and scope gives much broader coverage. When includeContent is true, full page content is fetched in the background. Multi-query searches include a brief review window where the user can press ${curateLabel} to curate results in the browser before they're sent. Set curate to false to skip this. Provider auto-selects: Perplexity if configured, else Exa, else Gemini API, else Gemini Web.`, - parameters: Type.Object({ - query: Type.Optional(Type.String({ description: "Single search query. For research tasks, prefer 'queries' with multiple varied angles instead." })), - queries: 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)." })), - numResults: Type.Optional(Type.Number({ description: "Results per query (default: 5, max: 20)" })), - includeContent: Type.Optional(Type.Boolean({ description: "Fetch full page content (async)" })), - recencyFilter: Type.Optional( - StringEnum(["day", "week", "month", "year"], { description: "Filter by recency" }), - ), - domainFilter: Type.Optional(Type.Array(Type.String(), { description: "Limit to domains (prefix with - to exclude)" })), - provider: Type.Optional( - StringEnum(["auto", "perplexity", "exa", "gemini"], { description: "Search provider (default: auto)" }), - ), - curate: Type.Optional(Type.Boolean({ - description: `Hold results for review after searching. The user can press ${curateLabel} to open an interactive review page in the browser, or wait for the countdown to auto-send all results. Enabled by default for multi-query searches. Set to false to skip the review window.`, - })), - context: Type.Optional(Type.String({ - description: "Brief description of your current task or goal. Improves auto-filter relevance for multi-query searches.", - })), - }), - - async execute(_toolCallId, params, signal, onUpdate, ctx) { - const queryList = params.queries ?? (params.query ? [params.query] : []); - const isMultiQuery = queryList.length > 1; - const shouldCurate = params.curate !== false && ctx?.hasUI !== false; - - if (queryList.length === 0) { - return { - content: [{ type: "text", text: "Error: No query provided. Use 'query' or 'queries' parameter." }], - details: { error: "No query provided" }, - }; - } - - if (shouldCurate) { - closeCurator(); - - const { promise, resolve: resolvePromise } = Promise.withResolvers(); - const includeContent = params.includeContent ?? false; - const searchResults = new Map(); - const allUrls: string[] = []; - let cancelled = false; - - const pplxAvail = isPerplexityAvailable(); - const exaAvail = isExaAvailable(); - const geminiApiAvail = isGeminiApiAvailable(); - const geminiWebAvail = await isGeminiWebAvailable(); - const availableProviders = { - perplexity: pplxAvail, - exa: exaAvail, - gemini: geminiApiAvail || !!geminiWebAvail, - }; - const defaultProvider = resolveProvider(params.provider, availableProviders); - const curateConfig = loadConfig(); - const curateWindow = curateConfig.curateWindow ?? DEFAULT_CURATE_WINDOW; - - const pc: PendingCurate = { - phase: "searching", - searchResults, - allUrls, - queryList, - includeContent, - numResults: params.numResults, - recencyFilter: params.recencyFilter, - domainFilter: params.domainFilter, - availableProviders, - defaultProvider, - onUpdate: onUpdate as PendingCurate["onUpdate"], - signal, - finish: () => {}, - cancel: () => {}, - }; - - const finish = (value: unknown) => { - if (cancelled) return; - cancelled = true; - if (pc.timer) clearTimeout(pc.timer); - if (pc.countdownInterval) clearInterval(pc.countdownInterval); - signal?.removeEventListener("abort", onAbort); - pendingCurate = null; - resolvePromise(value); - }; - - const cancel = () => { - const results = [...searchResults.values()]; - finish(buildSearchReturn({ - queryList: results.map(r => r.query), - results, - urls: allUrls, - includeContent, - })); - }; - - pc.finish = finish; - pc.cancel = cancel; - - const onAbort = () => closeCurator(); - pendingCurate = pc; - signal?.addEventListener("abort", onAbort, { once: true }); - - for (let qi = 0; qi < queryList.length; qi++) { - if (signal?.aborted || cancelled) break; - onUpdate?.({ - content: [{ type: "text", text: `Searching ${qi + 1}/${queryList.length}: "${queryList[qi]}"...` }], - details: { phase: "searching", progress: qi / queryList.length, currentQuery: queryList[qi] }, - }); - try { - const { answer, results } = await search(queryList[qi], { - provider: defaultProvider as SearchProvider | undefined, - numResults: params.numResults, - recencyFilter: params.recencyFilter, - domainFilter: params.domainFilter, - signal, - }); - searchResults.set(qi, { query: queryList[qi], answer, results, error: null }); - for (const r of results) { - if (!allUrls.includes(r.url)) allUrls.push(r.url); - } - if (activeCurator) { - activeCurator.pushResult(qi, { - answer, - results: results.map(r => ({ title: r.title, url: r.url, domain: extractDomain(r.url) })), - }); - } - } catch (err) { - if (signal?.aborted || cancelled) break; - const message = err instanceof Error ? err.message : String(err); - searchResults.set(qi, { query: queryList[qi], answer: "", results: [], error: message }); - if (activeCurator) { - activeCurator.pushError(qi, message); - } - } - } - - if (signal?.aborted || cancelled) { - cancel(); - return promise; - } - - if (pc.browserPromise) { - await pc.browserPromise; - if (activeCurator && !cancelled) { - activeCurator.searchesDone(); - pc.onUpdate?.({ - content: [{ type: "text", text: "All searches complete — waiting for user to curate in browser..." }], - details: { phase: "curating", progress: 1 }, - }); - } - } else if (curateWindow > 0 && isMultiQuery) { - pc.phase = "curate-window"; - const totalSources = [...searchResults.values()].reduce((sum, r) => sum + r.results.length, 0); - let remaining = curateWindow; - const condenseConfig = resolveCondenseConfig(curateConfig.autoFilter); - const preprocessed = preprocessSearchResults(searchResults); - const allSources = [...searchResults.values()].flatMap(r => r.results); - let condenseResult: string | null | undefined; - const shouldCondense = !!condenseConfig && !preprocessed.skipCondensation; - - if (shouldCondense) { - pc.condensePromise = condenseSearchResults(searchResults, condenseConfig, ctx, signal, params.context, preprocessed); - pc.condensePromise.then(text => { - condenseResult = text ? postProcessCondensed(text, allSources) : null; - if (!cancelled && remaining > 0 && pc.phase === "curate-window") { - pc.onUpdate?.(buildCountdownUpdate()); - } - }); - } - - function buildCountdownUpdate() { - const condensing = shouldCondense && condenseResult === undefined; - const condensed = condenseResult !== undefined && condenseResult !== null; - let text: string; - if (condensed) { - text = `${searchResults.size} searches condensed · ${curateLabel} for all · sending in ${remaining}s`; - } else if (condensing) { - text = `${searchResults.size} searches (${totalSources} sources) · condensing... · ${curateLabel} to review · sending in ${remaining}s`; - } else { - text = `${searchResults.size} searches (${totalSources} sources) · ${curateLabel} to review · sending in ${remaining}s`; - } - return { - content: [{ type: "text", text }], - details: { - phase: "curate-window", - searchCount: searchResults.size, - sourceCount: totalSources, - remaining, - ...(condensing ? { condensing: true } : {}), - ...(condensed ? { condensed: true, condensedFrom: searchResults.size } : {}), - }, - }; - } - - onUpdate?.(buildCountdownUpdate()); - - pc.countdownInterval = setInterval(() => { - if (cancelled) return; - remaining--; - if (remaining > 0) { - pc.onUpdate?.(buildCountdownUpdate()); - } - }, 1000); - - pc.timer = setTimeout(async () => { - if (cancelled) return; - if (pc.countdownInterval) clearInterval(pc.countdownInterval); - - if (shouldCondense && condenseResult === undefined) { - pc.phase = "condensing"; - pc.onUpdate?.({ - content: [{ type: "text", text: "Condensing results..." }], - details: { phase: "condensing", progress: 1 }, - }); - await pc.condensePromise!; - if (cancelled) return; - } - - if (condenseResult) { - finish(buildCondensedReturn({ - condensed: condenseResult, - results: [...searchResults.values()], - urls: allUrls, - includeContent, - })); - } else { - cancel(); - } - }, curateWindow * 1000); - } else { - cancel(); - } - - return promise; - } - - const searchResults: QueryResultData[] = []; - const allUrls: string[] = []; - const resolvedProvider = params.provider || loadConfig().provider || undefined; - - for (let i = 0; i < queryList.length; i++) { - const query = queryList[i]; - - onUpdate?.({ - content: [{ type: "text", text: `Searching ${i + 1}/${queryList.length}: "${query}"...` }], - details: { phase: "search", progress: i / queryList.length, currentQuery: query }, - }); - - try { - const { answer, results } = await search(query, { - provider: resolvedProvider as SearchProvider | undefined, - numResults: params.numResults, - recencyFilter: params.recencyFilter, - domainFilter: params.domainFilter, - signal, - }); - - searchResults.push({ query, answer, results, error: null }); - for (const r of results) { - if (!allUrls.includes(r.url)) { - allUrls.push(r.url); - } - } - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - searchResults.push({ query, answer: "", results: [], error: message }); - } - } - - return buildSearchReturn({ - queryList, - results: searchResults, - urls: allUrls, - includeContent: params.includeContent ?? false, - }); - }, - - renderCall(args, theme) { - const { query, queries } = args as { query?: string; queries?: string[] }; - const queryList = queries ?? (query ? [query] : []); - if (queryList.length === 0) { - return new Text(theme.fg("toolTitle", theme.bold("search ")) + theme.fg("error", "(no query)"), 0, 0); - } - if (queryList.length === 1) { - const q = queryList[0]; - const display = q.length > 60 ? q.slice(0, 57) + "..." : q; - return new Text(theme.fg("toolTitle", theme.bold("search ")) + theme.fg("accent", `"${display}"`), 0, 0); - } - const lines = [theme.fg("toolTitle", theme.bold("search ")) + theme.fg("accent", `${queryList.length} queries`)]; - for (const q of queryList.slice(0, 5)) { - const display = q.length > 50 ? q.slice(0, 47) + "..." : q; - lines.push(theme.fg("muted", ` "${display}"`)); - } - if (queryList.length > 5) { - lines.push(theme.fg("muted", ` ... and ${queryList.length - 5} more`)); - } - return new Text(lines.join("\n"), 0, 0); - }, - - renderResult(result, { expanded, isPartial }, theme) { - type QueryDetail = { - query: string; - answer: string | null; - sources: Array<{ title: string; url: string }>; - error: string | null; - }; - const details = result.details as { - queryCount?: number; - successfulQueries?: number; - totalResults?: number; - error?: string; - fetchId?: string; - fetchUrls?: string[]; - phase?: string; - progress?: number; - currentQuery?: string; - curated?: boolean; - curatedFrom?: number; - curatedQueries?: QueryDetail[]; - searchCount?: number; - sourceCount?: number; - remaining?: number; - condensing?: boolean; - condensed?: boolean; - condensedFrom?: number; - }; - - if (isPartial) { - if (details?.phase === "curate-window") { - const count = details?.searchCount ?? 0; - const sources = details?.sourceCount ?? 0; - const remaining = details?.remaining ?? 0; - - if (details?.condensed) { - return new Text( - theme.fg("success", `${count} searches condensed`) + - theme.fg("accent", ` \u00b7 ${curateLabel} for all`) + - theme.fg("muted", ` \u00b7 sending in ${remaining}s`), - 0, 0, - ); - } - - let line = theme.fg("success", `${count} searches (${sources} sources)`); - if (details?.condensing) { - line += theme.fg("dim", " \u00b7 condensing..."); - } - line += theme.fg("accent", ` \u00b7 ${curateLabel} to review`) + - theme.fg("muted", ` \u00b7 sending in ${remaining}s`); - return new Text(line, 0, 0); - } - if (details?.phase === "curating") { - return new Text(theme.fg("accent", "waiting for user to curate results in browser..."), 0, 0); - } - if (details?.phase === "condensing") { - return new Text(theme.fg("accent", "condensing results..."), 0, 0); - } - if (details?.phase === "searching") { - const progress = details?.progress ?? 0; - const bar = "\u2588".repeat(Math.floor(progress * 10)) + "\u2591".repeat(10 - Math.floor(progress * 10)); - const query = details?.currentQuery || ""; - const display = query.length > 40 ? query.slice(0, 37) + "..." : query; - return new Text(theme.fg("accent", `[${bar}] ${display}`), 0, 0); - } - const progress = details?.progress ?? 0; - const bar = "\u2588".repeat(Math.floor(progress * 10)) + "\u2591".repeat(10 - Math.floor(progress * 10)); - return new Text(theme.fg("accent", `[${bar}] ${details?.phase || "searching"}`), 0, 0); - } - - if (details?.error) { - return new Text(theme.fg("error", `Error: ${details.error}`), 0, 0); - } - - let statusLine: string; - if (details?.condensed && details?.condensedFrom) { - statusLine = theme.fg("success", `condensed from ${details.condensedFrom} queries, ${details?.totalResults ?? 0} sources`); - } else { - const queryInfo = details?.queryCount === 1 ? "" : `${details?.successfulQueries}/${details?.queryCount} queries, `; - statusLine = theme.fg("success", `${queryInfo}${details?.totalResults ?? 0} sources`); - } - if (details?.curated && details?.curatedFrom) { - statusLine += theme.fg("muted", ` (${details.queryCount}/${details.curatedFrom} queries curated)`); - } - if (details?.fetchId && details?.fetchUrls) { - statusLine += theme.fg("muted", ` (fetching ${details.fetchUrls.length} URLs)`); - } else if (details?.fetchId) { - statusLine += theme.fg("muted", " (content fetching)"); - } - - if (!expanded) { - const textContent = result.content.find((c) => c.type === "text")?.text || ""; - const firstLine = (textContent.split("\n").find(l => l.trim() && !l.startsWith("[") && !l.startsWith("#") && !l.startsWith("---"))?.trim() || "").replace(/\*\*/g, ""); - const preview = firstLine.length > 80 ? firstLine.slice(0, 77) + "..." : firstLine; - if (preview) { - const box = new Box(1, 0, (t) => theme.bg("toolSuccessBg", t)); - box.addChild(new Text(statusLine, 0, 0)); - box.addChild(new Text(theme.fg("dim", preview), 0, 0)); - return box; - } - return new Text(statusLine, 0, 0); - } - - const lines = [statusLine]; - - const queryDetails = details?.curatedQueries; - if (queryDetails?.length) { - const kept = queryDetails.length; - const from = details?.curatedFrom ?? kept; - lines.push(""); - lines.push(theme.fg("accent", `\u2500\u2500 Curated Results (${kept} of ${from} queries kept) ` + "\u2500".repeat(24))); - - for (const cq of queryDetails) { - lines.push(""); - const dq = cq.query.length > 65 ? cq.query.slice(0, 62) + "..." : cq.query; - lines.push(theme.fg("accent", ` "${dq}"`)); - - if (cq.error) { - lines.push(theme.fg("error", ` ${cq.error}`)); - } else if (cq.answer) { - lines.push(""); - for (const line of cq.answer.split("\n")) { - lines.push(` ${line}`); - } - } - - if (cq.sources.length > 0) { - lines.push(""); - for (const s of cq.sources) { - const domain = s.url.replace(/^https?:\/\//, "").replace(/\/.*$/, ""); - const title = s.title.length > 50 ? s.title.slice(0, 47) + "..." : s.title; - lines.push(theme.fg("muted", ` \u25b8 ${title}`) + theme.fg("dim", ` \u00b7 ${domain}`)); - } - } - } - lines.push(""); - } else if (details?.condensed) { - const textContent = result.content.find((c) => c.type === "text")?.text || ""; - lines.push(""); - lines.push(theme.fg("dim", textContent)); - } else { - const textContent = result.content.find((c) => c.type === "text")?.text || ""; - const preview = textContent.length > 500 ? textContent.slice(0, 500) + "..." : textContent; - lines.push(theme.fg("dim", preview)); - } - - if (details?.fetchUrls && details.fetchUrls.length > 0) { - if (details.curated || details.condensed) { - lines.push(theme.fg("muted", `Fetching ${details.fetchUrls.length} URLs in background`)); - } else { - lines.push(theme.fg("muted", "Fetching:")); - for (const u of details.fetchUrls.slice(0, 5)) { - const display = u.length > 60 ? u.slice(0, 57) + "..." : u; - lines.push(theme.fg("dim", " " + display)); - } - if (details.fetchUrls.length > 5) { - lines.push(theme.fg("dim", ` ... and ${details.fetchUrls.length - 5} more`)); - } - } - } - - return new Text(lines.join("\n"), 0, 0); - }, - }); - - pi.registerTool({ - name: "fetch_content", - label: "Fetch Content", - description: "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.", - parameters: Type.Object({ - url: Type.Optional(Type.String({ description: "Single URL to fetch" })), - urls: Type.Optional(Type.Array(Type.String(), { description: "Multiple URLs (parallel)" })), - forceClone: Type.Optional(Type.Boolean({ - description: "Force cloning large GitHub repositories that exceed the size threshold", - })), - prompt: Type.Optional(Type.String({ - description: "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.", - })), - timestamp: Type.Optional(Type.String({ - description: "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.", - })), - frames: Type.Optional(Type.Integer({ - minimum: 1, - maximum: 12, - description: "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.", - })), - model: Type.Optional(Type.String({ - description: "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.", - })), - }), - - async execute(_toolCallId, params, signal, onUpdate) { - const urlList = params.urls ?? (params.url ? [params.url] : []); - if (urlList.length === 0) { - return { - content: [{ type: "text", text: "Error: No URL provided." }], - details: { error: "No URL provided" }, - }; - } - - onUpdate?.({ - content: [{ type: "text", text: `Fetching ${urlList.length} URL(s)...` }], - details: { phase: "fetch", progress: 0 }, - }); - - const fetchResults = await fetchAllContent(urlList, signal, { - forceClone: params.forceClone, - prompt: params.prompt, - timestamp: params.timestamp, - frames: params.frames, - model: params.model, - }); - const successful = fetchResults.filter((r) => !r.error).length; - const totalChars = fetchResults.reduce((sum, r) => sum + r.content.length, 0); - - // ALWAYS store results (even for single URL) - const responseId = generateId(); - const data: StoredSearchData = { - id: responseId, - type: "fetch", - timestamp: Date.now(), - urls: stripThumbnails(fetchResults), - }; - storeResult(responseId, data); - pi.appendEntry("web-search-results", data); - - // Single URL: return content directly (possibly truncated) with responseId - if (urlList.length === 1) { - const result = fetchResults[0]; - if (result.error) { - return { - content: [{ type: "text", text: `Error: ${result.error}` }], - details: { urls: urlList, urlCount: 1, successful: 0, error: result.error, responseId, prompt: params.prompt, timestamp: params.timestamp, frames: params.frames }, - }; - } - - const fullLength = result.content.length; - const truncated = fullLength > MAX_INLINE_CONTENT; - let output = truncated - ? result.content.slice(0, MAX_INLINE_CONTENT) + "\n\n[Content truncated...]" - : result.content; - - if (truncated) { - output += `\n\n---\nShowing ${MAX_INLINE_CONTENT} of ${fullLength} chars. ` + - `Use get_search_content({ responseId: "${responseId}", urlIndex: 0 }) for full content.`; - } - - const content: Array<{ type: string; text?: string; data?: string; mimeType?: string }> = []; - if (result.frames?.length) { - for (const frame of result.frames) { - content.push({ type: "image", data: frame.data, mimeType: frame.mimeType }); - content.push({ type: "text", text: `Frame at ${frame.timestamp}` }); - } - } else if (result.thumbnail) { - content.push({ type: "image", data: result.thumbnail.data, mimeType: result.thumbnail.mimeType }); - } - content.push({ type: "text", text: output }); - - const imageCount = (result.frames?.length ?? 0) + (result.thumbnail ? 1 : 0); - return { - content, - details: { - urls: urlList, - urlCount: 1, - successful: 1, - totalChars: fullLength, - title: result.title, - responseId, - truncated, - hasImage: imageCount > 0, - imageCount, - prompt: params.prompt, - timestamp: params.timestamp, - frames: params.frames, - duration: result.duration, - }, - }; - } - - // Multi-URL: existing behavior (summary + responseId) - let output = "## Fetched URLs\n\n"; - for (const { url, title, content, error } of fetchResults) { - if (error) { - output += `- ${url}: Error - ${error}\n`; - } else { - output += `- ${title || url} (${content.length} chars)\n`; - } - } - output += `\n---\nUse get_search_content({ responseId: "${responseId}", urlIndex: 0 }) to retrieve full content.`; - - return { - content: [{ type: "text", text: output }], - details: { urls: urlList, urlCount: urlList.length, successful, totalChars, responseId }, - }; - }, - - renderCall(args, theme) { - const { url, urls, prompt, timestamp, frames, model } = args as { url?: string; urls?: string[]; prompt?: string; timestamp?: string; frames?: number; model?: string }; - const urlList = urls ?? (url ? [url] : []); - if (urlList.length === 0) { - return new Text(theme.fg("toolTitle", theme.bold("fetch ")) + theme.fg("error", "(no URL)"), 0, 0); - } - const lines: string[] = []; - if (urlList.length === 1) { - const display = urlList[0].length > 60 ? urlList[0].slice(0, 57) + "..." : urlList[0]; - lines.push(theme.fg("toolTitle", theme.bold("fetch ")) + theme.fg("accent", display)); - } else { - lines.push(theme.fg("toolTitle", theme.bold("fetch ")) + theme.fg("accent", `${urlList.length} URLs`)); - for (const u of urlList.slice(0, 5)) { - const display = u.length > 60 ? u.slice(0, 57) + "..." : u; - lines.push(theme.fg("muted", " " + display)); - } - if (urlList.length > 5) { - lines.push(theme.fg("muted", ` ... and ${urlList.length - 5} more`)); - } - } - if (timestamp) { - lines.push(theme.fg("dim", " timestamp: ") + theme.fg("warning", timestamp)); - } - if (typeof frames === "number") { - lines.push(theme.fg("dim", " frames: ") + theme.fg("warning", String(frames))); - } - if (prompt) { - const display = prompt.length > 250 ? prompt.slice(0, 247) + "..." : prompt; - lines.push(theme.fg("dim", " prompt: ") + theme.fg("muted", `"${display}"`)); - } - if (model) { - lines.push(theme.fg("dim", " model: ") + theme.fg("warning", model)); - } - return new Text(lines.join("\n"), 0, 0); - }, - - renderResult(result, { expanded, isPartial }, theme) { - const details = result.details as { - urlCount?: number; - successful?: number; - totalChars?: number; - error?: string; - title?: string; - truncated?: boolean; - responseId?: string; - phase?: string; - progress?: number; - hasImage?: boolean; - imageCount?: number; - prompt?: string; - timestamp?: string; - frames?: number; - duration?: number; - }; - - if (isPartial) { - const progress = details?.progress ?? 0; - const bar = "\u2588".repeat(Math.floor(progress * 10)) + "\u2591".repeat(10 - Math.floor(progress * 10)); - return new Text(theme.fg("accent", `[${bar}] ${details?.phase || "fetching"}`), 0, 0); - } - - if (details?.error) { - return new Text(theme.fg("error", `Error: ${details.error}`), 0, 0); - } - - if (details?.urlCount === 1) { - const title = details?.title || "Untitled"; - const imgCount = details?.imageCount ?? (details?.hasImage ? 1 : 0); - const imageBadge = imgCount > 1 - ? theme.fg("accent", ` [${imgCount} images]`) - : imgCount === 1 - ? theme.fg("accent", " [image]") - : ""; - let statusLine = theme.fg("success", title) + theme.fg("muted", ` (${details?.totalChars ?? 0} chars)`) + imageBadge; - if (details?.truncated) { - statusLine += theme.fg("warning", " [truncated]"); - } - if (typeof details?.duration === "number") { - statusLine += theme.fg("muted", ` | ${formatSeconds(Math.floor(details.duration))} total`); - } - const textContent = result.content.find((c) => c.type === "text")?.text || ""; - if (!expanded) { - const brief = textContent.length > 200 ? textContent.slice(0, 200) + "..." : textContent; - return new Text(statusLine + "\n" + theme.fg("dim", brief), 0, 0); - } - const lines = [statusLine]; - if (details?.prompt) { - const display = details.prompt.length > 250 ? details.prompt.slice(0, 247) + "..." : details.prompt; - lines.push(theme.fg("dim", ` prompt: "${display}"`)); - } - if (details?.timestamp) { - lines.push(theme.fg("dim", ` timestamp: ${details.timestamp}`)); - } - if (typeof details?.frames === "number") { - lines.push(theme.fg("dim", ` frames: ${details.frames}`)); - } - const preview = textContent.length > 500 ? textContent.slice(0, 500) + "..." : textContent; - lines.push(theme.fg("dim", preview)); - return new Text(lines.join("\n"), 0, 0); - } - - const countColor = (details?.successful ?? 0) > 0 ? "success" : "error"; - const statusLine = theme.fg(countColor, `${details?.successful}/${details?.urlCount} URLs`) + theme.fg("muted", " (content stored)"); - if (!expanded) { - return new Text(statusLine, 0, 0); - } - const textContent = result.content.find((c) => c.type === "text")?.text || ""; - const preview = textContent.length > 500 ? textContent.slice(0, 500) + "..." : textContent; - return new Text(statusLine + "\n" + theme.fg("dim", preview), 0, 0); - }, - }); - - pi.registerTool({ - name: "get_search_content", - label: "Get Search Content", - description: "Retrieve full content from a previous web_search or fetch_content call.", - parameters: Type.Object({ - responseId: Type.String({ description: "The responseId from web_search or fetch_content" }), - query: Type.Optional(Type.String({ description: "Get content for this query (web_search)" })), - queryIndex: Type.Optional(Type.Number({ description: "Get content for query at index" })), - url: Type.Optional(Type.String({ description: "Get content for this URL" })), - urlIndex: Type.Optional(Type.Number({ description: "Get content for URL at index" })), - }), - - async execute(_toolCallId, params) { - const data = getResult(params.responseId); - if (!data) { - return { - content: [{ type: "text", text: `Error: No stored results for "${params.responseId}"` }], - details: { error: "Not found", responseId: params.responseId }, - }; - } - - if (data.type === "search" && data.queries) { - let queryData: QueryResultData | undefined; - - if (params.query !== undefined) { - queryData = data.queries.find((q) => q.query === params.query); - if (!queryData) { - const available = data.queries.map((q) => `"${q.query}"`).join(", "); - return { - content: [{ type: "text", text: `Query "${params.query}" not found. Available: ${available}` }], - details: { error: "Query not found" }, - }; - } - } else if (params.queryIndex !== undefined) { - queryData = data.queries[params.queryIndex]; - if (!queryData) { - return { - content: [{ type: "text", text: `Index ${params.queryIndex} out of range (0-${data.queries.length - 1})` }], - details: { error: "Index out of range" }, - }; - } - } else { - const available = data.queries.map((q, i) => `${i}: "${q.query}"`).join(", "); - return { - content: [{ type: "text", text: `Specify query or queryIndex. Available: ${available}` }], - details: { error: "No query specified" }, - }; - } - - if (queryData.error) { - return { - content: [{ type: "text", text: `Error for "${queryData.query}": ${queryData.error}` }], - details: { error: queryData.error, query: queryData.query }, - }; - } - - return { - content: [{ type: "text", text: formatFullResults(queryData) }], - details: { query: queryData.query, resultCount: queryData.results.length }, - }; - } - - if (data.type === "fetch" && data.urls) { - let urlData: ExtractedContent | undefined; - - if (params.url !== undefined) { - urlData = data.urls.find((u) => u.url === params.url); - if (!urlData) { - const available = data.urls.map((u) => u.url).join("\n "); - return { - content: [{ type: "text", text: `URL not found. Available:\n ${available}` }], - details: { error: "URL not found" }, - }; - } - } else if (params.urlIndex !== undefined) { - urlData = data.urls[params.urlIndex]; - if (!urlData) { - return { - content: [{ type: "text", text: `Index ${params.urlIndex} out of range (0-${data.urls.length - 1})` }], - details: { error: "Index out of range" }, - }; - } - } else { - const available = data.urls.map((u, i) => `${i}: ${u.url}`).join("\n "); - return { - content: [{ type: "text", text: `Specify url or urlIndex. Available:\n ${available}` }], - details: { error: "No URL specified" }, - }; - } - - if (urlData.error) { - return { - content: [{ type: "text", text: `Error for ${urlData.url}: ${urlData.error}` }], - details: { error: urlData.error, url: urlData.url }, - }; - } - - return { - content: [{ type: "text", text: `# ${urlData.title}\n\n${urlData.content}` }], - details: { url: urlData.url, title: urlData.title, contentLength: urlData.content.length }, - }; - } - - return { - content: [{ type: "text", text: "Invalid stored data format" }], - details: { error: "Invalid data" }, - }; - }, - - renderCall(args, theme) { - const { responseId, query, queryIndex, url, urlIndex } = args as { - responseId: string; - query?: string; - queryIndex?: number; - url?: string; - urlIndex?: number; - }; - let target = ""; - if (query) target = `query="${query}"`; - else if (queryIndex !== undefined) target = `queryIndex=${queryIndex}`; - else if (url) target = url.length > 30 ? url.slice(0, 27) + "..." : url; - else if (urlIndex !== undefined) target = `urlIndex=${urlIndex}`; - return new Text(theme.fg("toolTitle", theme.bold("get_content ")) + theme.fg("accent", target || responseId.slice(0, 8)), 0, 0); - }, - - renderResult(result, { expanded }, theme) { - const details = result.details as { - error?: string; - query?: string; - url?: string; - title?: string; - resultCount?: number; - contentLength?: number; - }; - - if (details?.error) { - return new Text(theme.fg("error", `Error: ${details.error}`), 0, 0); - } - - let statusLine: string; - if (details?.query) { - statusLine = theme.fg("success", `"${details.query}"`) + theme.fg("muted", ` (${details.resultCount} results)`); - } else { - statusLine = theme.fg("success", details?.title || "Content") + theme.fg("muted", ` (${details?.contentLength ?? 0} chars)`); - } - - if (!expanded) { - return new Text(statusLine, 0, 0); - } - - const textContent = result.content.find((c) => c.type === "text")?.text || ""; - const preview = textContent.length > 500 ? textContent.slice(0, 500) + "..." : textContent; - return new Text(statusLine + "\n" + theme.fg("dim", preview), 0, 0); - }, - }); - - pi.registerCommand("websearch", { - description: "Open web search curator in the browser", - handler: async (args, ctx) => { - closeCurator(); - const sessionToken = randomUUID(); - const queries = args.trim() ? args.trim().split(/\s*,\s*/) : []; - - const pplxAvail = isPerplexityAvailable(); - const exaAvail = isExaAvailable(); - const geminiApiAvail = isGeminiApiAvailable(); - const geminiWebAvail = await isGeminiWebAvailable(); - const availableProviders = { - perplexity: pplxAvail, - exa: exaAvail, - gemini: geminiApiAvail || !!geminiWebAvail, - }; - const defaultProvider = resolveProvider(undefined, availableProviders); - - ctx.ui.notify("Opening web search curator...", "info"); - - const collected = new Map(); - const searchAbort = new AbortController(); - let aborted = false; - - function sendResults(selectedQueryIndices?: number[]) { - const results = selectedQueryIndices - ? selectedQueryIndices.map(qi => collected.get(qi)).filter((r): r is QueryResultData => !!r) - : [...collected.values()]; - if (results.length === 0) return; - const urls: string[] = []; - let text = ""; - for (const q of results) { - text += `## Query: "${q.query}"\n\n${q.answer}\n\n`; - if (q.results.length > 0) { - text += "**Sources:**\n"; - for (const r of q.results) { - text += `- ${r.title}: ${r.url}\n`; - if (!urls.includes(r.url)) urls.push(r.url); - } - text += "\n"; - } - } - pi.sendMessage({ - customType: "web-search-results", - content: [{ type: "text", text }], - display: "tool", - details: { queryCount: results.length, totalResults: urls.length }, - }, { triggerTurn: true, deliverAs: "followUp" }); - } - - try { - const handle = await startCuratorServer( - { queries, sessionToken, timeout: 120, availableProviders, defaultProvider }, - { - onSubmit(selectedQueryIndices) { - aborted = true; - searchAbort.abort(); - sendResults(selectedQueryIndices); - closeCurator(); - }, - onCancel(reason) { - aborted = true; - searchAbort.abort(); - if (reason === "timeout") sendResults(); - closeCurator(); - }, - onProviderChange(provider) { saveConfig({ provider }); }, - async onAddSearch(query, queryIndex) { - const { answer, results } = await search(query, { - provider: defaultProvider as SearchProvider | undefined, - signal: searchAbort.signal, - }); - collected.set(queryIndex, { query, answer, results, error: null }); - return { - answer, - results: results.map(r => ({ title: r.title, url: r.url, domain: extractDomain(r.url) })), - }; - }, - }, - ); - - activeCurator = handle; - await openInBrowser(pi, handle.url); - - if (queries.length > 0) { - (async () => { - for (let qi = 0; qi < queries.length; qi++) { - if (aborted) break; - try { - const { answer, results } = await search(queries[qi], { - provider: defaultProvider as SearchProvider | undefined, - signal: searchAbort.signal, - }); - if (aborted) break; - handle.pushResult(qi, { - answer, - results: results.map(r => ({ title: r.title, url: r.url, domain: extractDomain(r.url) })), - }); - collected.set(qi, { query: queries[qi], answer, results, error: null }); - } catch (err) { - if (aborted) break; - const message = err instanceof Error ? err.message : String(err); - handle.pushError(qi, message); - collected.set(qi, { query: queries[qi], answer: "", results: [], error: message }); - } - } - if (!aborted) handle.searchesDone(); - })(); - } else { - handle.searchesDone(); - } - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - ctx.ui.notify(`Failed to open curator: ${message}`, "error"); - } - }, - }); - - pi.registerCommand("google-account", { - description: "Show the active Google account for Gemini Web", - handler: async () => { - const cookies = await isGeminiWebAvailable(); - if (!cookies) { - pi.sendMessage({ - customType: "google-account", - content: [{ type: "text", text: "Gemini Web is unavailable. Sign into gemini.google.com in a supported Chromium-based browser." }], - display: "tool", - details: { available: false }, - }, { triggerTurn: true, deliverAs: "followUp" }); - return; - } - - const email = await getActiveGoogleEmail(cookies); - const text = email - ? `Active Google account: ${email}` - : "Gemini Web is available, but the active Google account could not be determined."; - - pi.sendMessage({ - customType: "google-account", - content: [{ type: "text", text }], - display: "tool", - details: { available: true, email: email ?? null }, - }, { triggerTurn: true, deliverAs: "followUp" }); - }, - }); - - pi.registerCommand("web-results", { - description: "Browse stored web search results", - handler: async (_args, ctx) => { - const results = getAllResults(); - - if (results.length === 0) { - ctx.ui.notify("No stored search results", "info"); - return; - } - - const options = results.map((r) => { - const age = Math.floor((Date.now() - r.timestamp) / 60000); - const ageStr = age < 60 ? `${age}m ago` : `${Math.floor(age / 60)}h ago`; - if (r.type === "search" && r.queries) { - const query = r.queries[0]?.query || "unknown"; - return `[${r.id.slice(0, 6)}] "${query}" (${r.queries.length} queries) - ${ageStr}`; - } - if (r.type === "fetch" && r.urls) { - return `[${r.id.slice(0, 6)}] ${r.urls.length} URLs fetched - ${ageStr}`; - } - return `[${r.id.slice(0, 6)}] ${r.type} - ${ageStr}`; - }); - - const choice = await ctx.ui.select("Stored Search Results", options); - if (!choice) return; - - const match = choice.match(/^\[([a-z0-9]+)\]/); - if (!match) return; - - const selected = results.find((r) => r.id.startsWith(match[1])); - if (!selected) return; - - const actions = ["View details", "Delete"]; - const action = await ctx.ui.select(`Result ${selected.id.slice(0, 6)}`, actions); - - if (action === "Delete") { - deleteResult(selected.id); - ctx.ui.notify(`Deleted ${selected.id.slice(0, 6)}`, "info"); - } else if (action === "View details") { - let info = `ID: ${selected.id}\nType: ${selected.type}\nAge: ${Math.floor((Date.now() - selected.timestamp) / 60000)}m\n\n`; - if (selected.type === "search" && selected.queries) { - info += "Queries:\n"; - const queries = selected.queries.slice(0, 10); - for (const q of queries) { - info += `- "${q.query}" (${q.results.length} results)\n`; - } - if (selected.queries.length > 10) { - info += `... and ${selected.queries.length - 10} more\n`; - } - } - if (selected.type === "fetch" && selected.urls) { - info += "URLs:\n"; - const urls = selected.urls.slice(0, 10); - for (const u of urls) { - const urlDisplay = u.url.length > 50 ? u.url.slice(0, 47) + "..." : u.url; - info += `- ${urlDisplay} (${u.error || `${u.content.length} chars`})\n`; - } - if (selected.urls.length > 10) { - info += `... and ${selected.urls.length - 10} more\n`; - } - } - ctx.ui.notify(info, "info"); - } - }, - }); -} diff --git a/CHANGELOG.md b/CHANGELOG.md index 1831ff6..18a840b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -149,3 +149,12 @@ Use this file to track chronology, not release notes. Keep entries short, factua - Failed / learned: The MiniMax provider catalog in Pi already uses canonical IDs like `MiniMax-M2.7`, so the only failure during validation was a test assertion using the wrong casing rather than a runtime bug. - Blockers: The Cloud Code Assist fix is validated by targeted patch tests and code-path review rather than an end-to-end Google account repro in this environment. - Next: Push the tracker-triage commit, close the docs/MiniMax PRs as superseded by main, close the support-style model issues against the new docs, and decide whether the remaining feature requests should be left open or closed as not planned/upstream-dependent. + +### 2026-04-10 10:22 PDT — web-access-stale-override-fix + +- Objective: Fix the new `ctx.modelRegistry.getApiKeyAndHeaders is not a function` / stale `search-filter.js` report without reintroducing broad vendor drift. +- Changed: Removed the stale `.feynman/vendor-overrides/pi-web-access/*` files and removed `syncVendorOverride` from `scripts/patch-embedded-pi.mjs`; kept the targeted `pi-web-access` runtime config-path patch; added `feynman search set [api-key]` and `feynman search clear` commands with a shared save path in `src/pi/web-access.ts`. +- Verified: Ran `npm test`, `npm run typecheck`, `npm run build`; ran `node scripts/patch-embedded-pi.mjs`, confirmed the installed `pi-web-access/index.ts` has no `search-filter` / condense helper references, and smoke-imported `./.feynman/npm/node_modules/pi-web-access/index.ts`; ran `npm pack --dry-run` and confirmed stale `vendor-overrides` files are no longer in the package tarball. +- Failed / learned: The public Linux installer Docker test was attempted but Docker Desktop became unresponsive even for simple `docker run node:22-bookworm node -v` commands; the earlier Linux npm-artifact container smoke remains valid, but this specific public-installer run is blocked by the local Docker daemon. +- Blockers: Issue `#54` is too underspecified to fix directly without logs; public Linux installer behavior still needs a stable Docker daemon or a real Linux shell to reproduce the user's exact npm errors. +- Next: Push the stale-override fix, close PR `#52` and PR `#53` as superseded/merged-by-main once pushed, and ask for logs on issue `#54` instead of guessing. diff --git a/metadata/commands.mjs b/metadata/commands.mjs index 06128c6..9ec7e14 100644 --- a/metadata/commands.mjs +++ b/metadata/commands.mjs @@ -106,6 +106,8 @@ export const cliCommandSections = [ { usage: "feynman packages list", description: "Show core and optional Pi package presets." }, { usage: "feynman packages install ", description: "Install optional package presets on demand." }, { usage: "feynman search status", description: "Show Pi web-access status and config path." }, + { usage: "feynman search set [api-key]", description: "Set the web search provider and optionally save its API key." }, + { usage: "feynman search clear", description: "Reset web search provider to auto while preserving API keys." }, { usage: "feynman update [package]", description: "Update installed packages, or a specific package." }, ], }, diff --git a/package.json b/package.json index 6693e77..5550852 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,6 @@ ".feynman/settings.json", ".feynman/SYSTEM.md", ".feynman/themes/", - ".feynman/vendor-overrides/", "extensions/", "prompts/", "logo.mjs", diff --git a/scripts/patch-embedded-pi.mjs b/scripts/patch-embedded-pi.mjs index ac83c28..2b18c2e 100644 --- a/scripts/patch-embedded-pi.mjs +++ b/scripts/patch-embedded-pi.mjs @@ -74,7 +74,6 @@ const workspaceExtensionLoaderPath = resolve( "extensions", "loader.js", ); -const vendorOverrideRoot = resolve(appRoot, ".feynman", "vendor-overrides"); const piSubagentsRoot = resolve(workspaceRoot, "pi-subagents"); const webAccessPath = resolve(workspaceRoot, "pi-web-access", "index.ts"); const sessionSearchIndexerPath = resolve( @@ -211,18 +210,6 @@ function resolveExecutable(name, fallbackPaths = []) { return null; } -function syncVendorOverride(relativePath) { - const sourcePath = resolve(vendorOverrideRoot, relativePath); - const targetPath = resolve(workspaceRoot, relativePath); - if (!existsSync(sourcePath) || !existsSync(targetPath)) return; - - const source = readFileSync(sourcePath, "utf8"); - const current = readFileSync(targetPath, "utf8"); - if (source !== current) { - writeFileSync(targetPath, source, "utf8"); - } -} - function ensurePackageWorkspace() { if (!existsSync(settingsPath)) return; @@ -571,16 +558,6 @@ if (editorPath && existsSync(editorPath)) { } if (existsSync(webAccessPath)) { - for (const relativePath of [ - "pi-web-access/index.ts", - "pi-web-access/gemini-search.ts", - "pi-web-access/curator-page.ts", - "pi-web-access/curator-server.ts", - "pi-web-access/exa.ts", - ]) { - syncVendorOverride(relativePath); - } - const source = readFileSync(webAccessPath, "utf8"); if (source.includes('pi.registerCommand("search",')) { writeFileSync( diff --git a/src/cli.ts b/src/cli.ts index ee03958..0b45a04 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -28,7 +28,8 @@ import { printModelList, setDefaultModelSpec, } from "./model/commands.js"; -import { printSearchStatus } from "./search/commands.js"; +import { clearSearchConfig, printSearchStatus, setSearchProvider } from "./search/commands.js"; +import type { PiWebSearchProvider } from "./pi/web-access.js"; import { runDoctor, runStatus } from "./setup/doctor.js"; import { setupPreviewDependencies } from "./setup/preview.js"; import { runSetup } from "./setup/setup.js"; @@ -269,12 +270,27 @@ async function handlePackagesCommand(subcommand: string | undefined, args: strin console.log("Optional packages installed."); } -function handleSearchCommand(subcommand: string | undefined): void { +function handleSearchCommand(subcommand: string | undefined, args: string[]): void { if (!subcommand || subcommand === "status") { printSearchStatus(); return; } + if (subcommand === "set") { + const provider = args[0] as PiWebSearchProvider | undefined; + const validProviders: PiWebSearchProvider[] = ["auto", "perplexity", "exa", "gemini"]; + if (!provider || !validProviders.includes(provider)) { + throw new Error("Usage: feynman search set [api-key]"); + } + setSearchProvider(provider, args[1]); + return; + } + + if (subcommand === "clear") { + clearSearchConfig(); + return; + } + throw new Error(`Unknown search command: ${subcommand}`); } @@ -442,7 +458,7 @@ export async function main(): Promise { } if (command === "search") { - handleSearchCommand(rest[0]); + handleSearchCommand(rest[0], rest.slice(1)); return; } diff --git a/src/pi/web-access.ts b/src/pi/web-access.ts index 0dca2e8..b9e9937 100644 --- a/src/pi/web-access.ts +++ b/src/pi/web-access.ts @@ -1,6 +1,5 @@ -import { existsSync, readFileSync } from "node:fs"; -import { homedir } from "node:os"; -import { resolve } from "node:path"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; import { getFeynmanHome } from "../config/paths.js"; export type PiWebSearchProvider = "auto" | "perplexity" | "exa" | "gemini"; @@ -53,6 +52,23 @@ export function loadPiWebAccessConfig(configPath = getPiWebSearchConfigPath()): } } +export function savePiWebAccessConfig( + updates: Partial>, + configPath = getPiWebSearchConfigPath(), +): void { + const merged: Record = { ...loadPiWebAccessConfig(configPath) }; + for (const [key, value] of Object.entries(updates)) { + if (value === undefined) { + delete merged[key]; + } else { + merged[key] = value; + } + } + + mkdirSync(dirname(configPath), { recursive: true }); + writeFileSync(configPath, JSON.stringify(merged, null, 2) + "\n", "utf8"); +} + function formatRouteLabel(provider: PiWebSearchProvider): string { switch (provider) { case "perplexity": diff --git a/src/search/commands.ts b/src/search/commands.ts index 1b446c2..494cc88 100644 --- a/src/search/commands.ts +++ b/src/search/commands.ts @@ -1,6 +1,18 @@ -import { getPiWebAccessStatus } from "../pi/web-access.js"; +import { + getPiWebAccessStatus, + savePiWebAccessConfig, + type PiWebAccessConfig, + type PiWebSearchProvider, +} from "../pi/web-access.js"; import { printInfo } from "../ui/terminal.js"; +const SEARCH_PROVIDERS: PiWebSearchProvider[] = ["auto", "perplexity", "exa", "gemini"]; +const PROVIDER_API_KEY_FIELDS: Partial> = { + perplexity: "perplexityApiKey", + exa: "exaApiKey", + gemini: "geminiApiKey", +}; + export function printSearchStatus(): void { const status = getPiWebAccessStatus(); printInfo("Managed by: pi-web-access"); @@ -12,3 +24,35 @@ export function printSearchStatus(): void { printInfo(`Browser profile: ${status.chromeProfile ?? "default Chromium profile"}`); printInfo(`Config path: ${status.configPath}`); } + +export function setSearchProvider(provider: PiWebSearchProvider, apiKey?: string): void { + if (!SEARCH_PROVIDERS.includes(provider)) { + throw new Error(`Usage: feynman search set <${SEARCH_PROVIDERS.join("|")}> [api-key]`); + } + if (apiKey !== undefined && provider === "auto") { + throw new Error("The auto provider does not use an API key. Usage: feynman search set auto"); + } + + const updates: Partial> = { + provider, + searchProvider: provider, + route: undefined, + }; + const apiKeyField = PROVIDER_API_KEY_FIELDS[provider]; + if (apiKeyField && apiKey !== undefined) { + updates[apiKeyField] = apiKey; + } + savePiWebAccessConfig(updates); + + const status = getPiWebAccessStatus(); + console.log(`Web search provider set to ${status.routeLabel}.`); + console.log(`Config path: ${status.configPath}`); +} + +export function clearSearchConfig(): void { + savePiWebAccessConfig({ provider: undefined, searchProvider: undefined, route: undefined }); + + const status = getPiWebAccessStatus(); + console.log(`Web search provider reset to ${status.routeLabel}.`); + console.log(`Config path: ${status.configPath}`); +} diff --git a/tests/pi-web-access.test.ts b/tests/pi-web-access.test.ts index 98e8ee2..7436002 100644 --- a/tests/pi-web-access.test.ts +++ b/tests/pi-web-access.test.ts @@ -9,6 +9,7 @@ import { getPiWebAccessStatus, getPiWebSearchConfigPath, loadPiWebAccessConfig, + savePiWebAccessConfig, } from "../src/pi/web-access.js"; test("loadPiWebAccessConfig returns empty config when Pi web config is missing", () => { @@ -22,6 +23,26 @@ test("getPiWebSearchConfigPath respects FEYNMAN_HOME semantics", () => { assert.equal(getPiWebSearchConfigPath("/tmp/custom-home"), "/tmp/custom-home/.feynman/web-search.json"); }); +test("savePiWebAccessConfig merges updates and deletes undefined values", () => { + const root = mkdtempSync(join(tmpdir(), "feynman-pi-web-")); + const configPath = getPiWebSearchConfigPath(root); + + savePiWebAccessConfig({ + provider: "perplexity", + searchProvider: "perplexity", + perplexityApiKey: "pplx_...", + }, configPath); + savePiWebAccessConfig({ + provider: undefined, + searchProvider: undefined, + route: undefined, + }, configPath); + + assert.deepEqual(loadPiWebAccessConfig(configPath), { + perplexityApiKey: "pplx_...", + }); +}); + test("getPiWebAccessStatus reads Pi web-access config directly", () => { const root = mkdtempSync(join(tmpdir(), "feynman-pi-web-")); const configPath = getPiWebSearchConfigPath(root);