termsearch 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "termsearch",
3
+ "version": "0.3.0",
4
+ "description": "Personal search engine for Termux/Linux/macOS — zero-config, privacy-first, AI-optional",
5
+ "type": "module",
6
+ "bin": {
7
+ "termsearch": "./bin/termsearch.js"
8
+ },
9
+ "main": "./src/server.js",
10
+ "files": [
11
+ "bin/",
12
+ "src/",
13
+ "scripts/",
14
+ "frontend/dist/",
15
+ "README.md",
16
+ "LICENSE",
17
+ "config.example.json"
18
+ ],
19
+ "scripts": {
20
+ "start": "node ./bin/termsearch.js",
21
+ "dev": "node --watch ./src/server.js",
22
+ "postinstall": "node scripts/postinstall.js"
23
+ },
24
+ "dependencies": {
25
+ "express": "^4.18.2"
26
+ },
27
+ "engines": {
28
+ "node": ">=18.0.0"
29
+ },
30
+ "keywords": [
31
+ "search",
32
+ "metasearch",
33
+ "personal",
34
+ "terminal",
35
+ "termux",
36
+ "privacy",
37
+ "ai",
38
+ "duckduckgo",
39
+ "wikipedia",
40
+ "self-hosted"
41
+ ],
42
+ "author": "DAG",
43
+ "license": "MIT",
44
+ "repository": {
45
+ "type": "git",
46
+ "url": "https://github.com/DioNanos/termsearch"
47
+ }
48
+ }
@@ -0,0 +1,84 @@
1
+ #!/usr/bin/env node
2
+ // Post-install check — runs after npm install -g termsearch
3
+ // Never throws: warnings only, install must always succeed.
4
+
5
+ import { execSync } from 'child_process';
6
+ import os from 'os';
7
+ import fs from 'fs';
8
+ import path from 'path';
9
+
10
+ const RESET = '\x1b[0m';
11
+ const GREEN = '\x1b[32m';
12
+ const YELLOW = '\x1b[33m';
13
+ const CYAN = '\x1b[36m';
14
+ const BOLD = '\x1b[1m';
15
+
16
+ function ok(msg) { console.log(` ${GREEN}✓${RESET} ${msg}`); }
17
+ function warn(msg) { console.log(` ${YELLOW}⚠${RESET} ${msg}`); }
18
+ function info(msg) { console.log(` ${CYAN}→${RESET} ${msg}`); }
19
+
20
+ try {
21
+ console.log('');
22
+ console.log(`${BOLD} TermSearch — post-install check${RESET}`);
23
+ console.log('');
24
+
25
+ // ── Node.js version ──────────────────────────────────────────────────────
26
+ const [major] = process.versions.node.split('.').map(Number);
27
+ if (major >= 18) {
28
+ ok(`Node.js ${process.versions.node}`);
29
+ } else {
30
+ warn(`Node.js ${process.versions.node} detected — requires ≥ 18.0.0`);
31
+ warn('Please upgrade: https://nodejs.org');
32
+ }
33
+
34
+ // ── Platform ─────────────────────────────────────────────────────────────
35
+ const isTermux = process.env.PREFIX?.includes('com.termux') ||
36
+ fs.existsSync('/data/data/com.termux');
37
+ if (isTermux) {
38
+ ok('Platform: Termux (Android) — compatible');
39
+ } else if (process.platform === 'darwin') {
40
+ ok('Platform: macOS — compatible');
41
+ } else if (process.platform === 'linux') {
42
+ ok('Platform: Linux — compatible');
43
+ } else {
44
+ warn(`Platform: ${process.platform} — not officially tested`);
45
+ }
46
+
47
+ // ── Home dir / data dir writable ─────────────────────────────────────────
48
+ const dataDir = path.join(os.homedir(), '.termsearch');
49
+ try {
50
+ fs.mkdirSync(dataDir, { recursive: true });
51
+ fs.accessSync(dataDir, fs.constants.W_OK);
52
+ ok(`Data dir: ${dataDir}`);
53
+ } catch {
54
+ warn(`Cannot write to ${dataDir} — check permissions`);
55
+ }
56
+
57
+ // ── Systemd hint (Linux only, non-Termux) ─────────────────────────────────
58
+ if (process.platform === 'linux' && !isTermux) {
59
+ try {
60
+ execSync('systemctl --user status > /dev/null 2>&1', { stdio: 'ignore' });
61
+ ok('systemd --user: available (autostart supported)');
62
+ } catch {
63
+ warn('systemd --user: not available — autostart will use manual method');
64
+ }
65
+ }
66
+
67
+ // ── Termux:Boot hint ──────────────────────────────────────────────────────
68
+ if (isTermux) {
69
+ const bootDir = path.join(os.homedir(), '.termux', 'boot');
70
+ if (fs.existsSync(bootDir)) {
71
+ ok('Termux:Boot: found — autostart supported');
72
+ } else {
73
+ info('Termux:Boot: install the app for autostart support');
74
+ info(' → F-Droid: search "Termux:Boot"');
75
+ }
76
+ }
77
+
78
+ console.log('');
79
+ info(`Run ${BOLD}termsearch${RESET}${CYAN} then open http://localhost:3000`);
80
+ console.log('');
81
+
82
+ } catch {
83
+ // Never block the install
84
+ }
@@ -0,0 +1,163 @@
1
+ // AI orchestrator — coordinates the 2-phase agentic summary flow
2
+
3
+ import { call, stream, classifyAiError } from './providers/openai-compat.js';
4
+ import { buildFetchDecisionPrompt, parseFetchDecision, buildAgenticSummaryPrompt, extractAiSites, scoreResultsFromSummary } from './summary.js';
5
+ import { batchFetch } from '../fetch/document.js';
6
+
7
+ function mapAiErrorCode(error) { return classifyAiError(error); }
8
+ function mapAiErrorMessage(error) {
9
+ switch (classifyAiError(error)) {
10
+ case 'ai_provider_auth': return 'AI provider authentication failed. Check your API key in Settings.';
11
+ case 'ai_rate_limited_provider': return 'AI provider rate limited. Try again shortly.';
12
+ case 'ai_timeout_provider': return 'AI provider timed out. Try again shortly.';
13
+ case 'ai_provider_unavailable': case 'ai_provider_unreachable': return 'AI provider temporarily unavailable.';
14
+ default: return 'AI unavailable. Check endpoint in Settings.';
15
+ }
16
+ }
17
+ function mapAiErrorStatus(error) {
18
+ switch (classifyAiError(error)) {
19
+ case 'ai_rate_limited_provider': return 429;
20
+ case 'ai_timeout_provider': return 504;
21
+ case 'ai_provider_auth': return 503;
22
+ default: return 502;
23
+ }
24
+ }
25
+
26
+ // Refine a query using AI (non-streaming, quick call)
27
+ // Returns { content, model } or throws
28
+ export async function aiQueryRefine(prompt, aiConfig) {
29
+ return call(prompt, {
30
+ apiBase: aiConfig.api_base,
31
+ apiKey: aiConfig.api_key,
32
+ model: aiConfig.model,
33
+ maxTokens: 200,
34
+ timeoutMs: 5000,
35
+ jsonMode: true,
36
+ });
37
+ }
38
+
39
+ // Generate AI summary with optional streaming
40
+ // If onToken is provided, streams tokens as they arrive
41
+ // Returns { summary, sites, fetchedCount, fetchedUrls, model, error? }
42
+ export async function generateSummary({
43
+ query,
44
+ lang = 'en-US',
45
+ results = [],
46
+ session = [],
47
+ onToken = null,
48
+ docCache = null,
49
+ }, aiConfig) {
50
+ if (!aiConfig?.enabled || !aiConfig?.api_base || !aiConfig?.model) {
51
+ return { error: 'ai_not_configured', message: 'AI not configured. Add endpoint in Settings.' };
52
+ }
53
+
54
+ const ai = {
55
+ apiBase: aiConfig.api_base,
56
+ apiKey: aiConfig.api_key,
57
+ model: aiConfig.model,
58
+ maxTokens: aiConfig.max_tokens || 1200,
59
+ timeoutMs: aiConfig.timeout_ms || 90_000,
60
+ };
61
+
62
+ try {
63
+ // Phase 1: AI decides which URLs to fetch
64
+ const phase1Prompt = buildFetchDecisionPrompt({
65
+ query,
66
+ results,
67
+ maxFetch: aiConfig.fetch_soft_cap || 10,
68
+ session,
69
+ });
70
+
71
+ let phase1Result = null;
72
+ try {
73
+ phase1Result = await call(phase1Prompt, {
74
+ ...ai,
75
+ maxTokens: 300,
76
+ timeoutMs: 8000,
77
+ jsonMode: true,
78
+ });
79
+ } catch {
80
+ // Phase 1 failure is non-fatal — fall back to fetching top results
81
+ }
82
+
83
+ const allResultUrls = results.slice(0, 10).map((r) => r.url).filter(Boolean);
84
+ const { urls: urlsToFetch } = parseFetchDecision(phase1Result?.content, allResultUrls);
85
+
86
+ // Fetch the selected URLs
87
+ let documents = [];
88
+ if (urlsToFetch.length > 0) {
89
+ const fetched = await batchFetch(
90
+ urlsToFetch.slice(0, aiConfig.fetch_hard_cap || 15),
91
+ { timeoutMs: 12000, docCache }
92
+ );
93
+ documents = fetched.filter((d) => d.status === 'ok' && d.content);
94
+ }
95
+
96
+ // Fallback: if no docs fetched, try top 2 results directly
97
+ if (documents.length === 0 && allResultUrls.length > 0) {
98
+ const fallback = await batchFetch(allResultUrls.slice(0, 2), { timeoutMs: 10000, docCache });
99
+ documents = fallback.filter((d) => d.status === 'ok' && d.content);
100
+ }
101
+
102
+ // Phase 2: synthesize summary
103
+ const phase2Prompt = buildAgenticSummaryPrompt({ query, lang, results, documents, session });
104
+
105
+ let summaryText = '';
106
+ let summaryModel = ai.model;
107
+
108
+ if (typeof onToken === 'function') {
109
+ // Streaming mode
110
+ const streamResult = await stream(phase2Prompt, onToken, {
111
+ ...ai,
112
+ systemPrompt: 'You are a search assistant. Write your answer directly. Do not include reasoning or thinking.',
113
+ });
114
+ summaryText = streamResult.content;
115
+ summaryModel = streamResult.model;
116
+ } else {
117
+ // Non-streaming mode
118
+ const result = await call(phase2Prompt, {
119
+ ...ai,
120
+ systemPrompt: 'You are a search assistant. Write your answer directly. Do not include reasoning or thinking.',
121
+ });
122
+ summaryText = result.content;
123
+ summaryModel = result.model;
124
+ }
125
+
126
+ const sites = extractAiSites(summaryText);
127
+ const scoredResults = scoreResultsFromSummary(results, summaryText, urlsToFetch);
128
+
129
+ return {
130
+ summary: summaryText,
131
+ sites,
132
+ fetchedCount: documents.length,
133
+ fetchedUrls: urlsToFetch,
134
+ scoredResults,
135
+ model: summaryModel,
136
+ };
137
+ } catch (error) {
138
+ return {
139
+ error: mapAiErrorCode(error),
140
+ message: mapAiErrorMessage(error),
141
+ status: mapAiErrorStatus(error),
142
+ };
143
+ }
144
+ }
145
+
146
+ // Test the AI connection with a simple completion
147
+ export async function testConnection(aiConfig) {
148
+ if (!aiConfig?.api_base || !aiConfig?.model) {
149
+ return { ok: false, error: 'Missing api_base or model' };
150
+ }
151
+ try {
152
+ const result = await call('Say "OK" and nothing else.', {
153
+ apiBase: aiConfig.api_base,
154
+ apiKey: aiConfig.api_key,
155
+ model: aiConfig.model,
156
+ maxTokens: 10,
157
+ timeoutMs: 10000,
158
+ });
159
+ return { ok: true, model: result.model, response: result.content.slice(0, 50) };
160
+ } catch (error) {
161
+ return { ok: false, error: mapAiErrorMessage(error), code: mapAiErrorCode(error) };
162
+ }
163
+ }
@@ -0,0 +1,255 @@
1
+ // Generic OpenAI-compatible API client
2
+ // Works with: Ollama, llama.cpp, LM Studio, OpenAI, Groq, Chutes.ai, any compatible endpoint
3
+
4
+ const RATE_LIMIT_RETRY_DELAYS_MS = [3000, 6000];
5
+ const TRANSIENT_RETRY_DELAYS_MS = [1500, 3000];
6
+
7
+ const THINKING_START_RE = /^(?:Thinking Process:|Let me (?:analyze|think)|I (?:need to|will|should)|The user (?:is|wants|asked)|Looking at (?:the|these)|Analyzing (?:the|this))/i;
8
+
9
+ function stripThinkingPrefix(text) {
10
+ if (!THINKING_START_RE.test(text)) return text;
11
+ const answerStart = text.search(/\n\n(?=##|###|\*\*[A-Z]|[A-Z][a-zÀ-ÿ])/);
12
+ if (answerStart !== -1) return text.slice(answerStart).trim();
13
+ const parts = text.split(/\n\n+/);
14
+ const firstReal = parts.findIndex((p, i) => i > 0 && !/^\d+\.|^Let me|^I |^The user|^Thinking|^Analyzing/i.test(p.trim()));
15
+ if (firstReal > 0) return parts.slice(firstReal).join('\n\n').trim();
16
+ return text;
17
+ }
18
+
19
+ function extractThinking(raw) {
20
+ const thinkMatch = raw.match(/^\s*<think>([\s\S]*?)<\/think>\s*/);
21
+ if (thinkMatch) {
22
+ return { reasoning: thinkMatch[1].trim(), content: raw.slice(thinkMatch[0].length).trim() };
23
+ }
24
+ const blockMatch = raw.match(/^(?:Thinking Process:|Let me|I need to|The user|Looking at)[\s\S]*?(\{[\s\S]*\})\s*$/);
25
+ if (blockMatch) {
26
+ return { reasoning: raw.slice(0, raw.lastIndexOf(blockMatch[1])).trim(), content: blockMatch[1].trim() };
27
+ }
28
+ return { reasoning: '', content: raw };
29
+ }
30
+
31
+ function buildAiError(code, message) {
32
+ const error = new Error(message);
33
+ error.aiCode = code;
34
+ return error;
35
+ }
36
+
37
+ export function classifyAiError(error) {
38
+ const explicit = String(error?.aiCode || '').trim();
39
+ if (explicit) return explicit;
40
+ const raw = String(error?.message || error || '').trim().toLowerCase();
41
+ if (!raw) return 'ai_unavailable';
42
+ if (raw.includes(' 401') || raw.includes(' 403') || raw.includes('authentication failed') || raw.includes('invalid token')) return 'ai_provider_auth';
43
+ if (raw.includes(' 429') || raw.includes('rate limit') || raw.includes('too many requests')) return 'ai_rate_limited_provider';
44
+ if (raw.includes('aborted') || raw.includes('timeout') || raw.includes('timed out')) return 'ai_timeout_provider';
45
+ if (raw.includes('fetch failed') || raw.includes('networkerror') || raw.includes('network error')) return 'ai_provider_unreachable';
46
+ if (raw.includes(' 500') || raw.includes(' 502') || raw.includes(' 503') || raw.includes(' 504')) return 'ai_provider_unavailable';
47
+ return 'ai_unavailable';
48
+ }
49
+
50
+ function shouldRetry(error) {
51
+ const code = classifyAiError(error);
52
+ return code === 'ai_rate_limited_provider' || code === 'ai_timeout_provider' || code === 'ai_provider_unreachable' || code === 'ai_provider_unavailable';
53
+ }
54
+
55
+ function sleep(ms) {
56
+ return new Promise((resolve) => setTimeout(resolve, ms));
57
+ }
58
+
59
+ // Single non-streaming call with retry logic
60
+ // Returns { content, reasoning, model } or throws
61
+ export async function call(prompt, {
62
+ apiBase,
63
+ apiKey,
64
+ model,
65
+ maxTokens = 1200,
66
+ timeoutMs = 90_000,
67
+ systemPrompt = null,
68
+ jsonMode = false,
69
+ temperature = 0.3,
70
+ } = {}) {
71
+ const base = String(apiBase || '').replace(/\/$/, '');
72
+ if (!base || !model) throw buildAiError('ai_unavailable', 'AI not configured');
73
+
74
+ const messages = systemPrompt
75
+ ? [{ role: 'system', content: systemPrompt }, { role: 'user', content: prompt }]
76
+ : [{ role: 'user', content: prompt }];
77
+ const bodyBase = { messages, max_tokens: maxTokens, temperature };
78
+ if (jsonMode) bodyBase.response_format = { type: 'json_object' };
79
+
80
+ const headers = { 'Content-Type': 'application/json' };
81
+ if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`;
82
+
83
+ let rateLimitAttempt = 0;
84
+ let transientAttempt = 0;
85
+
86
+ while (true) {
87
+ const ac = new AbortController();
88
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
89
+ try {
90
+ const response = await fetch(`${base}/chat/completions`, {
91
+ method: 'POST',
92
+ headers,
93
+ body: JSON.stringify({ ...bodyBase, model }),
94
+ signal: ac.signal,
95
+ });
96
+
97
+ if (response.status === 401 || response.status === 403) {
98
+ throw buildAiError('ai_provider_auth', `ai ${response.status}: authentication failed`);
99
+ }
100
+ if (response.status === 429) {
101
+ if (rateLimitAttempt < RATE_LIMIT_RETRY_DELAYS_MS.length) {
102
+ const retryAfter = Number(response.headers.get('retry-after') || 0);
103
+ const delay = retryAfter > 0 ? retryAfter * 1000 : RATE_LIMIT_RETRY_DELAYS_MS[rateLimitAttempt++];
104
+ console.warn(`[AI] rate limited, retrying in ${delay}ms`);
105
+ await sleep(delay);
106
+ continue;
107
+ }
108
+ throw buildAiError('ai_rate_limited_provider', 'ai 429: rate limit exceeded');
109
+ }
110
+ if (!response.ok) {
111
+ const errBody = await response.text().catch(() => '');
112
+ const err = new Error(`ai ${response.status}: ${errBody.slice(0, 220)}`);
113
+ if (shouldRetry(err) && classifyAiError(err) !== 'ai_rate_limited_provider' && transientAttempt < TRANSIENT_RETRY_DELAYS_MS.length) {
114
+ const delay = TRANSIENT_RETRY_DELAYS_MS[transientAttempt++];
115
+ await sleep(delay);
116
+ continue;
117
+ }
118
+ throw err;
119
+ }
120
+
121
+ const data = await response.json();
122
+ const msg = data.choices?.[0]?.message || {};
123
+ const rawContent = stripThinkingPrefix((msg.content || '').trim());
124
+ const { reasoning, content } = extractThinking(rawContent);
125
+ return {
126
+ content,
127
+ reasoning: (msg.reasoning_content || reasoning || '').trim(),
128
+ model: data.model || model,
129
+ };
130
+ } catch (error) {
131
+ clearTimeout(timer);
132
+ if (shouldRetry(error) && classifyAiError(error) !== 'ai_rate_limited_provider' && transientAttempt < TRANSIENT_RETRY_DELAYS_MS.length) {
133
+ const delay = TRANSIENT_RETRY_DELAYS_MS[transientAttempt++];
134
+ console.warn(`[AI] transient error, retrying in ${delay}ms:`, error.message);
135
+ await sleep(delay);
136
+ continue;
137
+ }
138
+ throw error;
139
+ } finally {
140
+ clearTimeout(timer);
141
+ }
142
+ }
143
+ }
144
+
145
+ // Streaming call — emits tokens via onToken(chunk, fullContent) callback
146
+ // Returns { content, reasoning, model } when done
147
+ export async function stream(prompt, onToken, {
148
+ apiBase,
149
+ apiKey,
150
+ model,
151
+ maxTokens = 1200,
152
+ timeoutMs = 90_000,
153
+ systemPrompt = 'You are a search assistant. Write your answer directly. Do not include reasoning or thinking.',
154
+ temperature = 0.3,
155
+ } = {}) {
156
+ const base = String(apiBase || '').replace(/\/$/, '');
157
+ if (!base || !model) throw buildAiError('ai_unavailable', 'AI not configured');
158
+
159
+ const messages = systemPrompt
160
+ ? [{ role: 'system', content: systemPrompt }, { role: 'user', content: prompt }]
161
+ : [{ role: 'user', content: prompt }];
162
+ const body = JSON.stringify({ messages, max_tokens: maxTokens, temperature, model, stream: true });
163
+
164
+ const headers = { 'Content-Type': 'application/json' };
165
+ if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`;
166
+
167
+ let rateLimitAttempt = 0;
168
+ let transientAttempt = 0;
169
+
170
+ while (true) {
171
+ const ac = new AbortController();
172
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
173
+ let emittedAny = false;
174
+ try {
175
+ const response = await fetch(`${base}/chat/completions`, {
176
+ method: 'POST',
177
+ headers,
178
+ body,
179
+ signal: ac.signal,
180
+ });
181
+
182
+ if (response.status === 401 || response.status === 403) {
183
+ throw buildAiError('ai_provider_auth', `ai ${response.status}: authentication failed`);
184
+ }
185
+ if (response.status === 429) {
186
+ if (rateLimitAttempt < RATE_LIMIT_RETRY_DELAYS_MS.length) {
187
+ const retryAfter = Number(response.headers.get('retry-after') || 0);
188
+ const delay = retryAfter > 0 ? retryAfter * 1000 : RATE_LIMIT_RETRY_DELAYS_MS[rateLimitAttempt++];
189
+ await sleep(delay);
190
+ continue;
191
+ }
192
+ return { content: '', reasoning: '', model };
193
+ }
194
+ if (!response.ok) {
195
+ const errBody = await response.text().catch(() => '');
196
+ const err = new Error(`ai ${response.status}: ${errBody.slice(0, 220)}`);
197
+ if (shouldRetry(err) && !emittedAny && transientAttempt < TRANSIENT_RETRY_DELAYS_MS.length) {
198
+ await sleep(TRANSIENT_RETRY_DELAYS_MS[transientAttempt++]);
199
+ continue;
200
+ }
201
+ throw err;
202
+ }
203
+
204
+ const reader = response.body.getReader();
205
+ const decoder = new TextDecoder();
206
+ let fullContent = '';
207
+ let fullReasoning = '';
208
+ let buffer = '';
209
+ let inThinking = false;
210
+ let streamModel = '';
211
+
212
+ while (true) {
213
+ const { done, value } = await reader.read();
214
+ if (done) break;
215
+ buffer += decoder.decode(value, { stream: true });
216
+ const lines = buffer.split('\n');
217
+ buffer = lines.pop() || '';
218
+ for (const line of lines) {
219
+ if (!line.startsWith('data: ')) continue;
220
+ const data = line.slice(6).trim();
221
+ if (data === '[DONE]') continue;
222
+ try {
223
+ const parsed = JSON.parse(data);
224
+ if (!streamModel && parsed.model) streamModel = parsed.model;
225
+ const delta = parsed.choices?.[0]?.delta || {};
226
+ if (delta.reasoning_content) fullReasoning += delta.reasoning_content;
227
+ if (delta.content) {
228
+ let chunk = delta.content;
229
+ if (inThinking) {
230
+ const end = chunk.indexOf('</think>');
231
+ if (end !== -1) { fullReasoning += chunk.slice(0, end); chunk = chunk.slice(end + 8); inThinking = false; }
232
+ else { fullReasoning += chunk; continue; }
233
+ } else if (chunk.includes('<think>')) {
234
+ const start = chunk.indexOf('<think>');
235
+ const end = chunk.indexOf('</think>');
236
+ if (end !== -1) { fullReasoning += chunk.slice(start + 7, end); chunk = chunk.slice(0, start) + chunk.slice(end + 8); }
237
+ else { inThinking = true; fullReasoning += chunk.slice(start + 7); chunk = chunk.slice(0, start); }
238
+ }
239
+ if (chunk) { emittedAny = true; fullContent += chunk; onToken(chunk, fullContent); }
240
+ }
241
+ } catch { /* ignore SSE parse errors */ }
242
+ }
243
+ }
244
+ clearTimeout(timer);
245
+ return { content: stripThinkingPrefix(fullContent.trim()), reasoning: fullReasoning.trim(), model: streamModel || model };
246
+ } catch (error) {
247
+ clearTimeout(timer);
248
+ if (!emittedAny && shouldRetry(error) && classifyAiError(error) !== 'ai_rate_limited_provider' && transientAttempt < TRANSIENT_RETRY_DELAYS_MS.length) {
249
+ await sleep(TRANSIENT_RETRY_DELAYS_MS[transientAttempt++]);
250
+ continue;
251
+ }
252
+ throw error;
253
+ }
254
+ }
255
+ }
@@ -0,0 +1,54 @@
1
+ // AI query refinement — Phase 0 (parallel with search, optional)
2
+
3
+ import { call } from './providers/openai-compat.js';
4
+
5
+ function buildQueryInterpretPrompt({ query, lang }) {
6
+ const langName = {
7
+ 'it-IT': 'Italian', 'en-US': 'English', 'es-ES': 'Spanish',
8
+ 'fr-FR': 'French', 'de-DE': 'German', 'pt-PT': 'Portuguese',
9
+ }[lang] || 'English';
10
+
11
+ return `Analyze this search query and respond in JSON only.
12
+
13
+ Query: "${query}"
14
+ User language: ${langName}
15
+
16
+ Respond with this exact JSON structure:
17
+ {
18
+ "refined_query": "improved version of the query (or same if already good)",
19
+ "intent": "one of: definition, how_to, news, research, comparison, navigation, other",
20
+ "also_search": ["optional alternative query 1", "optional alternative query 2"]
21
+ }
22
+
23
+ Rules:
24
+ - refined_query: fix typos, expand acronyms, clarify ambiguous terms — keep it concise
25
+ - intent: classify what the user is looking for
26
+ - also_search: at most 2 useful variant queries, empty array if not applicable
27
+ - JSON only, no explanation`;
28
+ }
29
+
30
+ // Returns { refined_query, intent, also_search } or null on failure
31
+ export async function refineQuery({ query, lang = 'en-US' }, aiConfig) {
32
+ if (!aiConfig?.enabled || !aiConfig?.api_base || !aiConfig?.model) return null;
33
+
34
+ try {
35
+ const result = await call(buildQueryInterpretPrompt({ query, lang }), {
36
+ apiBase: aiConfig.api_base,
37
+ apiKey: aiConfig.api_key,
38
+ model: aiConfig.model,
39
+ maxTokens: 200,
40
+ timeoutMs: 5000,
41
+ jsonMode: true,
42
+ });
43
+
44
+ if (!result?.content) return null;
45
+ const parsed = JSON.parse(result.content);
46
+ return {
47
+ refined_query: String(parsed.refined_query || query).slice(0, 240),
48
+ intent: String(parsed.intent || 'other'),
49
+ also_search: Array.isArray(parsed.also_search) ? parsed.also_search.slice(0, 2).map(String) : [],
50
+ };
51
+ } catch {
52
+ return null;
53
+ }
54
+ }