kiku-agent 1.3.0 → 1.3.1
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/README.md +7 -7
- package/package.json +6 -1
- package/server/agent/agentCore.js +2 -2
- package/server/agent/catalogEntityResolver.js +8 -0
- package/server/agent/cli.js +4 -0
- package/server/agent/ink/App.jsx +348 -0
- package/server/agent/ink/Panel.jsx +54 -0
- package/server/agent/ink/PromptInput.jsx +321 -0
- package/server/agent/ink/TaskPlanPanel.jsx +88 -0
- package/server/agent/ink/ThinkingPanel.jsx +68 -0
- package/server/agent/ink/ToolResult.jsx +133 -0
- package/server/agent/ink/main.jsx +195 -0
- package/server/agent/ink/useEventBus.js +218 -0
- package/server/agent/memory/contextAllocator.js +214 -0
- package/server/agent/memory/hierarchicalStore.js +523 -0
- package/server/agent/memory/reflectionWorker.js +110 -0
- package/server/agent/prompts.js +1 -1
- package/server/agent/telemetry/hardwareTiers.js +332 -0
- package/server/agent/telemetry/runtimeWatchdog.js +66 -0
- package/server/agent/telemetry/techPowerUpClient.js +682 -0
- package/server/agent/telemetry/techpowerup-mcp.json +22 -0
- package/server/agent/websearch/adapters/duckduckgo.js +97 -0
- package/server/agent/websearch/adapters/jina.js +77 -0
- package/server/agent/websearch/adapters/searxng.js +57 -0
- package/server/agent/websearch/core/canonical.js +89 -0
- package/server/agent/websearch/core/fusion.js +81 -0
- package/server/agent/websearch/core/types.js +29 -0
- package/server/agent/websearch/extract.js +605 -0
- package/server/agent/websearch/index.js +43 -0
- package/server/agent/websearch/orchestrator.js +266 -0
- package/server/agent/websearch/resilience/breaker.js +97 -0
- package/server/agent/websearch/resilience/cache.js +59 -0
- package/server/agent/websearch/resilience/limiter.js +93 -0
- package/server/agent/websearch/security/filter.js +36 -0
- package/server/agent/websearch/security/ssrf.js +289 -0
- package/server/agent/websearch/telemetry.js +71 -0
- package/server/apiRouter.js +7 -11
- package/server/routes/agent.js +55 -0
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { searchDuckDuckGo } from './adapters/duckduckgo.js';
|
|
2
|
+
import { searchSearXNG } from './adapters/searxng.js';
|
|
3
|
+
import { searchJina } from './adapters/jina.js';
|
|
4
|
+
import { fuseSearchResults } from './core/fusion.js';
|
|
5
|
+
import { defaultLimiters } from './resilience/limiter.js';
|
|
6
|
+
import { defaultBreakers } from './resilience/breaker.js';
|
|
7
|
+
import { defaultSearchCache, computeSearchCacheKey, TTL_GENERAL_MS, TTL_NEWS_MS } from './resilience/cache.js';
|
|
8
|
+
import { defaultDomainFilter } from './security/filter.js';
|
|
9
|
+
import { safeFetchWithPinning } from './security/ssrf.js';
|
|
10
|
+
import { defaultTelemetry } from './telemetry.js';
|
|
11
|
+
|
|
12
|
+
function cleanHtml(raw) {
|
|
13
|
+
if (!raw || typeof raw !== 'string') return '';
|
|
14
|
+
return raw
|
|
15
|
+
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, ' ')
|
|
16
|
+
.replace(/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi, ' ')
|
|
17
|
+
.replace(/<[^>]+>/g, ' ')
|
|
18
|
+
.replace(/\s+/g, ' ')
|
|
19
|
+
.trim();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class MetaSearchOrchestrator {
|
|
23
|
+
constructor(options = {}) {
|
|
24
|
+
this.limiters = options.limiters || defaultLimiters;
|
|
25
|
+
this.breakers = options.breakers || defaultBreakers;
|
|
26
|
+
this.cache = options.cache || defaultSearchCache;
|
|
27
|
+
this.filter = options.filter || defaultDomainFilter;
|
|
28
|
+
this.telemetry = options.telemetry || defaultTelemetry;
|
|
29
|
+
this.fallbackTimeoutMs = options.fallbackTimeoutMs || 2500;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
expandQuery(query) {
|
|
34
|
+
const raw = String(query || '').trim();
|
|
35
|
+
const expansions = [raw];
|
|
36
|
+
|
|
37
|
+
if (/[\u0E00-\u0E7F]/.test(raw)) {
|
|
38
|
+
|
|
39
|
+
if (raw.includes('จวีจิ้งอี')) {
|
|
40
|
+
expansions.push(raw.replace('จวีจิ้งอี', 'Ju Jingyi'));
|
|
41
|
+
expansions.push(raw.replace('จวีจิ้งอี', '鞠婧祎'));
|
|
42
|
+
}
|
|
43
|
+
} else if (/[\u4E00-\u9FA5]/.test(raw)) {
|
|
44
|
+
|
|
45
|
+
if (raw.includes('鞠婧祎')) {
|
|
46
|
+
expansions.push(raw.replace('鞠婧祎', 'Ju Jingyi'));
|
|
47
|
+
}
|
|
48
|
+
} else {
|
|
49
|
+
|
|
50
|
+
if (/ju\s*jingyi/i.test(raw)) {
|
|
51
|
+
expansions.push(raw.replace(/ju\s*jingyi/gi, '鞠婧祎'));
|
|
52
|
+
expansions.push(raw.replace(/ju\s*jingyi/gi, 'จวีจิ้งอี'));
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return [...new Set(expansions)].slice(0, 4);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
async _searchSingleEngine(engineName, query, options = {}) {
|
|
61
|
+
const breaker = this.breakers[engineName];
|
|
62
|
+
if (breaker && !breaker.canExecute()) {
|
|
63
|
+
return [];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const limiter = this.limiters[engineName];
|
|
67
|
+
if (limiter) {
|
|
68
|
+
try {
|
|
69
|
+
await limiter.acquire(1, 2000);
|
|
70
|
+
} catch (limiterErr) {
|
|
71
|
+
if (breaker) breaker.recordFailure(limiterErr);
|
|
72
|
+
return [];
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const timeoutMs = options.timeoutMs || this.fallbackTimeoutMs;
|
|
77
|
+
const controller = new AbortController();
|
|
78
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
let results = [];
|
|
82
|
+
if (engineName === 'searxng') {
|
|
83
|
+
results = await searchSearXNG(query, { ...options, timeoutMs, signal: controller.signal });
|
|
84
|
+
} else if (engineName === 'duckduckgo') {
|
|
85
|
+
results = await searchDuckDuckGo(query, { ...options, timeoutMs, signal: controller.signal });
|
|
86
|
+
} else if (engineName === 'jina') {
|
|
87
|
+
results = await searchJina(query, { ...options, timeoutMs, signal: controller.signal });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
clearTimeout(timer);
|
|
91
|
+
if (breaker) breaker.recordSuccess();
|
|
92
|
+
return results;
|
|
93
|
+
} catch (err) {
|
|
94
|
+
clearTimeout(timer);
|
|
95
|
+
if (breaker) {
|
|
96
|
+
breaker.recordFailure(err);
|
|
97
|
+
if (breaker.state === 'OPEN') {
|
|
98
|
+
this.telemetry.recordCircuitTrip(engineName);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return [];
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
async searchWithFallback(query, options = {}) {
|
|
107
|
+
const startTime = Date.now();
|
|
108
|
+
const limit = options.limit || 10;
|
|
109
|
+
const isNews = options.isNews === true;
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
const cacheKey = computeSearchCacheKey('meta', query, limit);
|
|
113
|
+
const cached = this.cache.get(cacheKey);
|
|
114
|
+
if (cached && !options.noCache) {
|
|
115
|
+
return { ...cached, fromCache: true };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
const engineChain = [];
|
|
120
|
+
if (process.env.SEARXNG_URL || options.searxngUrl) {
|
|
121
|
+
engineChain.push('searxng');
|
|
122
|
+
}
|
|
123
|
+
engineChain.push('duckduckgo');
|
|
124
|
+
engineChain.push('jina');
|
|
125
|
+
|
|
126
|
+
const collectedLists = [];
|
|
127
|
+
let fallbackTriggered = false;
|
|
128
|
+
let successfulEngine = null;
|
|
129
|
+
|
|
130
|
+
const engineSettled = await Promise.allSettled(
|
|
131
|
+
engineChain.map(engine => this._searchSingleEngine(engine, query, options))
|
|
132
|
+
);
|
|
133
|
+
const engineLists = engineChain.map((engine, index) => {
|
|
134
|
+
const settled = engineSettled[index];
|
|
135
|
+
return settled.status === 'fulfilled' && Array.isArray(settled.value) ? settled.value : [];
|
|
136
|
+
});
|
|
137
|
+
for (const list of engineLists) {
|
|
138
|
+
if (list.length > 0) {
|
|
139
|
+
collectedLists.push(list);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const firstEngineResults = engineLists[0] || [];
|
|
144
|
+
fallbackTriggered = engineChain.length > 1 && firstEngineResults.length < Math.ceil(limit / 2);
|
|
145
|
+
successfulEngine = engineChain.find((engine, index) => engineLists[index].length > 0) || null;
|
|
146
|
+
|
|
147
|
+
let fusedInputCount = collectedLists.reduce((sum, list) => sum + list.length, 0);
|
|
148
|
+
if (fusedInputCount < 3 && options.fanOut !== false) {
|
|
149
|
+
const expansions = this.expandQuery(query).slice(1);
|
|
150
|
+
const fanSettled = await Promise.allSettled(
|
|
151
|
+
expansions.map(expQuery => this._searchSingleEngine('duckduckgo', expQuery, options))
|
|
152
|
+
);
|
|
153
|
+
for (const settled of fanSettled) {
|
|
154
|
+
const list = settled.status === 'fulfilled' && Array.isArray(settled.value) ? settled.value : [];
|
|
155
|
+
if (list.length > 0) {
|
|
156
|
+
collectedLists.push(list);
|
|
157
|
+
fusedInputCount += list.length;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
const fused = fuseSearchResults(collectedLists, { limit, k: options.k || 60 });
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
const filtered = fused.filter(item => {
|
|
167
|
+
try {
|
|
168
|
+
const host = new URL(item.link).hostname;
|
|
169
|
+
return this.filter.isDomainAllowed(host);
|
|
170
|
+
} catch {
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
const latencyMs = Date.now() - startTime;
|
|
176
|
+
const zeroResult = filtered.length === 0;
|
|
177
|
+
|
|
178
|
+
this.telemetry.recordSearch({
|
|
179
|
+
latencyMs,
|
|
180
|
+
fallbackOccurred: fallbackTriggered,
|
|
181
|
+
zeroResult,
|
|
182
|
+
engine: successfulEngine
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
const response = {
|
|
186
|
+
status: zeroResult ? 'no_results' : 'success',
|
|
187
|
+
query,
|
|
188
|
+
count: filtered.length,
|
|
189
|
+
engine: successfulEngine || 'hybrid',
|
|
190
|
+
latencyMs,
|
|
191
|
+
results: filtered
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
this.cache.set(cacheKey, response, zeroResult ? 30_000 : (isNews ? TTL_NEWS_MS : TTL_GENERAL_MS));
|
|
195
|
+
|
|
196
|
+
return response;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
async executeSearch(query, options = {}) {
|
|
201
|
+
const searchResponse = await this.searchWithFallback(query, options);
|
|
202
|
+
if (searchResponse.status === 'no_results' || options.mode === 'bypass' || options.deepScrape !== true) {
|
|
203
|
+
return searchResponse;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
const topUrls = searchResponse.results.slice(0, 3);
|
|
208
|
+
const scrapePromises = topUrls.map(async (resItem) => {
|
|
209
|
+
try {
|
|
210
|
+
const pageRes = await safeFetchWithPinning(resItem.link, {
|
|
211
|
+
timeoutMs: 8000,
|
|
212
|
+
maxBytes: 256_000,
|
|
213
|
+
headers: {
|
|
214
|
+
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
|
|
215
|
+
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
if (!pageRes.ok) {
|
|
220
|
+
return {
|
|
221
|
+
title: resItem.title,
|
|
222
|
+
link: resItem.link,
|
|
223
|
+
snippet: resItem.snippet,
|
|
224
|
+
content: resItem.snippet,
|
|
225
|
+
scrapeStatus: 'failed'
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const html = await pageRes.text();
|
|
230
|
+
const text = cleanHtml(html);
|
|
231
|
+
|
|
232
|
+
return {
|
|
233
|
+
title: resItem.title,
|
|
234
|
+
link: resItem.link,
|
|
235
|
+
snippet: resItem.snippet,
|
|
236
|
+
content: text.slice(0, 4000),
|
|
237
|
+
scrapeStatus: 'success'
|
|
238
|
+
};
|
|
239
|
+
} catch (err) {
|
|
240
|
+
return {
|
|
241
|
+
title: resItem.title,
|
|
242
|
+
link: resItem.link,
|
|
243
|
+
snippet: resItem.snippet,
|
|
244
|
+
content: resItem.snippet,
|
|
245
|
+
scrapeStatus: 'error',
|
|
246
|
+
error: err.message
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
const scrapedPages = await Promise.all(scrapePromises);
|
|
252
|
+
const combinedMarkdown = scrapedPages
|
|
253
|
+
.filter(p => p.content)
|
|
254
|
+
.map(p => `### ${p.title}\nSource: ${p.link}\n\n${p.content}`)
|
|
255
|
+
.join('\n\n---\n\n');
|
|
256
|
+
|
|
257
|
+
return {
|
|
258
|
+
...searchResponse,
|
|
259
|
+
mode: 'deep_scrape',
|
|
260
|
+
scrapedPages,
|
|
261
|
+
combinedContent: combinedMarkdown || 'No deep content extracted.'
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export const defaultMetaSearch = new MetaSearchOrchestrator();
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
export const CircuitState = Object.freeze({
|
|
2
|
+
CLOSED: 'CLOSED',
|
|
3
|
+
OPEN: 'OPEN',
|
|
4
|
+
HALF_OPEN: 'HALF_OPEN'
|
|
5
|
+
});
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Circuit Breaker per Engine
|
|
9
|
+
* Tripping threshold: 3 consecutive failures
|
|
10
|
+
* Cool-down: 60s
|
|
11
|
+
*/
|
|
12
|
+
export class CircuitBreaker {
|
|
13
|
+
/**
|
|
14
|
+
* @param {Object} options
|
|
15
|
+
* @param {string} [options.name] - Breaker identifier
|
|
16
|
+
* @param {number} [options.failureThreshold=3] - Consecutive failures before opening
|
|
17
|
+
* @param {number} [options.cooldownMs=60000] - Duration to stay OPEN before HALF_OPEN
|
|
18
|
+
*/
|
|
19
|
+
constructor(options = {}) {
|
|
20
|
+
this.name = options.name || 'default';
|
|
21
|
+
this.failureThreshold = options.failureThreshold || 3;
|
|
22
|
+
this.cooldownMs = options.cooldownMs || 60_000;
|
|
23
|
+
|
|
24
|
+
this.state = CircuitState.CLOSED;
|
|
25
|
+
this.consecutiveFailures = 0;
|
|
26
|
+
this.lastFailureTime = 0;
|
|
27
|
+
this.tripCount = 0;
|
|
28
|
+
this.probeInFlight = false;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
canExecute() {
|
|
32
|
+
if (this.state === CircuitState.CLOSED) {
|
|
33
|
+
this.probeInFlight = false;
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const now = Date.now();
|
|
38
|
+
if (this.state === CircuitState.OPEN) {
|
|
39
|
+
if (now - this.lastFailureTime >= this.cooldownMs) {
|
|
40
|
+
this.state = CircuitState.HALF_OPEN;
|
|
41
|
+
this.probeInFlight = true;
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (this.probeInFlight) {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
this.probeInFlight = true;
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
recordSuccess() {
|
|
56
|
+
this.consecutiveFailures = 0;
|
|
57
|
+
this.state = CircuitState.CLOSED;
|
|
58
|
+
this.probeInFlight = false;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
recordFailure(error) {
|
|
62
|
+
this.consecutiveFailures++;
|
|
63
|
+
this.lastFailureTime = Date.now();
|
|
64
|
+
this.probeInFlight = false;
|
|
65
|
+
|
|
66
|
+
if (this.state === CircuitState.HALF_OPEN || this.consecutiveFailures >= this.failureThreshold) {
|
|
67
|
+
if (this.state !== CircuitState.OPEN) {
|
|
68
|
+
this.tripCount++;
|
|
69
|
+
}
|
|
70
|
+
this.state = CircuitState.OPEN;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
getState() {
|
|
75
|
+
this.canExecute();
|
|
76
|
+
return {
|
|
77
|
+
name: this.name,
|
|
78
|
+
state: this.state,
|
|
79
|
+
consecutiveFailures: this.consecutiveFailures,
|
|
80
|
+
tripCount: this.tripCount,
|
|
81
|
+
lastFailureTime: this.lastFailureTime
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
reset() {
|
|
86
|
+
this.state = CircuitState.CLOSED;
|
|
87
|
+
this.consecutiveFailures = 0;
|
|
88
|
+
this.lastFailureTime = 0;
|
|
89
|
+
this.probeInFlight = false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export const defaultBreakers = {
|
|
94
|
+
duckduckgo: new CircuitBreaker({ name: 'duckduckgo', failureThreshold: 3, cooldownMs: 60_000 }),
|
|
95
|
+
searxng: new CircuitBreaker({ name: 'searxng', failureThreshold: 3, cooldownMs: 60_000 }),
|
|
96
|
+
jina: new CircuitBreaker({ name: 'jina', failureThreshold: 3, cooldownMs: 60_000 })
|
|
97
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
export const TTL_GENERAL_MS = 10 * 60 * 1000;
|
|
4
|
+
export const TTL_NEWS_MS = 2 * 60 * 1000;
|
|
5
|
+
|
|
6
|
+
export function computeSearchCacheKey(engine, query, count = 10) {
|
|
7
|
+
const normalized = `${String(engine || '').toLowerCase()}:${String(query || '').toLowerCase().trim()}:${count}`;
|
|
8
|
+
return crypto.createHash('sha256').update(normalized).digest('hex');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class SearchResponseCache {
|
|
12
|
+
constructor(maxSize = 500) {
|
|
13
|
+
this.maxSize = Math.max(1, maxSize);
|
|
14
|
+
this.cache = new Map();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
get(key) {
|
|
18
|
+
const entry = this.cache.get(key);
|
|
19
|
+
if (!entry) return undefined;
|
|
20
|
+
if (entry.expiresAt && Date.now() > entry.expiresAt) {
|
|
21
|
+
this.cache.delete(key);
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
this.cache.delete(key);
|
|
26
|
+
this.cache.set(key, entry);
|
|
27
|
+
return entry.value;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
set(key, value, ttlMs = TTL_GENERAL_MS) {
|
|
31
|
+
if (this.cache.has(key)) {
|
|
32
|
+
this.cache.delete(key);
|
|
33
|
+
} else if (this.cache.size >= this.maxSize) {
|
|
34
|
+
const oldestKey = this.cache.keys().next().value;
|
|
35
|
+
if (oldestKey !== undefined) this.cache.delete(oldestKey);
|
|
36
|
+
}
|
|
37
|
+
const expiresAt = ttlMs > 0 ? Date.now() + ttlMs : 0;
|
|
38
|
+
this.cache.set(key, { value, expiresAt });
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
has(key) {
|
|
43
|
+
return this.get(key) !== undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
delete(key) {
|
|
47
|
+
return this.cache.delete(key);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
clear() {
|
|
51
|
+
this.cache.clear();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
get size() {
|
|
55
|
+
return this.cache.size;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const defaultSearchCache = new SearchResponseCache(500);
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
export class TokenBucketLimiter {
|
|
2
|
+
/**
|
|
3
|
+
* @param {Object} options
|
|
4
|
+
* @param {number} options.capacity - Maximum bucket burst capacity
|
|
5
|
+
* @param {number} options.refillRatePerSec - Number of tokens refilled per second
|
|
6
|
+
* @param {string} [options.name] - Identifier for logging
|
|
7
|
+
*/
|
|
8
|
+
constructor(options = {}) {
|
|
9
|
+
this.name = options.name || 'default';
|
|
10
|
+
this.capacity = Math.max(1, options.capacity || 1);
|
|
11
|
+
this.refillRatePerSec = Math.max(0.001, options.refillRatePerSec || 1);
|
|
12
|
+
this.tokens = this.capacity;
|
|
13
|
+
this.lastRefill = Date.now();
|
|
14
|
+
this.queue = [];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
_refill() {
|
|
18
|
+
const now = Date.now();
|
|
19
|
+
const elapsedSec = (now - this.lastRefill) / 1000;
|
|
20
|
+
this.tokens = Math.min(this.capacity, this.tokens + elapsedSec * this.refillRatePerSec);
|
|
21
|
+
this.lastRefill = now;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
tryAcquire(cost = 1) {
|
|
25
|
+
this._refill();
|
|
26
|
+
if (this.tokens >= cost) {
|
|
27
|
+
this.tokens -= cost;
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
async acquire(cost = 1, timeoutMs = 15000) {
|
|
35
|
+
this._refill();
|
|
36
|
+
if (this.tokens >= cost) {
|
|
37
|
+
this.tokens -= cost;
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return new Promise((resolve, reject) => {
|
|
42
|
+
let timer = null;
|
|
43
|
+
const entry = {
|
|
44
|
+
cost,
|
|
45
|
+
resolve: () => {
|
|
46
|
+
if (timer) clearTimeout(timer);
|
|
47
|
+
resolve(true);
|
|
48
|
+
},
|
|
49
|
+
reject: (err) => {
|
|
50
|
+
if (timer) clearTimeout(timer);
|
|
51
|
+
reject(err);
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
if (timeoutMs > 0) {
|
|
56
|
+
timer = setTimeout(() => {
|
|
57
|
+
const idx = this.queue.indexOf(entry);
|
|
58
|
+
if (idx !== -1) this.queue.splice(idx, 1);
|
|
59
|
+
entry.reject(new Error(`Rate limiter "${this.name}" timeout waiting for token`));
|
|
60
|
+
}, timeoutMs);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
this.queue.push(entry);
|
|
64
|
+
this._scheduleProcess();
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
_scheduleProcess() {
|
|
69
|
+
if (this.queue.length === 0) return;
|
|
70
|
+
this._refill();
|
|
71
|
+
|
|
72
|
+
while (this.queue.length > 0) {
|
|
73
|
+
const next = this.queue[0];
|
|
74
|
+
if (this.tokens >= next.cost) {
|
|
75
|
+
this.tokens -= next.cost;
|
|
76
|
+
this.queue.shift();
|
|
77
|
+
next.resolve();
|
|
78
|
+
} else {
|
|
79
|
+
|
|
80
|
+
const needed = next.cost - this.tokens;
|
|
81
|
+
const waitMs = Math.ceil((needed / this.refillRatePerSec) * 1000);
|
|
82
|
+
setTimeout(() => this._scheduleProcess(), Math.min(Math.max(waitMs, 50), 2000));
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export const defaultLimiters = {
|
|
90
|
+
duckduckgo: new TokenBucketLimiter({ name: 'duckduckgo', capacity: 2, refillRatePerSec: 1 }),
|
|
91
|
+
jina: new TokenBucketLimiter({ name: 'jina', capacity: 5, refillRatePerSec: 20 / 60 }),
|
|
92
|
+
searxng: new TokenBucketLimiter({ name: 'searxng', capacity: 5, refillRatePerSec: 5 })
|
|
93
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
const BLOCKED_DOMAINS = new Set([
|
|
2
|
+
'example.invalid',
|
|
3
|
+
'localhost',
|
|
4
|
+
'local',
|
|
5
|
+
'test',
|
|
6
|
+
'invalid'
|
|
7
|
+
]);
|
|
8
|
+
|
|
9
|
+
export class DomainFilter {
|
|
10
|
+
constructor(options = {}) {
|
|
11
|
+
this.blockedDomains = new Set([...BLOCKED_DOMAINS, ...(options.blockedDomains || [])]);
|
|
12
|
+
this.allowedDomains = options.allowedDomains ? new Set(options.allowedDomains) : null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
isDomainAllowed(hostname) {
|
|
16
|
+
if (!hostname || typeof hostname !== 'string') return false;
|
|
17
|
+
const clean = hostname.toLowerCase().trim().replace(/^www\./, '');
|
|
18
|
+
|
|
19
|
+
if (this.blockedDomains.has(clean)) return false;
|
|
20
|
+
for (const blocked of this.blockedDomains) {
|
|
21
|
+
if (clean.endsWith(`.${blocked}`)) return false;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (this.allowedDomains && this.allowedDomains.size > 0) {
|
|
25
|
+
if (this.allowedDomains.has(clean)) return true;
|
|
26
|
+
for (const allowed of this.allowedDomains) {
|
|
27
|
+
if (clean.endsWith(`.${allowed}`)) return true;
|
|
28
|
+
}
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const defaultDomainFilter = new DomainFilter();
|