@polylogicai/polycode 1.1.4 → 1.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,208 @@
1
+ // lib/paste-aware-prompt.mjs
2
+ // A custom line reader that implements bracketed paste mode for the polycode
3
+ // REPL. Standard readline cannot tell a one-character typed key from a
4
+ // multi-line paste, so pasting a 500-line file floods the terminal and
5
+ // floods the display with echoed content. Modern terminals support
6
+ // bracketed paste mode: when enabled, the terminal wraps pasted content in
7
+ // ESC[200~ ... ESC[201~ so the host program can detect the paste as a
8
+ // single event.
9
+ //
10
+ // This reader enables bracketed paste on startup, reads stdin in raw mode,
11
+ // collapses each paste to a placeholder like [Pasted #1 (20 lines)] in the
12
+ // display, and returns both the displayed line and the full content with
13
+ // placeholders expanded for the agent to read.
14
+
15
+ import { stdin, stdout } from 'node:process';
16
+
17
+ const ESC = '\u001b';
18
+ const PASTE_START = `${ESC}[200~`;
19
+ const PASTE_END = `${ESC}[201~`;
20
+
21
+ const BRACKETED_PASTE_ON = `${ESC}[?2004h`;
22
+ const BRACKETED_PASTE_OFF = `${ESC}[?2004l`;
23
+
24
+ // Ordinal counter for paste placeholders within the lifetime of the process.
25
+ // Each successful paste increments it so the user can reference pastes by
26
+ // number ("expand pasted #3") in future features.
27
+ let pasteOrdinal = 0;
28
+
29
+ export function enableBracketedPaste() {
30
+ if (stdout.isTTY) stdout.write(BRACKETED_PASTE_ON);
31
+ }
32
+
33
+ export function disableBracketedPaste() {
34
+ if (stdout.isTTY) stdout.write(BRACKETED_PASTE_OFF);
35
+ }
36
+
37
+ // Read one line from stdin with paste awareness. Returns a promise that
38
+ // resolves to { displayed, content, pastes }:
39
+ // - displayed: the string the user sees in the terminal (with placeholders)
40
+ // - content: the string to send to the agent (with full paste text)
41
+ // - pastes: an array of { ordinal, lines, bytes, text } for logging/telemetry
42
+ //
43
+ // On non-TTY stdin (piped input, tests), this reader falls back to a plain
44
+ // line read with no paste handling, since bracketed paste requires a real
45
+ // terminal.
46
+ export function readPasteAwareLine(prompt) {
47
+ return new Promise((resolve, reject) => {
48
+ stdout.write(prompt);
49
+
50
+ if (!stdin.isTTY || typeof stdin.setRawMode !== 'function') {
51
+ let buf = '';
52
+ const onData = (chunk) => {
53
+ buf += chunk.toString('utf8');
54
+ const nlIdx = buf.indexOf('\n');
55
+ if (nlIdx !== -1) {
56
+ stdin.removeListener('data', onData);
57
+ const line = buf.slice(0, nlIdx).replace(/\r$/, '');
58
+ resolve({ displayed: line, content: line, pastes: [] });
59
+ }
60
+ };
61
+ stdin.on('data', onData);
62
+ return;
63
+ }
64
+
65
+ const wasRaw = stdin.isRaw;
66
+ stdin.setRawMode(true);
67
+ stdin.resume();
68
+ stdin.setEncoding('utf8');
69
+
70
+ let stream = '';
71
+ const pastes = [];
72
+ const segments = [];
73
+ let currentText = '';
74
+ let visibleLength = 0;
75
+
76
+ const cleanup = () => {
77
+ stdin.removeListener('data', onData);
78
+ try { stdin.setRawMode(wasRaw); } catch { /* ignore */ }
79
+ stdin.pause();
80
+ };
81
+
82
+ const flushCurrent = () => {
83
+ if (currentText.length > 0) {
84
+ segments.push({ kind: 'text', text: currentText });
85
+ currentText = '';
86
+ }
87
+ };
88
+
89
+ const finish = () => {
90
+ flushCurrent();
91
+ cleanup();
92
+ stdout.write('\n');
93
+
94
+ const displayed = segments
95
+ .map((s) =>
96
+ s.kind === 'text'
97
+ ? s.text
98
+ : `[Pasted #${s.ordinal} (${s.lines} lines)]`
99
+ )
100
+ .join('');
101
+ const content = segments.map((s) => (s.kind === 'text' ? s.text : s.text)).join('');
102
+ resolve({ displayed, content, pastes });
103
+ };
104
+
105
+ const onData = (chunk) => {
106
+ try {
107
+ stream += chunk;
108
+ // Drain the stream one token at a time.
109
+ while (stream.length > 0) {
110
+ // Paste start?
111
+ if (stream.startsWith(PASTE_START)) {
112
+ const endIdx = stream.indexOf(PASTE_END, PASTE_START.length);
113
+ if (endIdx === -1) {
114
+ // Waiting for the rest of the paste; don't consume anything.
115
+ return;
116
+ }
117
+ const pasted = stream.slice(PASTE_START.length, endIdx);
118
+ stream = stream.slice(endIdx + PASTE_END.length);
119
+ flushCurrent();
120
+ pasteOrdinal += 1;
121
+ const ordinal = pasteOrdinal;
122
+ const lines = pasted.split('\n').length;
123
+ const bytes = Buffer.byteLength(pasted, 'utf8');
124
+ const entry = { ordinal, lines, bytes, text: pasted };
125
+ pastes.push(entry);
126
+ segments.push({ kind: 'paste', ordinal, lines, bytes, text: pasted });
127
+ const placeholder = `[Pasted #${ordinal} (${lines} lines)]`;
128
+ stdout.write(`\u001b[2m${placeholder}\u001b[0m`);
129
+ visibleLength += placeholder.length;
130
+ continue;
131
+ }
132
+ // Single char path
133
+ const ch = stream[0];
134
+ stream = stream.slice(1);
135
+
136
+ if (ch === '\r' || ch === '\n') {
137
+ finish();
138
+ return;
139
+ }
140
+ if (ch === '\u0003') {
141
+ // Ctrl-C
142
+ cleanup();
143
+ stdout.write('\n');
144
+ reject(new Error('cancelled'));
145
+ return;
146
+ }
147
+ if (ch === '\u0004') {
148
+ // Ctrl-D on empty line = exit; on non-empty = ignored
149
+ if (currentText.length === 0 && segments.length === 0) {
150
+ finish();
151
+ return;
152
+ }
153
+ continue;
154
+ }
155
+ if (ch === '\u007f' || ch === '\b') {
156
+ // Backspace. If the last segment is a paste, remove the whole
157
+ // paste (one deletion = one paste). Otherwise remove one char.
158
+ if (currentText.length > 0) {
159
+ currentText = currentText.slice(0, -1);
160
+ stdout.write('\b \b');
161
+ visibleLength = Math.max(0, visibleLength - 1);
162
+ } else if (segments.length > 0) {
163
+ const last = segments.pop();
164
+ if (last.kind === 'paste') {
165
+ const width = `[Pasted #${last.ordinal} (${last.lines} lines)]`.length;
166
+ for (let i = 0; i < width; i++) stdout.write('\b \b');
167
+ visibleLength = Math.max(0, visibleLength - width);
168
+ // Also remove from the pastes[] index so logs match.
169
+ const pi = pastes.findIndex((p) => p.ordinal === last.ordinal);
170
+ if (pi !== -1) pastes.splice(pi, 1);
171
+ } else {
172
+ // Put it back and delete one char.
173
+ segments.push(last);
174
+ if (last.text.length > 0) {
175
+ last.text = last.text.slice(0, -1);
176
+ stdout.write('\b \b');
177
+ visibleLength = Math.max(0, visibleLength - 1);
178
+ }
179
+ }
180
+ }
181
+ continue;
182
+ }
183
+ if (ch === ESC) {
184
+ // Unrecognized escape sequence. Consume the minimum so we don't
185
+ // block; terminal-specific sequences (arrow keys, etc.) aren't
186
+ // supported in this reader, they get swallowed silently.
187
+ if (stream.length >= 2 && stream[0] === '[') {
188
+ stream = stream.slice(2);
189
+ }
190
+ continue;
191
+ }
192
+ if (ch < ' ' && ch !== '\t') {
193
+ // Other control char: ignore.
194
+ continue;
195
+ }
196
+ currentText += ch;
197
+ stdout.write(ch);
198
+ visibleLength += 1;
199
+ }
200
+ } catch (err) {
201
+ cleanup();
202
+ reject(err);
203
+ }
204
+ };
205
+
206
+ stdin.on('data', onData);
207
+ });
208
+ }
package/lib/repl-ui.mjs CHANGED
@@ -139,7 +139,9 @@ export function createRenderer(stdout, opts = {}) {
139
139
  const out = compactToolResultLine(ev.name, ev.result);
140
140
  if (out) line(out);
141
141
  } else if (ev.phase === 'scrub_blocked') {
142
- line(`${C.red}refusing to send tool output to the model: contains a recognized secret pattern${C.reset}`);
142
+ // Suppressed: a follow-up `act/message` event renders the friendlier
143
+ // guidance that tells the user to type /key instead. We hide the raw
144
+ // scrub event to avoid a scary red line before the guidance.
143
145
  } else if (ev.phase === 'error') {
144
146
  line(`${C.red}error: ${ev.message}${C.reset}`);
145
147
  }
@@ -5,10 +5,12 @@
5
5
 
6
6
  import { C } from './repl-ui.mjs';
7
7
  import { rotateIntent } from './intent.mjs';
8
+ import { runInteractiveKeyFlow, PROVIDERS, getProviderById } from './key-store.mjs';
8
9
 
9
10
  export const SLASH_HELP = `
10
11
  ${C.bold}polycode commands${C.reset}
11
12
  /help Show this help
13
+ /key [provider] Save an API key (Groq, Anthropic, OpenAI). Input is masked.
12
14
  /log Show session log path, row count, and last hash
13
15
  /replay <n> Print the last N session log rows
14
16
  /verify Verify session log SHA-256 chain integrity
@@ -18,15 +20,44 @@ ${C.bold}polycode commands${C.reset}
18
20
  /exit, /quit Leave polycode
19
21
  `;
20
22
 
21
- export async function dispatchSlash(line, { canon, state, stdout }) {
23
+ export async function dispatchSlash(line, { canon, state, stdout, loop, rl }) {
22
24
  const [cmd, ...rest] = line.slice(1).split(/\s+/);
23
- const args = rest.join(' ');
25
+ const args = rest.join(' ').trim();
24
26
 
25
27
  switch (cmd) {
26
28
  case 'help':
27
29
  stdout.write(SLASH_HELP + '\n');
28
30
  return { continue: true };
29
31
 
32
+ case 'key':
33
+ case 'login': {
34
+ const providerHint = args || null;
35
+ if (providerHint && !getProviderById(providerHint)) {
36
+ stdout.write(`${C.red}unknown provider${C.reset}: ${providerHint}. Known: ${PROVIDERS.map((p) => p.id).join(', ')}\n`);
37
+ return { continue: true };
38
+ }
39
+ stdout.write(`${C.dim}Your key will be saved locally to ~/.polycode/secrets.env (chmod 600) and never sent to the model.${C.reset}\n`);
40
+ const result = await runInteractiveKeyFlow({ providerHint, stdout, rl });
41
+ if (!result.ok) {
42
+ if (result.reason === 'cancelled') {
43
+ stdout.write(`${C.dim}cancelled${C.reset}\n`);
44
+ } else {
45
+ stdout.write(`${C.red}error${C.reset}: ${result.reason}\n`);
46
+ }
47
+ return { continue: true };
48
+ }
49
+ stdout.write(`${C.amber}saved${C.reset}: ${result.provider.name} key (${result.provider.envVar})\n`);
50
+ // Hot-swap the live loop: if the saved key is for the current provider
51
+ // (Groq), flip out of hosted mode so the next turn goes direct to the
52
+ // provider. Other providers just land in env for next compile.
53
+ if (loop && result.provider.id === 'groq') {
54
+ loop.apiKey = process.env[result.provider.envVar];
55
+ loop.hostedMode = false;
56
+ stdout.write(`${C.dim}switched this session from hosted tier to direct Groq. Unlimited use.${C.reset}\n`);
57
+ }
58
+ return { continue: true };
59
+ }
60
+
30
61
  case 'log':
31
62
  case 'history':
32
63
  case 'canon': {
@@ -0,0 +1,111 @@
1
+ // lib/tools/describe-image.mjs
2
+ // Vision tool. Reads an image from disk, base64-encodes it, sends to a
3
+ // vision-capable model, returns the text description. In hosted mode the
4
+ // request goes through the Polylogic inference proxy so the user does not
5
+ // need any vision-specific key. In BYOK mode we use the user's Groq key
6
+ // against the same endpoint.
7
+
8
+ import { promises as fs } from 'node:fs';
9
+ import { extname } from 'node:path';
10
+
11
+ const MAX_IMAGE_BYTES = 4 * 1024 * 1024;
12
+ const DEFAULT_VISION_MODEL = 'meta-llama/llama-4-scout-17b-16e-instruct';
13
+ const FALLBACK_VISION_MODEL = 'meta-llama/llama-4-scout-17b-16e-instruct';
14
+
15
+ const MIME_BY_EXT = {
16
+ '.png': 'image/png',
17
+ '.jpg': 'image/jpeg',
18
+ '.jpeg': 'image/jpeg',
19
+ '.webp': 'image/webp',
20
+ '.gif': 'image/gif',
21
+ };
22
+
23
+ function mimeFor(path) {
24
+ const ext = extname(path).toLowerCase();
25
+ return MIME_BY_EXT[ext] || null;
26
+ }
27
+
28
+ export async function describeImage({ path, question }) {
29
+ const stat = await fs.stat(path);
30
+ if (!stat.isFile()) throw new Error(`not a file: ${path}`);
31
+ if (stat.size > MAX_IMAGE_BYTES) {
32
+ throw new Error(`image too large: ${stat.size} bytes (limit ${MAX_IMAGE_BYTES})`);
33
+ }
34
+ const mime = mimeFor(path);
35
+ if (!mime) {
36
+ throw new Error(`unsupported image type: ${extname(path) || '(no extension)'}. Supported: png, jpg, jpeg, webp, gif.`);
37
+ }
38
+
39
+ const bytes = await fs.readFile(path);
40
+ const dataUrl = `data:${mime};base64,${bytes.toString('base64')}`;
41
+
42
+ const body = {
43
+ model: DEFAULT_VISION_MODEL,
44
+ max_tokens: 700,
45
+ temperature: 0.2,
46
+ messages: [
47
+ {
48
+ role: 'user',
49
+ content: [
50
+ { type: 'text', text: question || 'Describe this image in detail.' },
51
+ { type: 'image_url', image_url: { url: dataUrl } },
52
+ ],
53
+ },
54
+ ],
55
+ };
56
+
57
+ // Routing: BYOK uses Groq directly, hosted mode uses the Polylogic proxy.
58
+ const groqKey = process.env.GROQ_API_KEY || '';
59
+ let res;
60
+ let endpoint;
61
+ let headers;
62
+ if (groqKey) {
63
+ endpoint = 'https://api.groq.com/openai/v1/chat/completions';
64
+ headers = {
65
+ Authorization: `Bearer ${groqKey}`,
66
+ 'Content-Type': 'application/json',
67
+ };
68
+ } else {
69
+ const { getOrCreateInstallId } = await import('../polycode-hosted-client.mjs');
70
+ endpoint = process.env.POLYCODE_PROXY_URL || 'https://polylogicai.com/api/polycode/inference';
71
+ headers = {
72
+ 'Content-Type': 'application/json',
73
+ 'X-Polycode-Install-ID': getOrCreateInstallId(),
74
+ 'X-Polycode-Version': 'vision',
75
+ };
76
+ }
77
+
78
+ async function callOnce(modelOverride) {
79
+ const payload = modelOverride ? { ...body, model: modelOverride } : body;
80
+ const r = await fetch(endpoint, { method: 'POST', headers, body: JSON.stringify(payload) });
81
+ const text = await r.text();
82
+ if (!r.ok) {
83
+ const err = new Error(`vision upstream ${r.status}: ${text.slice(0, 300)}`);
84
+ err.status = r.status;
85
+ err.body = text;
86
+ throw err;
87
+ }
88
+ try { return JSON.parse(text); } catch { throw new Error(`vision upstream returned non-JSON: ${text.slice(0, 200)}`); }
89
+ }
90
+
91
+ let completion;
92
+ try {
93
+ completion = await callOnce();
94
+ } catch (err) {
95
+ // Fall back to the older preview model if the maverick model is unavailable
96
+ // on the backend (e.g. proxy allowlist or decommissioned).
97
+ if (err.status === 400 || err.status === 404) {
98
+ completion = await callOnce(FALLBACK_VISION_MODEL);
99
+ } else {
100
+ throw err;
101
+ }
102
+ }
103
+
104
+ const content = completion?.choices?.[0]?.message?.content;
105
+ if (typeof content === 'string' && content.length > 0) return content;
106
+ if (Array.isArray(content)) {
107
+ // Some models return content as an array of blocks.
108
+ return content.map((b) => (typeof b === 'string' ? b : b?.text || '')).join('\n').trim();
109
+ }
110
+ throw new Error('vision response had no content');
111
+ }
@@ -0,0 +1,130 @@
1
+ // lib/tools/fetch-url.mjs
2
+ // Fetches a URL from the user's machine and returns its body as text.
3
+ // Supports HTTP(S), follows redirects, converts HTML to readable plain text,
4
+ // returns JSON and text verbatim. Refuses binary content, private network
5
+ // addresses, and non-http protocols.
6
+
7
+ const MAX_BODY_BYTES = 200 * 1024;
8
+ const FETCH_TIMEOUT_MS = 15_000;
9
+
10
+ const USER_AGENT = 'polycode/1.1 (+https://polylogicai.com/polycode)';
11
+
12
+ // Block private network ranges so the tool cannot be used as a server-side
13
+ // request forgery vector against the user's own localhost or LAN. This is a
14
+ // defense-in-depth since polycode is a user-agent, but the LLM might be
15
+ // told to ping an internal endpoint.
16
+ function isForbiddenHost(host) {
17
+ if (!host) return true;
18
+ const h = host.toLowerCase();
19
+ if (h === 'localhost' || h === '127.0.0.1' || h === '::1' || h === '0.0.0.0') return true;
20
+ if (/^10\./.test(h)) return true;
21
+ if (/^192\.168\./.test(h)) return true;
22
+ if (/^172\.(1[6-9]|2\d|3[0-1])\./.test(h)) return true;
23
+ if (/^169\.254\./.test(h)) return true;
24
+ if (/^fc[0-9a-f][0-9a-f]:/.test(h)) return true;
25
+ if (/^fe80:/.test(h)) return true;
26
+ return false;
27
+ }
28
+
29
+ // Strip HTML tags and normalize whitespace for a body that the model can
30
+ // actually read. We drop script/style blocks first, then replace tags with
31
+ // newlines, then collapse whitespace runs. This is a best-effort reader, not
32
+ // a full DOM parser.
33
+ function htmlToText(html) {
34
+ let out = html;
35
+ out = out.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, ' ');
36
+ out = out.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, ' ');
37
+ out = out.replace(/<noscript\b[^>]*>[\s\S]*?<\/noscript>/gi, ' ');
38
+ out = out.replace(/<(?:br|hr|p|div|li|h[1-6]|tr|td|th|section|article)\b[^>]*>/gi, '\n');
39
+ out = out.replace(/<\/(?:p|div|li|h[1-6]|tr|section|article)>/gi, '\n');
40
+ out = out.replace(/<[^>]+>/g, ' ');
41
+ out = out
42
+ .replace(/&nbsp;/gi, ' ')
43
+ .replace(/&amp;/gi, '&')
44
+ .replace(/&lt;/gi, '<')
45
+ .replace(/&gt;/gi, '>')
46
+ .replace(/&quot;/gi, '"')
47
+ .replace(/&#39;/gi, "'");
48
+ out = out.replace(/[ \t]+/g, ' ');
49
+ out = out.replace(/\n[ \t]+/g, '\n');
50
+ out = out.replace(/\n{3,}/g, '\n\n');
51
+ return out.trim();
52
+ }
53
+
54
+ export async function fetchUrl(rawUrl) {
55
+ let url;
56
+ try {
57
+ url = new URL(rawUrl);
58
+ } catch {
59
+ throw new Error(`invalid URL: ${rawUrl}`);
60
+ }
61
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
62
+ throw new Error(`unsupported protocol: ${url.protocol}`);
63
+ }
64
+ if (isForbiddenHost(url.hostname)) {
65
+ throw new Error(`refusing to fetch private-network or loopback host: ${url.hostname}`);
66
+ }
67
+
68
+ const controller = new AbortController();
69
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
70
+ let res;
71
+ try {
72
+ res = await fetch(url.toString(), {
73
+ method: 'GET',
74
+ headers: {
75
+ 'User-Agent': USER_AGENT,
76
+ Accept: 'text/html,text/plain,application/json,application/xhtml+xml,*/*;q=0.8',
77
+ },
78
+ redirect: 'follow',
79
+ signal: controller.signal,
80
+ });
81
+ } catch (err) {
82
+ clearTimeout(timer);
83
+ if (err.name === 'AbortError') throw new Error(`timeout after ${FETCH_TIMEOUT_MS}ms: ${url.hostname}`);
84
+ throw new Error(`network error: ${err.message}`);
85
+ }
86
+ clearTimeout(timer);
87
+
88
+ const contentType = (res.headers.get('content-type') || '').toLowerCase();
89
+ const isText =
90
+ contentType.startsWith('text/') ||
91
+ contentType.includes('json') ||
92
+ contentType.includes('xml') ||
93
+ contentType === '';
94
+ if (!isText) {
95
+ return `[${res.status} ${res.statusText}] ${url.toString()}\ncontent-type: ${contentType}\n(binary content refused; use describe_image for images)`;
96
+ }
97
+
98
+ const reader = res.body?.getReader?.();
99
+ let received = 0;
100
+ const chunks = [];
101
+ if (reader) {
102
+ while (true) {
103
+ const { value, done } = await reader.read();
104
+ if (done) break;
105
+ received += value.byteLength;
106
+ if (received > MAX_BODY_BYTES) {
107
+ chunks.push(value.subarray(0, MAX_BODY_BYTES - (received - value.byteLength)));
108
+ break;
109
+ }
110
+ chunks.push(value);
111
+ }
112
+ } else {
113
+ const buf = Buffer.from(await res.arrayBuffer());
114
+ chunks.push(buf.subarray(0, MAX_BODY_BYTES));
115
+ received = buf.byteLength;
116
+ }
117
+ const raw = Buffer.concat(chunks.map((c) => (c instanceof Buffer ? c : Buffer.from(c)))).toString('utf8');
118
+
119
+ let body;
120
+ if (contentType.includes('json')) {
121
+ body = raw;
122
+ } else if (contentType.includes('html') || /<html[^>]*>/i.test(raw)) {
123
+ body = htmlToText(raw);
124
+ } else {
125
+ body = raw;
126
+ }
127
+
128
+ const header = `[${res.status} ${res.statusText}] ${url.toString()}\ncontent-type: ${contentType || 'unknown'}\nbytes: ${received}${received > MAX_BODY_BYTES ? ' (truncated)' : ''}\n---\n`;
129
+ return header + body;
130
+ }
@@ -0,0 +1,107 @@
1
+ // lib/tools/web-search.mjs
2
+ // Web search tool. In hosted mode the request goes to the Polylogic search
3
+ // proxy at polylogicai.com/api/polycode/search, which runs the query against
4
+ // a real search backend (Tavily / Brave) with a server-side key so the user
5
+ // does not need to provision anything. In BYOK mode a user-supplied
6
+ // TAVILY_API_KEY is used directly if present; otherwise we still fall
7
+ // through to the Polylogic proxy.
8
+ //
9
+ // Returns a formatted text block with up to 8 (title, url, snippet) rows so
10
+ // the model can read it as plain context. Never dumps raw HTML.
11
+
12
+ const DEFAULT_PROXY_URL = 'https://polylogicai.com/api/polycode/search';
13
+ const MAX_RESULTS = 8;
14
+ const SEARCH_TIMEOUT_MS = 15_000;
15
+
16
+ function formatResults(results, query) {
17
+ if (!Array.isArray(results) || results.length === 0) {
18
+ return `web_search("${query}")\n(no results)`;
19
+ }
20
+ const lines = [`web_search("${query}")`, ''];
21
+ let i = 1;
22
+ for (const r of results.slice(0, MAX_RESULTS)) {
23
+ const title = String(r.title || r.name || '').slice(0, 200);
24
+ const url = String(r.url || r.link || '').slice(0, 400);
25
+ const snippet = String(r.content || r.snippet || r.description || '').replace(/\s+/g, ' ').slice(0, 300);
26
+ lines.push(`${i}. ${title}`);
27
+ if (url) lines.push(` ${url}`);
28
+ if (snippet) lines.push(` ${snippet}`);
29
+ lines.push('');
30
+ i++;
31
+ }
32
+ return lines.join('\n');
33
+ }
34
+
35
+ async function searchViaTavilyDirect(query, apiKey) {
36
+ const controller = new AbortController();
37
+ const timer = setTimeout(() => controller.abort(), SEARCH_TIMEOUT_MS);
38
+ try {
39
+ const res = await fetch('https://api.tavily.com/search', {
40
+ method: 'POST',
41
+ headers: {
42
+ 'Content-Type': 'application/json',
43
+ Authorization: `Bearer ${apiKey}`,
44
+ },
45
+ body: JSON.stringify({
46
+ query,
47
+ max_results: MAX_RESULTS,
48
+ search_depth: 'basic',
49
+ }),
50
+ signal: controller.signal,
51
+ });
52
+ const text = await res.text();
53
+ if (!res.ok) throw new Error(`tavily ${res.status}: ${text.slice(0, 200)}`);
54
+ const data = JSON.parse(text);
55
+ return formatResults(data.results || [], query);
56
+ } finally {
57
+ clearTimeout(timer);
58
+ }
59
+ }
60
+
61
+ async function searchViaPolylogicProxy(query) {
62
+ const { getOrCreateInstallId } = await import('../polycode-hosted-client.mjs');
63
+ const endpoint = process.env.POLYCODE_SEARCH_URL || DEFAULT_PROXY_URL;
64
+ const controller = new AbortController();
65
+ const timer = setTimeout(() => controller.abort(), SEARCH_TIMEOUT_MS);
66
+ try {
67
+ const res = await fetch(endpoint, {
68
+ method: 'POST',
69
+ headers: {
70
+ 'Content-Type': 'application/json',
71
+ 'X-Polycode-Install-ID': getOrCreateInstallId(),
72
+ 'X-Polycode-Version': 'search',
73
+ },
74
+ body: JSON.stringify({ query, max_results: MAX_RESULTS }),
75
+ signal: controller.signal,
76
+ });
77
+ const text = await res.text();
78
+ if (!res.ok) {
79
+ let msg;
80
+ try { msg = JSON.parse(text).error; } catch { msg = text; }
81
+ throw new Error(`polycode search proxy ${res.status}: ${String(msg).slice(0, 300)}`);
82
+ }
83
+ const data = JSON.parse(text);
84
+ if (Array.isArray(data.results)) return formatResults(data.results, query);
85
+ if (typeof data === 'string') return data;
86
+ return formatResults(data, query);
87
+ } finally {
88
+ clearTimeout(timer);
89
+ }
90
+ }
91
+
92
+ export async function webSearch(query) {
93
+ const q = String(query || '').trim();
94
+ if (!q) throw new Error('empty query');
95
+
96
+ const byokKey = process.env.TAVILY_API_KEY || '';
97
+ if (byokKey) {
98
+ try {
99
+ return await searchViaTavilyDirect(q, byokKey);
100
+ } catch (err) {
101
+ // Fall through to the hosted proxy on direct-provider failure.
102
+ const hosted = await searchViaPolylogicProxy(q);
103
+ return `${hosted}\n\n(note: direct tavily call failed: ${err.message})`;
104
+ }
105
+ }
106
+ return searchViaPolylogicProxy(q);
107
+ }