From 554350cc0e7b3b26c5e5fea9581dc1d0de27f7a7 Mon Sep 17 00:00:00 2001 From: Advait Paliwal Date: Tue, 31 Mar 2026 11:02:07 -0700 Subject: [PATCH] Finish backlog cleanup for Pi integration --- .../pi-web-access/curator-page.ts | 1210 ++++++++++++ .../pi-web-access/curator-server.ts | 325 ++++ .../vendor-overrides/pi-web-access/exa.ts | 147 ++ .../pi-web-access/gemini-search.ts | 256 +++ .../vendor-overrides/pi-web-access/index.ts | 1658 +++++++++++++++++ CHANGELOG.md | 9 + extensions/research-tools.ts | 4 + extensions/research-tools/discovery.ts | 130 ++ extensions/research-tools/service-tier.ts | 174 ++ metadata/commands.mjs | 7 + package.json | 1 + scripts/lib/pi-extension-loader-patch.d.mts | 1 + scripts/lib/pi-extension-loader-patch.mjs | 32 + scripts/patch-embedded-pi.mjs | 33 + src/cli.ts | 32 + src/model/service-tier.ts | 65 + src/pi/web-access.ts | 15 +- src/search/commands.ts | 1 + src/setup/doctor.ts | 3 + tests/pi-extension-loader-patch.test.ts | 42 + tests/pi-web-access.test.ts | 26 + tests/service-tier.test.ts | 41 + 22 files changed, 4209 insertions(+), 3 deletions(-) create mode 100644 .feynman/vendor-overrides/pi-web-access/curator-page.ts create mode 100644 .feynman/vendor-overrides/pi-web-access/curator-server.ts create mode 100644 .feynman/vendor-overrides/pi-web-access/exa.ts create mode 100644 .feynman/vendor-overrides/pi-web-access/gemini-search.ts create mode 100644 .feynman/vendor-overrides/pi-web-access/index.ts create mode 100644 extensions/research-tools/discovery.ts create mode 100644 extensions/research-tools/service-tier.ts create mode 100644 scripts/lib/pi-extension-loader-patch.d.mts create mode 100644 scripts/lib/pi-extension-loader-patch.mjs create mode 100644 src/model/service-tier.ts create mode 100644 tests/pi-extension-loader-patch.test.ts create mode 100644 tests/service-tier.test.ts diff --git a/.feynman/vendor-overrides/pi-web-access/curator-page.ts b/.feynman/vendor-overrides/pi-web-access/curator-page.ts new file mode 100644 index 0000000..7c17116 --- /dev/null +++ b/.feynman/vendor-overrides/pi-web-access/curator-page.ts @@ -0,0 +1,1210 @@ +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 new file mode 100644 index 0000000..725f51a --- /dev/null +++ b/.feynman/vendor-overrides/pi-web-access/curator-server.ts @@ -0,0 +1,325 @@ +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 new file mode 100644 index 0000000..d9fb870 --- /dev/null +++ b/.feynman/vendor-overrides/pi-web-access/exa.ts @@ -0,0 +1,147 @@ +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 new file mode 100644 index 0000000..08e76f8 --- /dev/null +++ b/.feynman/vendor-overrides/pi-web-access/gemini-search.ts @@ -0,0 +1,256 @@ +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 new file mode 100644 index 0000000..81191ed --- /dev/null +++ b/.feynman/vendor-overrides/pi-web-access/index.ts @@ -0,0 +1,1658 @@ +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 66e618f..baadef1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -104,3 +104,12 @@ Use this file to track chronology, not release notes. Keep entries short, factua - Failed / learned: PR #14 is a stale branch with no clean merge path against current `main`; the only user-facing delta is the ValiChord prompt/skill addition, and the branch also carries unrelated release churn plus demo-style material, so it was not merged in this pass. - Blockers: None in the local repo state; remote merge/push still depends on repository credentials and branch policy. - Next: If remote write access is available, commit and push the validated maintenance changes, then close issue #22 and resolve PR #15 as merged while leaving PR #14 unmerged pending a cleaned-up, non-promotional resubmission. + +### 2026-03-31 12:05 PDT — pi-backlog-cleanup-round-2 + +- Objective: Finish the remaining high-confidence open tracker items after the Pi 0.64.0 upgrade instead of leaving the issue list half-reconciled. +- Changed: Added a Windows extension-loader patch helper so Feynman rewrites Pi extension imports to `file://` URLs on Windows before interactive startup; added `/commands`, `/tools`, and `/capabilities` discovery commands and surfaced `/hotkeys` plus `/service-tier` in help metadata; added explicit service-tier support via `feynman model tier`, `--service-tier`, status/doctor output, and a provider-payload hook that passes `service_tier` only to supported OpenAI/OpenAI Codex/Anthropic models; added Exa provider recognition to Feynman's web-search status layer and vendored `pi-web-access`. +- Verified: Ran `npm test`, `npm run typecheck`, and `npm run build`; smoke-imported the modified vendored `pi-web-access` modules with `node --import tsx`. +- Failed / learned: The remaining ValiChord PR is still stale and mixes a real prompt/skill update with unrelated branch churn; it is a review/triage item, not a clean merge candidate. +- Blockers: No local build blockers remain; issue/PR closure still depends on the final push landing on `main`. +- Next: Push the verified cleanup commit, then close issues fixed by the dependency bump plus the new discoverability/service-tier/Windows patches, and close the stale ValiChord PR explicitly instead of leaving it open indefinitely. diff --git a/extensions/research-tools.ts b/extensions/research-tools.ts index 6e3553c..39e39d5 100644 --- a/extensions/research-tools.ts +++ b/extensions/research-tools.ts @@ -1,10 +1,12 @@ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import { registerAlphaTools } from "./research-tools/alpha.js"; +import { registerDiscoveryCommands } from "./research-tools/discovery.js"; import { registerFeynmanModelCommand } from "./research-tools/feynman-model.js"; import { installFeynmanHeader } from "./research-tools/header.js"; import { registerHelpCommand } from "./research-tools/help.js"; import { registerInitCommand, registerOutputsCommand } from "./research-tools/project.js"; +import { registerServiceTierControls } from "./research-tools/service-tier.js"; export default function researchTools(pi: ExtensionAPI): void { const cache: { agentSummaryPromise?: Promise<{ agents: string[]; chains: string[] }> } = {}; @@ -18,8 +20,10 @@ export default function researchTools(pi: ExtensionAPI): void { }); registerAlphaTools(pi); + registerDiscoveryCommands(pi); registerFeynmanModelCommand(pi); registerHelpCommand(pi); registerInitCommand(pi); registerOutputsCommand(pi); + registerServiceTierControls(pi); } diff --git a/extensions/research-tools/discovery.ts b/extensions/research-tools/discovery.ts new file mode 100644 index 0000000..47cabd5 --- /dev/null +++ b/extensions/research-tools/discovery.ts @@ -0,0 +1,130 @@ +import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { resolve } from "node:path"; + +import type { ExtensionAPI, SlashCommandInfo, ToolInfo } from "@mariozechner/pi-coding-agent"; + +function resolveFeynmanSettingsPath(): string { + const configured = process.env.PI_CODING_AGENT_DIR?.trim(); + const agentDir = configured + ? configured.startsWith("~/") + ? resolve(homedir(), configured.slice(2)) + : resolve(configured) + : resolve(homedir(), ".feynman", "agent"); + return resolve(agentDir, "settings.json"); +} + +function readConfiguredPackages(): string[] { + const settingsPath = resolveFeynmanSettingsPath(); + if (!existsSync(settingsPath)) return []; + + try { + const parsed = JSON.parse(readFileSync(settingsPath, "utf8")) as { packages?: unknown[] }; + return Array.isArray(parsed.packages) + ? parsed.packages + .map((entry) => { + if (typeof entry === "string") return entry; + if (!entry || typeof entry !== "object") return undefined; + const record = entry as { source?: unknown }; + return typeof record.source === "string" ? record.source : undefined; + }) + .filter((entry): entry is string => Boolean(entry)) + : []; + } catch { + return []; + } +} + +function formatSourceLabel(sourceInfo: { source: string; path: string }): string { + if (sourceInfo.source === "local") { + if (sourceInfo.path.includes("/prompts/")) return "workflow"; + if (sourceInfo.path.includes("/extensions/")) return "extension"; + return "local"; + } + return sourceInfo.source.replace(/^npm:/, "").replace(/^git:/, ""); +} + +function formatCommandLine(command: SlashCommandInfo): string { + const source = formatSourceLabel(command.sourceInfo); + return `/${command.name} — ${command.description ?? ""} [${source}]`; +} + +function summarizeToolParameters(tool: ToolInfo): string { + const properties = + tool.parameters && + typeof tool.parameters === "object" && + "properties" in tool.parameters && + tool.parameters.properties && + typeof tool.parameters.properties === "object" + ? Object.keys(tool.parameters.properties as Record) + : []; + return properties.length > 0 ? properties.join(", ") : "no parameters"; +} + +function formatToolLine(tool: ToolInfo): string { + const source = formatSourceLabel(tool.sourceInfo); + return `${tool.name} — ${tool.description ?? ""} [${source}]`; +} + +export function registerDiscoveryCommands(pi: ExtensionAPI): void { + pi.registerCommand("commands", { + description: "Browse all available slash commands, including package and built-in commands.", + handler: async (_args, ctx) => { + const commands = pi + .getCommands() + .slice() + .sort((left, right) => left.name.localeCompare(right.name)); + const items = commands.map((command) => formatCommandLine(command)); + const selected = await ctx.ui.select("Slash Commands", items); + if (!selected) return; + ctx.ui.setEditorText(selected.split(" — ")[0] ?? ""); + ctx.ui.notify(`Prefilled ${selected.split(" — ")[0]}`, "info"); + }, + }); + + pi.registerCommand("tools", { + description: "Browse all callable tools with their source and parameter summary.", + handler: async (_args, ctx) => { + const tools = pi + .getAllTools() + .slice() + .sort((left, right) => left.name.localeCompare(right.name)); + const selected = await ctx.ui.select("Tools", tools.map((tool) => formatToolLine(tool))); + if (!selected) return; + + const toolName = selected.split(" — ")[0] ?? selected; + const tool = tools.find((entry) => entry.name === toolName); + if (!tool) return; + ctx.ui.notify(`${tool.name}: ${summarizeToolParameters(tool)}`, "info"); + }, + }); + + pi.registerCommand("capabilities", { + description: "Show installed packages, discovery entrypoints, and high-level runtime capability counts.", + handler: async (_args, ctx) => { + const commands = pi.getCommands(); + const tools = pi.getAllTools(); + const workflows = commands.filter((command) => formatSourceLabel(command.sourceInfo) === "workflow"); + const packages = readConfiguredPackages(); + const items = [ + `Commands: ${commands.length}`, + `Workflows: ${workflows.length}`, + `Tools: ${tools.length}`, + `Packages: ${packages.length}`, + "--- Discovery ---", + "/commands — browse slash commands", + "/tools — inspect callable tools", + "/hotkeys — view keyboard shortcuts", + "/service-tier — set request tier for supported providers", + "--- Installed Packages ---", + ...packages.map((pkg) => pkg), + ]; + const selected = await ctx.ui.select("Capabilities", items); + if (!selected || selected.startsWith("---")) return; + if (selected.startsWith("/")) { + ctx.ui.setEditorText(selected.split(" — ")[0] ?? selected); + ctx.ui.notify(`Prefilled ${selected.split(" — ")[0]}`, "info"); + } + }, + }); +} diff --git a/extensions/research-tools/service-tier.ts b/extensions/research-tools/service-tier.ts new file mode 100644 index 0000000..804cf91 --- /dev/null +++ b/extensions/research-tools/service-tier.ts @@ -0,0 +1,174 @@ +import { homedir } from "node:os"; +import { readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; + +const FEYNMAN_SERVICE_TIERS = [ + "auto", + "default", + "flex", + "priority", + "standard_only", +] as const; + +type FeynmanServiceTier = (typeof FEYNMAN_SERVICE_TIERS)[number]; + +const SERVICE_TIER_SET = new Set(FEYNMAN_SERVICE_TIERS); +const OPENAI_SERVICE_TIERS = new Set(["auto", "default", "flex", "priority"]); +const ANTHROPIC_SERVICE_TIERS = new Set(["auto", "standard_only"]); + +type CommandContext = Parameters[1]["handler"]>[1]; + +type SelectOption = { + label: string; + value: T; +}; + +function resolveFeynmanSettingsPath(): string { + const configured = process.env.PI_CODING_AGENT_DIR?.trim(); + const agentDir = configured + ? configured.startsWith("~/") + ? resolve(homedir(), configured.slice(2)) + : resolve(configured) + : resolve(homedir(), ".feynman", "agent"); + return resolve(agentDir, "settings.json"); +} + +function normalizeServiceTier(value: string | undefined): FeynmanServiceTier | undefined { + if (!value) return undefined; + const normalized = value.trim().toLowerCase(); + return SERVICE_TIER_SET.has(normalized) ? (normalized as FeynmanServiceTier) : undefined; +} + +function getConfiguredServiceTier(settingsPath: string): FeynmanServiceTier | undefined { + try { + const parsed = JSON.parse(readFileSync(settingsPath, "utf8")) as { serviceTier?: string }; + return normalizeServiceTier(parsed.serviceTier); + } catch { + return undefined; + } +} + +function setConfiguredServiceTier(settingsPath: string, tier: FeynmanServiceTier | undefined): void { + let settings: Record = {}; + try { + settings = JSON.parse(readFileSync(settingsPath, "utf8")) as Record; + } catch {} + + if (tier) { + settings.serviceTier = tier; + } else { + delete settings.serviceTier; + } + + writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf8"); +} + +function resolveActiveServiceTier(settingsPath: string): FeynmanServiceTier | undefined { + return normalizeServiceTier(process.env.FEYNMAN_SERVICE_TIER) ?? getConfiguredServiceTier(settingsPath); +} + +function resolveProviderServiceTier( + provider: string | undefined, + tier: FeynmanServiceTier | undefined, +): FeynmanServiceTier | undefined { + if (!provider || !tier) return undefined; + if ((provider === "openai" || provider === "openai-codex") && OPENAI_SERVICE_TIERS.has(tier)) { + return tier; + } + if (provider === "anthropic" && ANTHROPIC_SERVICE_TIERS.has(tier)) { + return tier; + } + return undefined; +} + +async function selectOption( + ctx: CommandContext, + title: string, + options: SelectOption[], +): Promise { + const selected = await ctx.ui.select( + title, + options.map((option) => option.label), + ); + if (!selected) return undefined; + return options.find((option) => option.label === selected)?.value; +} + +function parseRequestedTier(rawArgs: string): FeynmanServiceTier | null | undefined { + const trimmed = rawArgs.trim(); + if (!trimmed) return undefined; + if (trimmed === "unset" || trimmed === "clear" || trimmed === "off") return null; + return normalizeServiceTier(trimmed); +} + +export function registerServiceTierControls(pi: ExtensionAPI): void { + pi.on("before_provider_request", (event, ctx) => { + if (!ctx.model || !event.payload || typeof event.payload !== "object") { + return; + } + + const activeTier = resolveActiveServiceTier(resolveFeynmanSettingsPath()); + const providerTier = resolveProviderServiceTier(ctx.model.provider, activeTier); + if (!providerTier) { + return; + } + + return { + ...(event.payload as Record), + service_tier: providerTier, + }; + }); + + pi.registerCommand("service-tier", { + description: "View or set the provider service tier override used for supported models.", + handler: async (args, ctx) => { + const settingsPath = resolveFeynmanSettingsPath(); + const requested = parseRequestedTier(args); + + if (requested === undefined && !args.trim()) { + if (!ctx.hasUI) { + ctx.ui.notify(getConfiguredServiceTier(settingsPath) ?? "not set", "info"); + return; + } + + const current = getConfiguredServiceTier(settingsPath); + const selected = await selectOption( + ctx, + "Select service tier", + [ + { label: current ? `unset (current: ${current})` : "unset (current)", value: null }, + ...FEYNMAN_SERVICE_TIERS.map((tier) => ({ + label: tier === current ? `${tier} (current)` : tier, + value: tier, + })), + ], + ); + if (selected === undefined) return; + if (selected === null) { + setConfiguredServiceTier(settingsPath, undefined); + ctx.ui.notify("Cleared service tier override.", "info"); + return; + } + setConfiguredServiceTier(settingsPath, selected); + ctx.ui.notify(`Service tier set to ${selected}.`, "info"); + return; + } + + if (requested === null) { + setConfiguredServiceTier(settingsPath, undefined); + ctx.ui.notify("Cleared service tier override.", "info"); + return; + } + + if (!requested) { + ctx.ui.notify("Use auto, default, flex, priority, standard_only, or unset.", "error"); + return; + } + + setConfiguredServiceTier(settingsPath, requested); + ctx.ui.notify(`Service tier set to ${requested}.`, "info"); + }, + }); +} diff --git a/metadata/commands.mjs b/metadata/commands.mjs index bc92441..06128c6 100644 --- a/metadata/commands.mjs +++ b/metadata/commands.mjs @@ -35,10 +35,14 @@ export function readPromptSpecs(appRoot) { } export const extensionCommandSpecs = [ + { name: "capabilities", args: "", section: "Project & Session", description: "Show installed packages, discovery entrypoints, and runtime capability counts.", publicDocs: true }, + { name: "commands", args: "", section: "Project & Session", description: "Browse all available slash commands, including built-in and package commands.", publicDocs: true }, { name: "help", args: "", section: "Project & Session", description: "Show grouped Feynman commands and prefill the editor with a selected command.", publicDocs: true }, { name: "feynman-model", args: "", section: "Project & Session", description: "Open Feynman model menu (main + per-subagent overrides).", publicDocs: true }, { name: "init", args: "", section: "Project & Session", description: "Bootstrap AGENTS.md and session-log folders for a research project.", publicDocs: true }, { name: "outputs", args: "", section: "Project & Session", description: "Browse all research artifacts (papers, outputs, experiments, notes).", publicDocs: true }, + { name: "service-tier", args: "", section: "Project & Session", description: "View or set the provider service tier override for supported models.", publicDocs: true }, + { name: "tools", args: "", section: "Project & Session", description: "Browse all callable tools with their source and parameter summary.", publicDocs: true }, ]; export const livePackageCommandGroups = [ @@ -58,6 +62,7 @@ export const livePackageCommandGroups = [ { name: "schedule-prompt", usage: "/schedule-prompt" }, { name: "search", usage: "/search" }, { name: "preview", usage: "/preview" }, + { name: "hotkeys", usage: "/hotkeys" }, { name: "new", usage: "/new" }, { name: "quit", usage: "/quit" }, { name: "exit", usage: "/exit" }, @@ -84,6 +89,7 @@ export const cliCommandSections = [ { usage: "feynman model login [id]", description: "Login to a Pi OAuth model provider." }, { usage: "feynman model logout [id]", description: "Logout from a Pi OAuth model provider." }, { usage: "feynman model set ", description: "Set the default model." }, + { usage: "feynman model tier [value]", description: "View or set the request service tier override." }, ], }, { @@ -111,6 +117,7 @@ export const legacyFlags = [ { usage: "--alpha-logout", description: "Clear alphaXiv auth and exit." }, { usage: "--alpha-status", description: "Show alphaXiv auth status and exit." }, { usage: "--model ", description: "Force a specific model." }, + { usage: "--service-tier ", description: "Override request service tier for this run." }, { usage: "--thinking ", description: "Set thinking level: off | minimal | low | medium | high | xhigh." }, { usage: "--cwd ", description: "Set the working directory for tools." }, { usage: "--session-dir ", description: "Set the session storage directory." }, diff --git a/package.json b/package.json index 63ba846..e8eaaf7 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ ".feynman/settings.json", ".feynman/SYSTEM.md", ".feynman/themes/", + ".feynman/vendor-overrides/", "extensions/", "prompts/", "logo.mjs", diff --git a/scripts/lib/pi-extension-loader-patch.d.mts b/scripts/lib/pi-extension-loader-patch.d.mts new file mode 100644 index 0000000..4798566 --- /dev/null +++ b/scripts/lib/pi-extension-loader-patch.d.mts @@ -0,0 +1 @@ +export function patchPiExtensionLoaderSource(source: string): string; diff --git a/scripts/lib/pi-extension-loader-patch.mjs b/scripts/lib/pi-extension-loader-patch.mjs new file mode 100644 index 0000000..1668a1a --- /dev/null +++ b/scripts/lib/pi-extension-loader-patch.mjs @@ -0,0 +1,32 @@ +const PATH_TO_FILE_URL_IMPORT = 'import { fileURLToPath, pathToFileURL } from "node:url";'; +const FILE_URL_TO_PATH_IMPORT = 'import { fileURLToPath } from "node:url";'; + +const IMPORT_CALL = 'const module = await jiti.import(extensionPath, { default: true });'; +const PATCHED_IMPORT_CALL = [ + ' const extensionSpecifier = process.platform === "win32" && path.isAbsolute(extensionPath)', + ' ? pathToFileURL(extensionPath).href', + ' : extensionPath;', + ' const module = await jiti.import(extensionSpecifier, { default: true });', +].join("\n"); + +export function patchPiExtensionLoaderSource(source) { + let patched = source; + + if (patched.includes(PATH_TO_FILE_URL_IMPORT) || patched.includes(PATCHED_IMPORT_CALL)) { + return patched; + } + + if (patched.includes(FILE_URL_TO_PATH_IMPORT)) { + patched = patched.replace(FILE_URL_TO_PATH_IMPORT, PATH_TO_FILE_URL_IMPORT); + } + + if (!patched.includes(PATH_TO_FILE_URL_IMPORT)) { + return source; + } + + if (!patched.includes(IMPORT_CALL)) { + return source; + } + + return patched.replace(IMPORT_CALL, PATCHED_IMPORT_CALL); +} diff --git a/scripts/patch-embedded-pi.mjs b/scripts/patch-embedded-pi.mjs index e1ff6e1..af82f13 100644 --- a/scripts/patch-embedded-pi.mjs +++ b/scripts/patch-embedded-pi.mjs @@ -4,6 +4,7 @@ import { createRequire } from "node:module"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { FEYNMAN_LOGO_HTML } from "../logo.mjs"; +import { patchPiExtensionLoaderSource } from "./lib/pi-extension-loader-patch.mjs"; import { PI_SUBAGENTS_PATCH_TARGETS, patchPiSubagentsSource } from "./lib/pi-subagents-patch.mjs"; const here = dirname(fileURLToPath(import.meta.url)); @@ -52,9 +53,11 @@ const cliPath = piPackageRoot ? resolve(piPackageRoot, "dist", "cli.js") : null; const bunCliPath = piPackageRoot ? resolve(piPackageRoot, "dist", "bun", "cli.js") : null; const interactiveModePath = piPackageRoot ? resolve(piPackageRoot, "dist", "modes", "interactive", "interactive-mode.js") : null; const interactiveThemePath = piPackageRoot ? resolve(piPackageRoot, "dist", "modes", "interactive", "theme", "theme.js") : null; +const extensionLoaderPath = piPackageRoot ? resolve(piPackageRoot, "dist", "core", "extensions", "loader.js") : null; const terminalPath = piTuiRoot ? resolve(piTuiRoot, "dist", "terminal.js") : null; const editorPath = piTuiRoot ? resolve(piTuiRoot, "dist", "components", "editor.js") : null; const workspaceRoot = resolve(appRoot, ".feynman", "npm", "node_modules"); +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( @@ -181,6 +184,18 @@ 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; @@ -352,6 +367,14 @@ if (interactiveModePath && existsSync(interactiveModePath)) { } } +if (extensionLoaderPath && existsSync(extensionLoaderPath)) { + const source = readFileSync(extensionLoaderPath, "utf8"); + const patched = patchPiExtensionLoaderSource(source); + if (patched !== source) { + writeFileSync(extensionLoaderPath, patched, "utf8"); + } +} + if (interactiveThemePath && existsSync(interactiveThemePath)) { let themeSource = readFileSync(interactiveThemePath, "utf8"); const desiredGetEditorTheme = [ @@ -517,6 +540,16 @@ 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 fd55a8c..ee03958 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -19,6 +19,7 @@ import { launchPiChat } from "./pi/launch.js"; import { CORE_PACKAGE_SOURCES, getOptionalPackagePresetSources, listOptionalPackagePresets } from "./pi/package-presets.js"; import { normalizeFeynmanSettings, normalizeThinkingLevel, parseModelSpec } from "./pi/settings.js"; import { applyFeynmanPackageManagerEnv } from "./pi/runtime.js"; +import { getConfiguredServiceTier, normalizeServiceTier, setConfiguredServiceTier } from "./model/service-tier.js"; import { authenticateModelProvider, getCurrentModelSpec, @@ -151,6 +152,29 @@ async function handleModelCommand(subcommand: string | undefined, args: string[] return; } + if (subcommand === "tier") { + const requested = args[0]; + if (!requested) { + console.log(getConfiguredServiceTier(feynmanSettingsPath) ?? "not set"); + return; + } + + if (requested === "unset" || requested === "clear" || requested === "off") { + setConfiguredServiceTier(feynmanSettingsPath, undefined); + console.log("Cleared service tier override"); + return; + } + + const tier = normalizeServiceTier(requested); + if (!tier) { + throw new Error("Usage: feynman model tier "); + } + + setConfiguredServiceTier(feynmanSettingsPath, tier); + console.log(`Service tier set to ${tier}`); + return; + } + throw new Error(`Unknown model command: ${subcommand}`); } @@ -311,6 +335,7 @@ export async function main(): Promise { model: { type: "string" }, "new-session": { type: "boolean" }, prompt: { type: "string" }, + "service-tier": { type: "string" }, "session-dir": { type: "string" }, "setup-preview": { type: "boolean" }, thinking: { type: "string" }, @@ -437,6 +462,13 @@ export async function main(): Promise { } const explicitModelSpec = values.model ?? process.env.FEYNMAN_MODEL; + const explicitServiceTier = normalizeServiceTier(values["service-tier"] ?? process.env.FEYNMAN_SERVICE_TIER); + if ((values["service-tier"] ?? process.env.FEYNMAN_SERVICE_TIER) && !explicitServiceTier) { + throw new Error("Unknown service tier. Use auto, default, flex, priority, or standard_only."); + } + if (explicitServiceTier) { + process.env.FEYNMAN_SERVICE_TIER = explicitServiceTier; + } if (explicitModelSpec) { const modelRegistry = createModelRegistry(feynmanAuthPath); const explicitModel = parseModelSpec(explicitModelSpec, modelRegistry); diff --git a/src/model/service-tier.ts b/src/model/service-tier.ts new file mode 100644 index 0000000..57d298c --- /dev/null +++ b/src/model/service-tier.ts @@ -0,0 +1,65 @@ +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; + +export const FEYNMAN_SERVICE_TIERS = [ + "auto", + "default", + "flex", + "priority", + "standard_only", +] as const; + +export type FeynmanServiceTier = (typeof FEYNMAN_SERVICE_TIERS)[number]; + +const SERVICE_TIER_SET = new Set(FEYNMAN_SERVICE_TIERS); +const OPENAI_SERVICE_TIERS = new Set(["auto", "default", "flex", "priority"]); +const ANTHROPIC_SERVICE_TIERS = new Set(["auto", "standard_only"]); + +function readSettings(settingsPath: string): Record { + try { + return JSON.parse(readFileSync(settingsPath, "utf8")) as Record; + } catch { + return {}; + } +} + +export function normalizeServiceTier(value: string | undefined): FeynmanServiceTier | undefined { + if (!value) return undefined; + const normalized = value.trim().toLowerCase(); + return SERVICE_TIER_SET.has(normalized) ? (normalized as FeynmanServiceTier) : undefined; +} + +export function getConfiguredServiceTier(settingsPath: string): FeynmanServiceTier | undefined { + const settings = readSettings(settingsPath); + return normalizeServiceTier(typeof settings.serviceTier === "string" ? settings.serviceTier : undefined); +} + +export function setConfiguredServiceTier(settingsPath: string, tier: FeynmanServiceTier | undefined): void { + const settings = readSettings(settingsPath); + if (tier) { + settings.serviceTier = tier; + } else { + delete settings.serviceTier; + } + + mkdirSync(dirname(settingsPath), { recursive: true }); + writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf8"); +} + +export function resolveActiveServiceTier(settingsPath: string): FeynmanServiceTier | undefined { + return normalizeServiceTier(process.env.FEYNMAN_SERVICE_TIER) ?? getConfiguredServiceTier(settingsPath); +} + +export function resolveProviderServiceTier( + provider: string | undefined, + tier: FeynmanServiceTier | undefined, +): FeynmanServiceTier | undefined { + if (!provider || !tier) return undefined; + if ((provider === "openai" || provider === "openai-codex") && OPENAI_SERVICE_TIERS.has(tier)) { + return tier; + } + if (provider === "anthropic" && ANTHROPIC_SERVICE_TIERS.has(tier)) { + return tier; + } + return undefined; +} diff --git a/src/pi/web-access.ts b/src/pi/web-access.ts index f413f24..f439ae1 100644 --- a/src/pi/web-access.ts +++ b/src/pi/web-access.ts @@ -2,12 +2,13 @@ import { existsSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; import { resolve } from "node:path"; -export type PiWebSearchProvider = "auto" | "perplexity" | "gemini"; +export type PiWebSearchProvider = "auto" | "perplexity" | "exa" | "gemini"; export type PiWebAccessConfig = Record & { provider?: PiWebSearchProvider; searchProvider?: PiWebSearchProvider; perplexityApiKey?: string; + exaApiKey?: string; geminiApiKey?: string; chromeProfile?: string; }; @@ -17,6 +18,7 @@ export type PiWebAccessStatus = { searchProvider: PiWebSearchProvider; requestProvider: PiWebSearchProvider; perplexityConfigured: boolean; + exaConfigured: boolean; geminiApiConfigured: boolean; chromeProfile?: string; routeLabel: string; @@ -28,7 +30,7 @@ export function getPiWebSearchConfigPath(home = process.env.HOME ?? homedir()): } function normalizeProvider(value: unknown): PiWebSearchProvider | undefined { - return value === "auto" || value === "perplexity" || value === "gemini" ? value : undefined; + return value === "auto" || value === "perplexity" || value === "exa" || value === "gemini" ? value : undefined; } function normalizeNonEmptyString(value: unknown): string | undefined { @@ -52,6 +54,8 @@ function formatRouteLabel(provider: PiWebSearchProvider): string { switch (provider) { case "perplexity": return "Perplexity"; + case "exa": + return "Exa"; case "gemini": return "Gemini"; default: @@ -63,10 +67,12 @@ function formatRouteNote(provider: PiWebSearchProvider): string { switch (provider) { case "perplexity": return "Pi web-access will use Perplexity for search."; + case "exa": + return "Pi web-access will use Exa for search."; case "gemini": return "Pi web-access will use Gemini API or Gemini Browser."; default: - return "Pi web-access will try Perplexity, then Gemini API, then Gemini Browser."; + return "Pi web-access will try Perplexity, then Exa, then Gemini API, then Gemini Browser."; } } @@ -77,6 +83,7 @@ export function getPiWebAccessStatus( const searchProvider = normalizeProvider(config.searchProvider) ?? "auto"; const requestProvider = normalizeProvider(config.provider) ?? searchProvider; const perplexityConfigured = Boolean(normalizeNonEmptyString(config.perplexityApiKey)); + const exaConfigured = Boolean(normalizeNonEmptyString(config.exaApiKey)); const geminiApiConfigured = Boolean(normalizeNonEmptyString(config.geminiApiKey)); const chromeProfile = normalizeNonEmptyString(config.chromeProfile); const effectiveProvider = searchProvider; @@ -86,6 +93,7 @@ export function getPiWebAccessStatus( searchProvider, requestProvider, perplexityConfigured, + exaConfigured, geminiApiConfigured, chromeProfile, routeLabel: formatRouteLabel(effectiveProvider), @@ -101,6 +109,7 @@ export function formatPiWebAccessDoctorLines( ` search route: ${status.routeLabel}`, ` request route: ${status.requestProvider}`, ` perplexity api: ${status.perplexityConfigured ? "configured" : "not configured"}`, + ` exa api: ${status.exaConfigured ? "configured" : "not configured"}`, ` gemini api: ${status.geminiApiConfigured ? "configured" : "not configured"}`, ` browser profile: ${status.chromeProfile ?? "default Chromium profile"}`, ` config path: ${status.configPath}`, diff --git a/src/search/commands.ts b/src/search/commands.ts index b6e442d..1b446c2 100644 --- a/src/search/commands.ts +++ b/src/search/commands.ts @@ -7,6 +7,7 @@ export function printSearchStatus(): void { printInfo(`Search route: ${status.routeLabel}`); printInfo(`Request route: ${status.requestProvider}`); printInfo(`Perplexity API configured: ${status.perplexityConfigured ? "yes" : "no"}`); + printInfo(`Exa API configured: ${status.exaConfigured ? "yes" : "no"}`); printInfo(`Gemini API configured: ${status.geminiApiConfigured ? "yes" : "no"}`); printInfo(`Browser profile: ${status.chromeProfile ?? "default Chromium profile"}`); printInfo(`Config path: ${status.configPath}`); diff --git a/src/setup/doctor.ts b/src/setup/doctor.ts index 40e2a06..02ef35e 100644 --- a/src/setup/doctor.ts +++ b/src/setup/doctor.ts @@ -10,6 +10,7 @@ import { printInfo, printPanel, printSection } from "../ui/terminal.js"; import { getCurrentModelSpec } from "../model/commands.js"; import { buildModelStatusSnapshotFromRecords, getAvailableModelRecords, getSupportedModelRecords } from "../model/catalog.js"; import { createModelRegistry, getModelsJsonPath } from "../model/registry.js"; +import { getConfiguredServiceTier } from "../model/service-tier.js"; function findProvidersMissingApiKey(modelsJsonPath: string): string[] { try { @@ -105,6 +106,7 @@ export function runStatus(options: DoctorOptions): void { printInfo(`Recommended model: ${snapshot.recommendedModel ?? "not available"}`); printInfo(`alphaXiv: ${snapshot.alphaLoggedIn ? snapshot.alphaUser ?? "configured" : "not configured"}`); printInfo(`Web access: pi-web-access (${snapshot.webRouteLabel})`); + printInfo(`Service tier: ${getConfiguredServiceTier(options.settingsPath) ?? "not set"}`); printInfo(`Preview: ${snapshot.previewConfigured ? "configured" : "not configured"}`); printSection("Paths"); @@ -165,6 +167,7 @@ export function runDoctor(options: DoctorOptions): void { console.log(`default model valid: ${modelStatus.modelValid ? "yes" : "no"}`); console.log(`authenticated providers: ${modelStatus.authenticatedProviderCount}`); console.log(`authenticated models: ${modelStatus.authenticatedModelCount}`); + console.log(`service tier: ${getConfiguredServiceTier(options.settingsPath) ?? "not set"}`); console.log(`recommended model: ${modelStatus.recommendedModel ?? "not available"}`); if (modelStatus.recommendedModelReason) { console.log(` why: ${modelStatus.recommendedModelReason}`); diff --git a/tests/pi-extension-loader-patch.test.ts b/tests/pi-extension-loader-patch.test.ts new file mode 100644 index 0000000..fc0af88 --- /dev/null +++ b/tests/pi-extension-loader-patch.test.ts @@ -0,0 +1,42 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { patchPiExtensionLoaderSource } from "../scripts/lib/pi-extension-loader-patch.mjs"; + +test("patchPiExtensionLoaderSource rewrites Windows extension imports to file URLs", () => { + const input = [ + 'import * as path from "node:path";', + 'import { fileURLToPath } from "node:url";', + "async function loadExtensionModule(extensionPath) {", + " const jiti = createJiti(import.meta.url);", + ' const module = await jiti.import(extensionPath, { default: true });', + " return module;", + "}", + "", + ].join("\n"); + + const patched = patchPiExtensionLoaderSource(input); + + assert.match(patched, /pathToFileURL/); + assert.match(patched, /process\.platform === "win32"/); + assert.match(patched, /path\.isAbsolute\(extensionPath\)/); + assert.match(patched, /jiti\.import\(extensionSpecifier, \{ default: true \}\)/); +}); + +test("patchPiExtensionLoaderSource is idempotent", () => { + const input = [ + 'import * as path from "node:path";', + 'import { fileURLToPath } from "node:url";', + "async function loadExtensionModule(extensionPath) {", + " const jiti = createJiti(import.meta.url);", + ' const module = await jiti.import(extensionPath, { default: true });', + " return module;", + "}", + "", + ].join("\n"); + + const once = patchPiExtensionLoaderSource(input); + const twice = patchPiExtensionLoaderSource(once); + + assert.equal(twice, once); +}); diff --git a/tests/pi-web-access.test.ts b/tests/pi-web-access.test.ts index e20761a..ca6bcfb 100644 --- a/tests/pi-web-access.test.ts +++ b/tests/pi-web-access.test.ts @@ -19,6 +19,31 @@ test("loadPiWebAccessConfig returns empty config when Pi web config is missing", }); test("getPiWebAccessStatus reads Pi web-access config directly", () => { + const root = mkdtempSync(join(tmpdir(), "feynman-pi-web-")); + const configPath = getPiWebSearchConfigPath(root); + mkdirSync(join(root, ".feynman"), { recursive: true }); + writeFileSync( + configPath, + JSON.stringify({ + provider: "exa", + searchProvider: "exa", + exaApiKey: "exa_...", + chromeProfile: "Profile 2", + geminiApiKey: "AIza...", + }), + "utf8", + ); + + const status = getPiWebAccessStatus(loadPiWebAccessConfig(configPath), configPath); + assert.equal(status.routeLabel, "Exa"); + assert.equal(status.requestProvider, "exa"); + assert.equal(status.exaConfigured, true); + assert.equal(status.geminiApiConfigured, true); + assert.equal(status.perplexityConfigured, false); + assert.equal(status.chromeProfile, "Profile 2"); +}); + +test("getPiWebAccessStatus reads Gemini routes directly", () => { const root = mkdtempSync(join(tmpdir(), "feynman-pi-web-")); const configPath = getPiWebSearchConfigPath(root); mkdirSync(join(root, ".feynman"), { recursive: true }); @@ -36,6 +61,7 @@ test("getPiWebAccessStatus reads Pi web-access config directly", () => { const status = getPiWebAccessStatus(loadPiWebAccessConfig(configPath), configPath); assert.equal(status.routeLabel, "Gemini"); assert.equal(status.requestProvider, "gemini"); + assert.equal(status.exaConfigured, false); assert.equal(status.geminiApiConfigured, true); assert.equal(status.perplexityConfigured, false); assert.equal(status.chromeProfile, "Profile 2"); diff --git a/tests/service-tier.test.ts b/tests/service-tier.test.ts new file mode 100644 index 0000000..9d1052f --- /dev/null +++ b/tests/service-tier.test.ts @@ -0,0 +1,41 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + getConfiguredServiceTier, + normalizeServiceTier, + resolveProviderServiceTier, + setConfiguredServiceTier, +} from "../src/model/service-tier.js"; + +test("normalizeServiceTier accepts supported values only", () => { + assert.equal(normalizeServiceTier("priority"), "priority"); + assert.equal(normalizeServiceTier("standard_only"), "standard_only"); + assert.equal(normalizeServiceTier("FAST"), undefined); + assert.equal(normalizeServiceTier(undefined), undefined); +}); + +test("setConfiguredServiceTier persists and clears settings.json values", () => { + const dir = mkdtempSync(join(tmpdir(), "feynman-service-tier-")); + const settingsPath = join(dir, "settings.json"); + + setConfiguredServiceTier(settingsPath, "priority"); + assert.equal(getConfiguredServiceTier(settingsPath), "priority"); + + const persisted = JSON.parse(readFileSync(settingsPath, "utf8")) as { serviceTier?: string }; + assert.equal(persisted.serviceTier, "priority"); + + setConfiguredServiceTier(settingsPath, undefined); + assert.equal(getConfiguredServiceTier(settingsPath), undefined); +}); + +test("resolveProviderServiceTier filters unsupported provider+tier pairs", () => { + assert.equal(resolveProviderServiceTier("openai", "priority"), "priority"); + assert.equal(resolveProviderServiceTier("openai-codex", "flex"), "flex"); + assert.equal(resolveProviderServiceTier("anthropic", "standard_only"), "standard_only"); + assert.equal(resolveProviderServiceTier("anthropic", "priority"), undefined); + assert.equal(resolveProviderServiceTier("google", "priority"), undefined); +});