total-recall-brain 3.1.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/LICENSE +21 -0
- package/README.md +173 -0
- package/bin/antigravity.mjs +212 -0
- package/bin/init.mjs +36 -0
- package/bin/oci-sniper.sh +69 -0
- package/bin/total-recall.mjs +229 -0
- package/docs/ARCHITECTURE.md +251 -0
- package/docs/IDE_INTEGRATION.md +112 -0
- package/package.json +71 -0
- package/scaffold/.agent/skills/total-recall/SKILL.md +195 -0
- package/scaffold/.agent/skills/total-recall/evals/evals.json +20 -0
- package/scaffold/.agent/skills/total-recall/memory-vault/concepts/cli-help-reference.md +145 -0
- package/scaffold/.agent/skills/total-recall/memory-vault/invariants/operating-instructions.md +75 -0
- package/scaffold/.agent/skills/total-recall/memory-vault/preferences/always-websearch-gap.md +16 -0
- package/scaffold/.agent/skills/total-recall/memory-vault/preferences/topic-research-sop.md +17 -0
- package/scaffold/.agent/skills/total-recall/references/api-reference.md +187 -0
- package/scaffold/.agent/skills/total-recall/references/architecture-reference.md +81 -0
- package/scaffold/.agent/skills/total-recall/references/cli-reference.md +103 -0
- package/scaffold/.agent/skills/total-recall/references/ssss-reference.md +88 -0
- package/scaffold/.agent/skills/total-recall/references/troubleshooting.md +68 -0
- package/scaffold/.agent/skills/total-recall/scripts/sync-repo.mjs +83 -0
- package/scaffold/.agent/skills/total-recall/skills/cli-agents/SKILL.md +122 -0
- package/scaffold/.agent/skills/total-recall/skills/cli-agents/agents.yml +27 -0
- package/scaffold/.agent/skills/total-recall/skills/cli-agents/references/antigravity.txt +20 -0
- package/scaffold/.agent/skills/total-recall/skills/cli-agents/references/claude.txt +58 -0
- package/scaffold/.agent/skills/total-recall/skills/cli-agents/references/codex.txt +81 -0
- package/scaffold/.agent/skills/total-recall/skills/cli-agents/references/gemini.txt +44 -0
- package/scaffold/.agent/skills/total-recall/skills/research/SKILL.md +337 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/SKILL.md +281 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/assets/skill-template/SKILL.md +20 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/evals/evals.json +20 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/hooks/on-skill-add.sh +14 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/hooks/on-skill-delete.sh +14 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/learnings/.gitkeep +0 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/references/.gitkeep +0 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/scripts/.gitkeep +0 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/scripts/create-skill.sh +65 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/scripts/enforce-skill-optimization.mjs +135 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/scripts/find-skills.mjs +124 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/scripts/scan-skill.mjs +207 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/scripts/skill-watcher.mjs +64 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/subagents/audit-all-skills.md +62 -0
- package/scaffold/.agent/skills/total-recall/skills/ssss/SKILL.md +27 -0
- package/scaffold/.agent/skills/total-recall/skills/ssss/evals/evals.json +17 -0
- package/scaffold/.agent/skills/total-recall/skills/ssss/references/ssss-spec.md +117 -0
- package/scaffold/.agent/skills/total-recall/skills/ssss/scripts/validate-schema.mjs +54 -0
- package/scaffold/.agent/skills/total-recall/skills/ssss/subagents/audit-ssss-node.md +9 -0
- package/scaffold/.agent/skills/total-recall/subagents/total-recall-diagnostician.md +13 -0
- package/src/cli/agent-dir.mjs +105 -0
- package/src/cli/backup.mjs +313 -0
- package/src/cli/brain.mjs +224 -0
- package/src/cli/chat.mjs +74 -0
- package/src/cli/config.mjs +247 -0
- package/src/cli/connect.mjs +872 -0
- package/src/cli/daemon.mjs +228 -0
- package/src/cli/deploy-ui.mjs +1793 -0
- package/src/cli/deploy.mjs +1726 -0
- package/src/cli/doctor.mjs +254 -0
- package/src/cli/dream.mjs +80 -0
- package/src/cli/forget.mjs +92 -0
- package/src/cli/friction.mjs +21 -0
- package/src/cli/generate-pat.mjs +65 -0
- package/src/cli/hash-password.mjs +22 -0
- package/src/cli/help.mjs +153 -0
- package/src/cli/import-rules.mjs +102 -0
- package/src/cli/ingest/index.mjs +318 -0
- package/src/cli/ingest/parsers/chrome-bookmarks.mjs +238 -0
- package/src/cli/ingest/parsers/google-keep.mjs +147 -0
- package/src/cli/ingest/parsers/search-history.mjs +142 -0
- package/src/cli/ingest/parsers/youtube-history.mjs +181 -0
- package/src/cli/ingest/utils/dedup.mjs +69 -0
- package/src/cli/ingest/utils/takeout-walker.mjs +101 -0
- package/src/cli/ingest.mjs +102 -0
- package/src/cli/init.mjs +736 -0
- package/src/cli/integration-dispatcher.mjs +128 -0
- package/src/cli/lint.mjs +166 -0
- package/src/cli/map.mjs +194 -0
- package/src/cli/migrate.mjs +87 -0
- package/src/cli/rebuild.mjs +88 -0
- package/src/cli/recall.mjs +219 -0
- package/src/cli/relay.mjs +518 -0
- package/src/cli/remember.mjs +306 -0
- package/src/cli/research.mjs +381 -0
- package/src/cli/reset-password.mjs +75 -0
- package/src/cli/restore.mjs +135 -0
- package/src/cli/setup.mjs +616 -0
- package/src/cli/share.mjs +180 -0
- package/src/cli/skill.mjs +313 -0
- package/src/cli/snapshot.mjs +108 -0
- package/src/cli/start.mjs +63 -0
- package/src/cli/status.mjs +296 -0
- package/src/cli/sync.mjs +383 -0
- package/src/cli/uninstall.mjs +613 -0
- package/src/cli/upgrade.mjs +141 -0
- package/src/cli/wizard.html +3682 -0
- package/src/core/blackboard.mjs +54 -0
- package/src/core/clarity-rewriter.mjs +770 -0
- package/src/core/conclusion-writer.mjs +259 -0
- package/src/core/config.mjs +277 -0
- package/src/core/conflict-detector.mjs +536 -0
- package/src/core/crypto.mjs +107 -0
- package/src/core/daemon-control.mjs +158 -0
- package/src/core/daemon-loop.mjs +364 -0
- package/src/core/dream.mjs +347 -0
- package/src/core/drift-detector.mjs +143 -0
- package/src/core/embeddings.mjs +632 -0
- package/src/core/emergency-alerts.mjs +137 -0
- package/src/core/evolution.mjs +107 -0
- package/src/core/fact-seeker.mjs +1602 -0
- package/src/core/friction.mjs +95 -0
- package/src/core/import-rules.mjs +308 -0
- package/src/core/inference-engine.mjs +367 -0
- package/src/core/logger.mjs +75 -0
- package/src/core/memory-layers.mjs +96 -0
- package/src/core/migrate.mjs +116 -0
- package/src/core/notifications.mjs +77 -0
- package/src/core/operation-validator.mjs +272 -0
- package/src/core/optimizer.mjs +330 -0
- package/src/core/pattern_detector.mjs +113 -0
- package/src/core/post-mortem.mjs +413 -0
- package/src/core/protect-instructions.mjs +148 -0
- package/src/core/quick-capture.mjs +145 -0
- package/src/core/research-queue.mjs +431 -0
- package/src/core/research.mjs +461 -0
- package/src/core/runtime.mjs +515 -0
- package/src/core/sandbox.mjs +163 -0
- package/src/core/scheduler.mjs +569 -0
- package/src/core/schema.mjs +333 -0
- package/src/core/search.mjs +219 -0
- package/src/core/semantic-index.mjs +276 -0
- package/src/core/session-watcher.mjs +850 -0
- package/src/core/snapshot.mjs +133 -0
- package/src/core/source-adapters.mjs +1038 -0
- package/src/core/steering.mjs +137 -0
- package/src/core/surface.mjs +444 -0
- package/src/core/task_runner.mjs +125 -0
- package/src/core/tts.mjs +142 -0
- package/src/core/usage-tracker.mjs +349 -0
- package/src/core/vault-cache.mjs +76 -0
- package/src/core/vault.mjs +273 -0
- package/src/core/watchdog.mjs +236 -0
- package/src/server/api.mjs +944 -0
- package/src/server/auth.mjs +410 -0
- package/src/server/index.mjs +868 -0
- package/src/server/keys.mjs +181 -0
- package/src/server/rest.mjs +1652 -0
- package/src/server/route-manifest.json +77 -0
- package/src/server/routes/_shared.mjs +93 -0
- package/src/server/routes/keys.mjs +55 -0
- package/src/server/routes/memory.mjs +249 -0
- package/src/server/routes/sessions.mjs +336 -0
- package/src/server/routes/share.mjs +138 -0
- package/src/server/tools.mjs +618 -0
- package/templates/Caddyfile +37 -0
- package/templates/com.totalrecall.backup.plist +42 -0
- package/templates/com.totalrecall.daemon.plist +40 -0
- package/templates/com.totalrecall.duckdns.plist +23 -0
- package/templates/com.totalrecall.server.plist +46 -0
- package/templates/default-config/budget.yml +6 -0
- package/templates/default-config/runtime.yml +11 -0
- package/templates/default-config/security.yml +53 -0
- package/templates/default-config/voice.yml +34 -0
- package/templates/obsidian-queries/conflicts.md +26 -0
- package/templates/obsidian-queries/daily.md +17 -0
- package/templates/obsidian-queries/index.md +38 -0
- package/templates/obsidian-queries/skills.md +14 -0
- package/templates/onboarding-interview.md +72 -0
- package/templates/skill_engineering.md +40 -0
- package/templates/total-recall-daemon.service +21 -0
- package/templates/total-recall-server.service +20 -0
|
@@ -0,0 +1,1038 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import yaml from 'yaml';
|
|
4
|
+
import { brainDir } from './config.mjs';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Research Source Adapters — Real Service Integrations
|
|
8
|
+
*
|
|
9
|
+
* Env vars (matching UltraChat conventions — already set in your environment):
|
|
10
|
+
* BRAVE_SEARCH_API_KEY — Brave Search API (primary web search)
|
|
11
|
+
* SERPER_API_KEY — Serper.dev (fallback web search)
|
|
12
|
+
* GITHUB_TOKEN — GitHub REST API (higher rate limits)
|
|
13
|
+
*
|
|
14
|
+
* Always-free sources (no auth required):
|
|
15
|
+
* arXiv, npm registry, Wikipedia REST API, DuckDuckGo Instant Answers, web fetch.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
// ─── Config Loading ────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
const DEFAULT_CONFIG_PATH = path.join(brainDir, 'config', 'research.yml');
|
|
21
|
+
const USAGE_FILE = path.join(brainDir, 'config', 'search-usage.json');
|
|
22
|
+
|
|
23
|
+
// Default: 50 paid searches/day ≈ 1,500/month — just under Brave's ~1,000 free credit
|
|
24
|
+
// (credit rounds up, so we stay safe). Users on paid plans can raise this.
|
|
25
|
+
const DEFAULT_DAILY_LIMIT = 50;
|
|
26
|
+
|
|
27
|
+
export function loadResearchConfig(configPath = DEFAULT_CONFIG_PATH) {
|
|
28
|
+
let fileConfig = {};
|
|
29
|
+
if (fs.existsSync(configPath)) {
|
|
30
|
+
try {
|
|
31
|
+
fileConfig = yaml.parse(fs.readFileSync(configPath, 'utf8')) || {};
|
|
32
|
+
} catch (err) { /* ignore parse errors */ }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let secrets = {};
|
|
36
|
+
try {
|
|
37
|
+
const secretsPath = path.join(path.dirname(configPath), 'secrets.enc');
|
|
38
|
+
if (fs.existsSync(secretsPath)) {
|
|
39
|
+
secrets = JSON.parse(fs.readFileSync(secretsPath, 'utf8') || '{}');
|
|
40
|
+
}
|
|
41
|
+
} catch {}
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
// ── Paid search providers (fallback chain: Brave → Tavily → Exa → Serper) ──
|
|
45
|
+
braveApiKey: process.env.BRAVE_SEARCH_API_KEY || process.env.BRAVE_API_KEY || secrets.brave_api_key || fileConfig.brave_api_key || null,
|
|
46
|
+
tavilyApiKey: process.env.TAVILY_API_KEY || secrets.tavily_api_key || fileConfig.tavily_api_key || null,
|
|
47
|
+
exaApiKey: process.env.EXA_API_KEY || secrets.exa_api_key || fileConfig.exa_api_key || null,
|
|
48
|
+
serperApiKey: process.env.SERPER_API_KEY || secrets.serper_api_key || fileConfig.serper_api_key || null,
|
|
49
|
+
// GitHub: personal access token for higher rate limits (60→5000 req/hr)
|
|
50
|
+
githubToken: process.env.GITHUB_TOKEN || secrets.github_token || fileConfig.github_token || null,
|
|
51
|
+
fetchTimeoutMs: fileConfig.fetch_timeout_ms || 10000,
|
|
52
|
+
maxResultsPerSource: fileConfig.max_results_per_source || 5,
|
|
53
|
+
userAgent: fileConfig.user_agent || 'TotalRecall/1.0 (knowledge-acquisition; +https://github.com/gregiteen/total-recall)',
|
|
54
|
+
// Daily cap: protects free-tier users from unexpected overage charges.
|
|
55
|
+
// Default 50/day ≈ 1,500/month — within Brave/Tavily/Exa free tiers (~1,000/month each).
|
|
56
|
+
// Set to 0 to disable (paid plans with high volume).
|
|
57
|
+
dailyWebSearchLimit: Number(fileConfig.daily_web_search_limit ?? process.env.TR_DAILY_SEARCH_LIMIT ?? DEFAULT_DAILY_LIMIT),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ─── Daily Search Budget Tracker ──────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
function todayKey() {
|
|
64
|
+
return new Date().toISOString().slice(0, 10); // "2026-05-19"
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function readUsage() {
|
|
68
|
+
try {
|
|
69
|
+
if (fs.existsSync(USAGE_FILE)) {
|
|
70
|
+
return JSON.parse(fs.readFileSync(USAGE_FILE, 'utf8'));
|
|
71
|
+
}
|
|
72
|
+
} catch (err) { /* corrupt file — reset */ }
|
|
73
|
+
return {};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function writeUsage(usage) {
|
|
77
|
+
try {
|
|
78
|
+
const dir = path.dirname(USAGE_FILE);
|
|
79
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
80
|
+
fs.writeFileSync(USAGE_FILE, JSON.stringify(usage, null, 2));
|
|
81
|
+
} catch (err) { /* non-fatal */ }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Check if the daily paid-search budget has been reached.
|
|
86
|
+
* If not, increments the counter and returns false (search is allowed).
|
|
87
|
+
* If yes, returns true (caller should use free fallback instead).
|
|
88
|
+
*/
|
|
89
|
+
export function isDailyCapReached(config) {
|
|
90
|
+
if (!config.dailyWebSearchLimit) return false; // 0 = unlimited
|
|
91
|
+
const today = todayKey();
|
|
92
|
+
const usage = readUsage();
|
|
93
|
+
const todayCount = usage[today] || 0;
|
|
94
|
+
if (todayCount >= config.dailyWebSearchLimit) return true;
|
|
95
|
+
usage[today] = todayCount + 1;
|
|
96
|
+
// Prune old days (keep last 7)
|
|
97
|
+
const days = Object.keys(usage).sort();
|
|
98
|
+
if (days.length > 7) { for (const d of days.slice(0, days.length - 7)) delete usage[d]; }
|
|
99
|
+
writeUsage(usage);
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Get today's usage stats for display in UI/logs.
|
|
105
|
+
*/
|
|
106
|
+
export function getSearchUsageStats(config) {
|
|
107
|
+
const today = todayKey();
|
|
108
|
+
const usage = readUsage();
|
|
109
|
+
return {
|
|
110
|
+
today: usage[today] || 0,
|
|
111
|
+
limit: config.dailyWebSearchLimit || 'unlimited',
|
|
112
|
+
remaining: config.dailyWebSearchLimit
|
|
113
|
+
? Math.max(0, config.dailyWebSearchLimit - (usage[today] || 0))
|
|
114
|
+
: 'unlimited',
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
// ─── Helper: Safe Fetch ─────────────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
async function safeFetch(url, options = {}, timeoutMs = 10000) {
|
|
122
|
+
const controller = new AbortController();
|
|
123
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
124
|
+
try {
|
|
125
|
+
const response = await fetch(url, { ...options, signal: controller.signal });
|
|
126
|
+
return response;
|
|
127
|
+
} finally {
|
|
128
|
+
clearTimeout(timer);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Strip HTML tags and normalize whitespace */
|
|
133
|
+
function stripHtml(html) {
|
|
134
|
+
return html
|
|
135
|
+
.replace(/<script[\s\S]*?<\/script>/gi, '')
|
|
136
|
+
.replace(/<style[\s\S]*?<\/style>/gi, '')
|
|
137
|
+
.replace(/<[^>]+>/g, ' ')
|
|
138
|
+
.replace(/ /g, ' ')
|
|
139
|
+
.replace(/&/g, '&')
|
|
140
|
+
.replace(/</g, '<')
|
|
141
|
+
.replace(/>/g, '>')
|
|
142
|
+
.replace(/"/g, '"')
|
|
143
|
+
.replace(/\s+/g, ' ')
|
|
144
|
+
.trim();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ─── Brave Search API ───────────────────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Search the web using Brave Search API.
|
|
151
|
+
* Free tier: 2,000 queries/month. Sign up at https://brave.com/search/api/
|
|
152
|
+
*
|
|
153
|
+
* @param {string} query
|
|
154
|
+
* @param {object} config - from loadResearchConfig()
|
|
155
|
+
* @param {number} [count=5]
|
|
156
|
+
* @returns {Promise<SourceResult[]>}
|
|
157
|
+
*/
|
|
158
|
+
export async function braveSearch(query, config, count = 5) {
|
|
159
|
+
if (!config.braveApiKey) {
|
|
160
|
+
throw new Error('BRAVE_SEARCH_API_KEY not set');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const url = new URL('https://api.search.brave.com/res/v1/web/search');
|
|
164
|
+
url.searchParams.set('q', query);
|
|
165
|
+
url.searchParams.set('count', String(Math.min(count, 20)));
|
|
166
|
+
url.searchParams.set('text_decorations', '0');
|
|
167
|
+
url.searchParams.set('search_lang', 'en');
|
|
168
|
+
|
|
169
|
+
const response = await safeFetch(url.toString(), {
|
|
170
|
+
headers: {
|
|
171
|
+
'Accept': 'application/json',
|
|
172
|
+
'Accept-Encoding': 'gzip',
|
|
173
|
+
'X-Subscription-Token': config.braveApiKey,
|
|
174
|
+
},
|
|
175
|
+
}, config.fetchTimeoutMs);
|
|
176
|
+
|
|
177
|
+
if (!response.ok) {
|
|
178
|
+
const body = await response.text();
|
|
179
|
+
throw new Error(`Brave Search API error ${response.status}: ${body.slice(0, 200)}`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const data = await response.json();
|
|
183
|
+
const results = data.web?.results || [];
|
|
184
|
+
|
|
185
|
+
return results.map(r => ({
|
|
186
|
+
source: 'brave-search',
|
|
187
|
+
type: 'web',
|
|
188
|
+
title: r.title || '',
|
|
189
|
+
url: r.url || '',
|
|
190
|
+
snippet: r.description || '',
|
|
191
|
+
published: normalizePublishedDate(r.age),
|
|
192
|
+
relevance: 0.8,
|
|
193
|
+
}));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ─── Serper Search API ──────────────────────────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Search the web using Serper.dev API (fallback when Brave unavailable).
|
|
200
|
+
* Uses SERPER_API_KEY — already defined in UltraChat .env.local.
|
|
201
|
+
* https://serper.dev/
|
|
202
|
+
*
|
|
203
|
+
* @param {string} query
|
|
204
|
+
* @param {object} config - from loadResearchConfig()
|
|
205
|
+
* @param {number} [count=5]
|
|
206
|
+
* @returns {Promise<SourceResult[]>}
|
|
207
|
+
*/
|
|
208
|
+
export async function serperSearch(query, config, count = 5) {
|
|
209
|
+
if (!config.serperApiKey) {
|
|
210
|
+
throw new Error('SERPER_API_KEY not set');
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const response = await safeFetch('https://google.serper.dev/search', {
|
|
214
|
+
method: 'POST',
|
|
215
|
+
headers: {
|
|
216
|
+
'X-API-KEY': config.serperApiKey,
|
|
217
|
+
'Content-Type': 'application/json',
|
|
218
|
+
},
|
|
219
|
+
body: JSON.stringify({ q: query, num: Math.min(count, 10) }),
|
|
220
|
+
}, config.fetchTimeoutMs);
|
|
221
|
+
|
|
222
|
+
if (!response.ok) {
|
|
223
|
+
const body = await response.text();
|
|
224
|
+
throw new Error(`Serper API error ${response.status}: ${body.slice(0, 200)}`);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const data = await response.json();
|
|
228
|
+
const organic = data.organic || [];
|
|
229
|
+
|
|
230
|
+
return organic.map(r => ({
|
|
231
|
+
source: 'serper',
|
|
232
|
+
type: 'web',
|
|
233
|
+
title: r.title || '',
|
|
234
|
+
url: r.link || '',
|
|
235
|
+
snippet: r.snippet || '',
|
|
236
|
+
published: normalizePublishedDate(r.date),
|
|
237
|
+
relevance: 0.8,
|
|
238
|
+
}));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ─── Tavily Search API ──────────────────────────────────────────────────────────
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Search using Tavily — purpose-built for AI agents.
|
|
245
|
+
* Returns clean extracted page text (not just snippets), saving a Playwright follow-up.
|
|
246
|
+
* Free tier: ~1,000 queries/month. https://tavily.com
|
|
247
|
+
*/
|
|
248
|
+
export async function tavilySearch(query, config, count = 5) {
|
|
249
|
+
if (!config.tavilyApiKey) throw new Error('TAVILY_API_KEY not set');
|
|
250
|
+
|
|
251
|
+
const response = await safeFetch('https://api.tavily.com/search', {
|
|
252
|
+
method: 'POST',
|
|
253
|
+
headers: { 'Content-Type': 'application/json' },
|
|
254
|
+
body: JSON.stringify({
|
|
255
|
+
api_key: config.tavilyApiKey,
|
|
256
|
+
query,
|
|
257
|
+
max_results: Math.min(count, 10),
|
|
258
|
+
search_depth: 'basic',
|
|
259
|
+
include_raw_content: false,
|
|
260
|
+
}),
|
|
261
|
+
}, config.fetchTimeoutMs);
|
|
262
|
+
|
|
263
|
+
if (!response.ok) {
|
|
264
|
+
const body = await response.text();
|
|
265
|
+
throw new Error(`Tavily API error ${response.status}: ${body.slice(0, 200)}`);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const data = await response.json();
|
|
269
|
+
return (data.results || []).map(r => ({
|
|
270
|
+
source: 'tavily',
|
|
271
|
+
type: 'web',
|
|
272
|
+
title: r.title || '',
|
|
273
|
+
url: r.url || '',
|
|
274
|
+
snippet: r.content || r.snippet || '',
|
|
275
|
+
published: normalizePublishedDate(r.published_date),
|
|
276
|
+
relevance: r.score || 0.8,
|
|
277
|
+
}));
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ─── Exa Search API ─────────────────────────────────────────────────────────────
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Search using Exa (formerly Metaphor) — neural/semantic search.
|
|
284
|
+
* Finds content by meaning rather than keywords; excellent for recent articles.
|
|
285
|
+
* Free tier: ~1,000 queries/month. https://exa.ai
|
|
286
|
+
*/
|
|
287
|
+
export async function exaSearch(query, config, count = 5) {
|
|
288
|
+
if (!config.exaApiKey) throw new Error('EXA_API_KEY not set');
|
|
289
|
+
|
|
290
|
+
const response = await safeFetch('https://api.exa.ai/search', {
|
|
291
|
+
method: 'POST',
|
|
292
|
+
headers: {
|
|
293
|
+
'Content-Type': 'application/json',
|
|
294
|
+
'x-api-key': config.exaApiKey,
|
|
295
|
+
},
|
|
296
|
+
body: JSON.stringify({
|
|
297
|
+
query,
|
|
298
|
+
numResults: Math.min(count, 10),
|
|
299
|
+
useAutoprompt: true,
|
|
300
|
+
type: 'neural',
|
|
301
|
+
}),
|
|
302
|
+
}, config.fetchTimeoutMs);
|
|
303
|
+
|
|
304
|
+
if (!response.ok) {
|
|
305
|
+
const body = await response.text();
|
|
306
|
+
throw new Error(`Exa API error ${response.status}: ${body.slice(0, 200)}`);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const data = await response.json();
|
|
310
|
+
return (data.results || []).map(r => ({
|
|
311
|
+
source: 'exa',
|
|
312
|
+
type: 'web',
|
|
313
|
+
title: r.title || '',
|
|
314
|
+
url: r.url || '',
|
|
315
|
+
snippet: r.text?.slice(0, 500) || r.highlight || '',
|
|
316
|
+
published: normalizePublishedDate(r.publishedDate),
|
|
317
|
+
relevance: r.score || 0.8,
|
|
318
|
+
}));
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Web search with automatic fallback chain:
|
|
323
|
+
* Brave → Tavily → Exa → Serper → DuckDuckGo (free, always on)
|
|
324
|
+
*
|
|
325
|
+
* Respects the daily paid-search cap (default 50/day ≈ 1,500/month).
|
|
326
|
+
* When the cap is hit, falls back to free DuckDuckGo — research never stops.
|
|
327
|
+
*/
|
|
328
|
+
export async function webSearch(query, config, count = 5) {
|
|
329
|
+
const capReached = isDailyCapReached(config);
|
|
330
|
+
|
|
331
|
+
if (!capReached) {
|
|
332
|
+
if (config.braveApiKey) return braveSearch(query, config, count);
|
|
333
|
+
if (config.tavilyApiKey) return tavilySearch(query, config, count);
|
|
334
|
+
if (config.exaApiKey) return exaSearch(query, config, count);
|
|
335
|
+
if (config.serperApiKey) return serperSearch(query, config, count);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// No paid key or daily cap reached — free fallback, always available
|
|
339
|
+
const ddg = await duckduckgoInstant(query, config).catch(() => null);
|
|
340
|
+
return ddg ? [ddg] : [];
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// ─── arXiv API ─────────────────────────────────────────────────────────────────
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Search arXiv for academic papers.
|
|
348
|
+
* No authentication required. https://arxiv.org/help/api
|
|
349
|
+
*
|
|
350
|
+
* @param {string} query
|
|
351
|
+
* @param {object} config
|
|
352
|
+
* @param {number} [maxResults=5]
|
|
353
|
+
* @returns {Promise<SourceResult[]>}
|
|
354
|
+
*/
|
|
355
|
+
export async function arxivSearch(query, config, maxResults = 5) {
|
|
356
|
+
const url = new URL('https://export.arxiv.org/api/query');
|
|
357
|
+
url.searchParams.set('search_query', `all:${query}`);
|
|
358
|
+
url.searchParams.set('start', '0');
|
|
359
|
+
url.searchParams.set('max_results', String(maxResults));
|
|
360
|
+
url.searchParams.set('sortBy', 'relevance');
|
|
361
|
+
url.searchParams.set('sortOrder', 'descending');
|
|
362
|
+
|
|
363
|
+
const response = await safeFetch(url.toString(), {
|
|
364
|
+
headers: { 'User-Agent': config.userAgent },
|
|
365
|
+
}, config.fetchTimeoutMs);
|
|
366
|
+
|
|
367
|
+
if (!response.ok) {
|
|
368
|
+
throw new Error(`arXiv API error ${response.status}`);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const xml = await response.text();
|
|
372
|
+
|
|
373
|
+
// Simple XML parsing without dependencies
|
|
374
|
+
const entries = [];
|
|
375
|
+
const entryRegex = /<entry>([\s\S]*?)<\/entry>/g;
|
|
376
|
+
let match;
|
|
377
|
+
|
|
378
|
+
while ((match = entryRegex.exec(xml)) !== null) {
|
|
379
|
+
const entry = match[1];
|
|
380
|
+
const title = (/<title>([\s\S]*?)<\/title>/.exec(entry)?.[1] || '').replace(/\s+/g, ' ').trim();
|
|
381
|
+
const summary = (/<summary>([\s\S]*?)<\/summary>/.exec(entry)?.[1] || '').replace(/\s+/g, ' ').trim();
|
|
382
|
+
const idRaw = /<id>([\s\S]*?)<\/id>/.exec(entry)?.[1] || '';
|
|
383
|
+
const published = /<published>([\s\S]*?)<\/published>/.exec(entry)?.[1] || null;
|
|
384
|
+
|
|
385
|
+
// Convert arXiv ID to link
|
|
386
|
+
const arxivId = idRaw.replace('http://arxiv.org/abs/', '').trim();
|
|
387
|
+
const url = arxivId ? `https://arxiv.org/abs/${arxivId}` : idRaw;
|
|
388
|
+
|
|
389
|
+
if (title) {
|
|
390
|
+
entries.push({
|
|
391
|
+
source: 'arxiv',
|
|
392
|
+
type: 'research-paper',
|
|
393
|
+
title,
|
|
394
|
+
url,
|
|
395
|
+
snippet: summary.slice(0, 500),
|
|
396
|
+
published: normalizePublishedDate(published),
|
|
397
|
+
relevance: 0.9,
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
return entries.slice(0, maxResults);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// ─── npm Registry API ───────────────────────────────────────────────────────────
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Search the npm registry.
|
|
409
|
+
* No authentication required. https://github.com/npm/registry/blob/main/docs/REGISTRY-API.md
|
|
410
|
+
*
|
|
411
|
+
* @param {string} query
|
|
412
|
+
* @param {object} config
|
|
413
|
+
* @param {number} [size=5]
|
|
414
|
+
* @returns {Promise<SourceResult[]>}
|
|
415
|
+
*/
|
|
416
|
+
export async function npmSearch(query, config, size = 5) {
|
|
417
|
+
const url = new URL('https://registry.npmjs.org/-/v1/search');
|
|
418
|
+
url.searchParams.set('text', query);
|
|
419
|
+
url.searchParams.set('size', String(size));
|
|
420
|
+
|
|
421
|
+
const response = await safeFetch(url.toString(), {
|
|
422
|
+
headers: { 'User-Agent': config.userAgent },
|
|
423
|
+
}, config.fetchTimeoutMs);
|
|
424
|
+
|
|
425
|
+
if (!response.ok) {
|
|
426
|
+
throw new Error(`npm Registry API error ${response.status}`);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const data = await response.json();
|
|
430
|
+
|
|
431
|
+
return (data.objects || []).map(obj => {
|
|
432
|
+
const pkg = obj.package;
|
|
433
|
+
return {
|
|
434
|
+
source: 'npm',
|
|
435
|
+
type: 'package',
|
|
436
|
+
title: `${pkg.name}@${pkg.version}`,
|
|
437
|
+
url: pkg.links?.npm || `https://www.npmjs.com/package/${pkg.name}`,
|
|
438
|
+
snippet: pkg.description || '',
|
|
439
|
+
published: normalizePublishedDate(pkg.date),
|
|
440
|
+
relevance: obj.score?.final || 0.5,
|
|
441
|
+
extra: {
|
|
442
|
+
name: pkg.name,
|
|
443
|
+
version: pkg.version,
|
|
444
|
+
keywords: pkg.keywords || [],
|
|
445
|
+
weeklyDownloads: null, // Available in package detail endpoint
|
|
446
|
+
},
|
|
447
|
+
};
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// ─── npm Package Detail ──────────────────────────────────────────────────────────
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Fetch detailed metadata for a specific npm package.
|
|
455
|
+
* Returns version history, dependencies, and full description.
|
|
456
|
+
*
|
|
457
|
+
* @param {string} packageName
|
|
458
|
+
* @param {object} config
|
|
459
|
+
* @returns {Promise<object>}
|
|
460
|
+
*/
|
|
461
|
+
export async function npmPackageDetail(packageName, config) {
|
|
462
|
+
const response = await safeFetch(
|
|
463
|
+
`https://registry.npmjs.org/${encodeURIComponent(packageName)}`,
|
|
464
|
+
{ headers: { 'User-Agent': config.userAgent } },
|
|
465
|
+
config.fetchTimeoutMs,
|
|
466
|
+
);
|
|
467
|
+
|
|
468
|
+
if (!response.ok) {
|
|
469
|
+
throw new Error(`npm package detail error ${response.status}`);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const data = await response.json();
|
|
473
|
+
const latest = data['dist-tags']?.latest;
|
|
474
|
+
const latestVersion = latest ? data.versions?.[latest] : null;
|
|
475
|
+
|
|
476
|
+
return {
|
|
477
|
+
source: 'npm',
|
|
478
|
+
type: 'package-detail',
|
|
479
|
+
title: `${data.name}@${latest || 'unknown'}`,
|
|
480
|
+
url: `https://www.npmjs.com/package/${data.name}`,
|
|
481
|
+
snippet: data.description || '',
|
|
482
|
+
published: normalizePublishedDate(data.time?.[latest]),
|
|
483
|
+
relevance: 1.0,
|
|
484
|
+
extra: {
|
|
485
|
+
name: data.name,
|
|
486
|
+
latestVersion: latest,
|
|
487
|
+
license: latestVersion?.license,
|
|
488
|
+
homepage: latestVersion?.homepage,
|
|
489
|
+
repository: latestVersion?.repository?.url,
|
|
490
|
+
dependencies: Object.keys(latestVersion?.dependencies || {}),
|
|
491
|
+
weeklyDownloads: null,
|
|
492
|
+
},
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// ─── GitHub Search API ──────────────────────────────────────────────────────────
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Search GitHub repositories and code.
|
|
500
|
+
* Optional GITHUB_TOKEN for higher rate limits (60 vs 5000 req/hour).
|
|
501
|
+
*
|
|
502
|
+
* @param {string} query
|
|
503
|
+
* @param {object} config
|
|
504
|
+
* @param {'repositories'|'code'|'issues'} [searchType='repositories']
|
|
505
|
+
* @param {number} [perPage=5]
|
|
506
|
+
* @returns {Promise<SourceResult[]>}
|
|
507
|
+
*/
|
|
508
|
+
export async function githubSearch(query, config, searchType = 'repositories', perPage = 5) {
|
|
509
|
+
const url = new URL(`https://api.github.com/search/${searchType}`);
|
|
510
|
+
url.searchParams.set('q', query);
|
|
511
|
+
url.searchParams.set('per_page', String(perPage));
|
|
512
|
+
url.searchParams.set('sort', 'stars');
|
|
513
|
+
url.searchParams.set('order', 'desc');
|
|
514
|
+
|
|
515
|
+
const headers = {
|
|
516
|
+
'Accept': 'application/vnd.github.v3+json',
|
|
517
|
+
'User-Agent': config.userAgent,
|
|
518
|
+
};
|
|
519
|
+
if (config.githubToken) {
|
|
520
|
+
headers['Authorization'] = `Bearer ${config.githubToken}`;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const response = await safeFetch(url.toString(), { headers }, config.fetchTimeoutMs);
|
|
524
|
+
|
|
525
|
+
if (response.status === 403) {
|
|
526
|
+
throw new Error('GitHub API rate limit exceeded. Set GITHUB_TOKEN for higher limits.');
|
|
527
|
+
}
|
|
528
|
+
if (!response.ok) {
|
|
529
|
+
throw new Error(`GitHub API error ${response.status}`);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const data = await response.json();
|
|
533
|
+
|
|
534
|
+
if (searchType === 'repositories') {
|
|
535
|
+
return (data.items || []).map(repo => ({
|
|
536
|
+
source: 'github',
|
|
537
|
+
type: 'repository',
|
|
538
|
+
title: repo.full_name,
|
|
539
|
+
url: repo.html_url,
|
|
540
|
+
snippet: repo.description || '',
|
|
541
|
+
published: normalizePublishedDate(repo.updated_at),
|
|
542
|
+
relevance: Math.min(1.0, (repo.stargazers_count || 0) / 10000),
|
|
543
|
+
extra: {
|
|
544
|
+
stars: repo.stargazers_count,
|
|
545
|
+
language: repo.language,
|
|
546
|
+
topics: repo.topics || [],
|
|
547
|
+
forks: repo.forks_count,
|
|
548
|
+
openIssues: repo.open_issues_count,
|
|
549
|
+
},
|
|
550
|
+
}));
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
if (searchType === 'issues') {
|
|
554
|
+
return (data.items || []).map(issue => ({
|
|
555
|
+
source: 'github',
|
|
556
|
+
type: 'issue',
|
|
557
|
+
title: issue.title,
|
|
558
|
+
url: issue.html_url,
|
|
559
|
+
snippet: (issue.body || '').slice(0, 400),
|
|
560
|
+
published: normalizePublishedDate(issue.created_at),
|
|
561
|
+
relevance: 0.6,
|
|
562
|
+
}));
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
return [];
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// ─── Wikipedia REST API ─────────────────────────────────────────────────────────
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Fetch a Wikipedia article summary.
|
|
572
|
+
* No authentication required. https://en.wikipedia.org/api/rest_v1/
|
|
573
|
+
*
|
|
574
|
+
* @param {string} topic Article title (spaces OK)
|
|
575
|
+
* @param {object} config
|
|
576
|
+
* @returns {Promise<SourceResult|null>}
|
|
577
|
+
*/
|
|
578
|
+
export async function wikipediaFetch(topic, config) {
|
|
579
|
+
const encoded = encodeURIComponent(topic.replace(/\s+/g, '_'));
|
|
580
|
+
const url = `https://en.wikipedia.org/api/rest_v1/page/summary/${encoded}`;
|
|
581
|
+
|
|
582
|
+
const response = await safeFetch(url, {
|
|
583
|
+
headers: {
|
|
584
|
+
'User-Agent': config.userAgent,
|
|
585
|
+
'Api-User-Agent': config.userAgent,
|
|
586
|
+
},
|
|
587
|
+
}, config.fetchTimeoutMs);
|
|
588
|
+
|
|
589
|
+
if (response.status === 404) return null; // Article doesn't exist
|
|
590
|
+
if (!response.ok) throw new Error(`Wikipedia API error ${response.status}`);
|
|
591
|
+
|
|
592
|
+
const data = await response.json();
|
|
593
|
+
if (data.type === 'disambiguation') {
|
|
594
|
+
// Try the first item from disambiguation
|
|
595
|
+
return null;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
return {
|
|
599
|
+
source: 'wikipedia',
|
|
600
|
+
type: 'encyclopedia',
|
|
601
|
+
title: data.title || topic,
|
|
602
|
+
url: data.content_urls?.desktop?.page || `https://en.wikipedia.org/wiki/${encoded}`,
|
|
603
|
+
snippet: data.extract || '',
|
|
604
|
+
published: normalizePublishedDate(data.timestamp),
|
|
605
|
+
relevance: 0.85,
|
|
606
|
+
extra: {
|
|
607
|
+
pageId: data.pageid,
|
|
608
|
+
thumbnail: data.thumbnail?.source || null,
|
|
609
|
+
categories: [],
|
|
610
|
+
},
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// ─── Wikidata API ────────────────────────────────────────────────────────────────
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* Query Wikidata SPARQL for structured facts.
|
|
618
|
+
* No authentication required. https://www.wikidata.org/wiki/Wikidata:SPARQL_query_service
|
|
619
|
+
*
|
|
620
|
+
* @param {string} sparqlQuery
|
|
621
|
+
* @param {object} config
|
|
622
|
+
* @returns {Promise<object[]>}
|
|
623
|
+
*/
|
|
624
|
+
export async function wikidataQuery(sparqlQuery, config) {
|
|
625
|
+
const url = new URL('https://query.wikidata.org/sparql');
|
|
626
|
+
url.searchParams.set('query', sparqlQuery);
|
|
627
|
+
url.searchParams.set('format', 'json');
|
|
628
|
+
|
|
629
|
+
const response = await safeFetch(url.toString(), {
|
|
630
|
+
headers: {
|
|
631
|
+
'User-Agent': config.userAgent,
|
|
632
|
+
'Accept': 'application/sparql-results+json',
|
|
633
|
+
},
|
|
634
|
+
}, config.fetchTimeoutMs);
|
|
635
|
+
|
|
636
|
+
if (!response.ok) throw new Error(`Wikidata SPARQL error ${response.status}`);
|
|
637
|
+
|
|
638
|
+
const data = await response.json();
|
|
639
|
+
return data.results?.bindings || [];
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// ─── Generic Web Fetch ──────────────────────────────────────────────────────────
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* Fetch a URL and extract readable text content.
|
|
646
|
+
* Strips HTML, removes nav/footer/script noise, returns plain text.
|
|
647
|
+
*
|
|
648
|
+
* @param {string} url
|
|
649
|
+
* @param {object} config
|
|
650
|
+
* @returns {Promise<SourceResult>}
|
|
651
|
+
*/
|
|
652
|
+
export async function webFetch(url, config) {
|
|
653
|
+
const response = await safeFetch(url, {
|
|
654
|
+
headers: {
|
|
655
|
+
'User-Agent': config.userAgent,
|
|
656
|
+
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
|
657
|
+
'Accept-Language': 'en-US,en;q=0.5',
|
|
658
|
+
},
|
|
659
|
+
}, config.fetchTimeoutMs);
|
|
660
|
+
|
|
661
|
+
if (!response.ok) {
|
|
662
|
+
throw new Error(`Web fetch error ${response.status} for ${url}`);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
const contentType = response.headers.get('content-type') || '';
|
|
666
|
+
let text = '';
|
|
667
|
+
|
|
668
|
+
if (contentType.includes('application/json')) {
|
|
669
|
+
const json = await response.json();
|
|
670
|
+
text = JSON.stringify(json, null, 2).slice(0, 5000);
|
|
671
|
+
} else {
|
|
672
|
+
const html = await response.text();
|
|
673
|
+
text = stripHtml(html).slice(0, 8000);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
return {
|
|
677
|
+
source: 'web-fetch',
|
|
678
|
+
type: 'webpage',
|
|
679
|
+
title: url,
|
|
680
|
+
url,
|
|
681
|
+
snippet: text.slice(0, 1000),
|
|
682
|
+
fullText: text,
|
|
683
|
+
published: null,
|
|
684
|
+
relevance: 0.7,
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// ─── DuckDuckGo Instant Answers ─────────────────────────────────────────────────
|
|
689
|
+
|
|
690
|
+
/**
|
|
691
|
+
* DuckDuckGo Instant Answers API — structured facts, no auth required.
|
|
692
|
+
* Best for definitions, entity facts, calculations.
|
|
693
|
+
* NOT full web search (use Brave for that).
|
|
694
|
+
*
|
|
695
|
+
* @param {string} query
|
|
696
|
+
* @param {object} config
|
|
697
|
+
* @returns {Promise<SourceResult|null>}
|
|
698
|
+
*/
|
|
699
|
+
export async function duckduckgoInstant(query, config) {
|
|
700
|
+
const url = new URL('https://api.duckduckgo.com/');
|
|
701
|
+
url.searchParams.set('q', query);
|
|
702
|
+
url.searchParams.set('format', 'json');
|
|
703
|
+
url.searchParams.set('no_html', '1');
|
|
704
|
+
url.searchParams.set('skip_disambig', '1');
|
|
705
|
+
|
|
706
|
+
const response = await safeFetch(url.toString(), {
|
|
707
|
+
headers: { 'User-Agent': config.userAgent },
|
|
708
|
+
}, config.fetchTimeoutMs);
|
|
709
|
+
|
|
710
|
+
if (!response.ok) throw new Error(`DuckDuckGo API error ${response.status}`);
|
|
711
|
+
|
|
712
|
+
const data = await response.json();
|
|
713
|
+
const answer = data.AbstractText || data.Answer || data.Definition || '';
|
|
714
|
+
const sourceUrl = data.AbstractURL || data.AnswerURL || '';
|
|
715
|
+
|
|
716
|
+
if (!answer) return null;
|
|
717
|
+
|
|
718
|
+
return {
|
|
719
|
+
source: 'duckduckgo',
|
|
720
|
+
type: 'instant-answer',
|
|
721
|
+
title: data.Heading || query,
|
|
722
|
+
url: sourceUrl,
|
|
723
|
+
snippet: answer,
|
|
724
|
+
published: null,
|
|
725
|
+
relevance: 0.75,
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// ─── Source Availability Check ──────────────────────────────────────────────────
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* Report which sources are available given current config.
|
|
733
|
+
* Used for self-diagnosis.
|
|
734
|
+
*
|
|
735
|
+
* @param {object} config
|
|
736
|
+
* @returns {object} { available: string[], unavailable: string[], warnings: string[] }
|
|
737
|
+
*/
|
|
738
|
+
export function checkSourceAvailability(config) {
|
|
739
|
+
const available = [];
|
|
740
|
+
const unavailable = [];
|
|
741
|
+
const warnings = [];
|
|
742
|
+
|
|
743
|
+
// Always available (no auth)
|
|
744
|
+
available.push('arxiv', 'npm', 'wikipedia', 'duckduckgo', 'web-fetch');
|
|
745
|
+
|
|
746
|
+
// Web search — Brave primary, Serper fallback
|
|
747
|
+
if (config.braveApiKey) {
|
|
748
|
+
available.push('brave-search');
|
|
749
|
+
} else {
|
|
750
|
+
unavailable.push('brave-search');
|
|
751
|
+
warnings.push('BRAVE_SEARCH_API_KEY not set — set in env (already in UltraChat .env.local)');
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
if (config.serperApiKey) {
|
|
755
|
+
available.push('serper');
|
|
756
|
+
} else {
|
|
757
|
+
unavailable.push('serper');
|
|
758
|
+
if (!config.braveApiKey) {
|
|
759
|
+
warnings.push('SERPER_API_KEY not set — no web search available (set BRAVE_SEARCH_API_KEY or SERPER_API_KEY)');
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
if (config.githubToken) {
|
|
764
|
+
available.push('github');
|
|
765
|
+
} else {
|
|
766
|
+
available.push('github'); // Works unauthenticated at 60 req/hr
|
|
767
|
+
warnings.push('GITHUB_TOKEN not set — GitHub rate limit is 60 req/hr (authenticated: 5000)');
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
return { available, unavailable, warnings };
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// ─── Playwright Headless Browser ────────────────────────────────────────────────
|
|
774
|
+
|
|
775
|
+
/**
|
|
776
|
+
* Check if playwright is installed (optional dependency).
|
|
777
|
+
* Returns the chromium launcher or null if not available.
|
|
778
|
+
*/
|
|
779
|
+
async function getPlaywright() {
|
|
780
|
+
try {
|
|
781
|
+
const pw = await import('playwright');
|
|
782
|
+
return pw.chromium || pw.default?.chromium || null;
|
|
783
|
+
} catch (err) {
|
|
784
|
+
return null;
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
/**
|
|
789
|
+
* Scrape a URL using a headless Chromium browser via Playwright.
|
|
790
|
+
*
|
|
791
|
+
* Handles:
|
|
792
|
+
* - JavaScript-rendered pages (SPAs, React, Next.js)
|
|
793
|
+
* - Pages that block plain fetch (bot detection, Cloudflare challenges)
|
|
794
|
+
* - Dynamic content loaded after scroll or interaction
|
|
795
|
+
* - Readable text extraction from complex page layouts
|
|
796
|
+
*
|
|
797
|
+
* Falls back to plain webFetch() if Playwright is not installed.
|
|
798
|
+
* Install with: npm install playwright && npx playwright install chromium
|
|
799
|
+
*
|
|
800
|
+
* @param {string} url
|
|
801
|
+
* @param {object} config
|
|
802
|
+
* @param {object} [opts]
|
|
803
|
+
* @param {number} [opts.waitMs=2000] Wait for JS rendering
|
|
804
|
+
* @param {string} [opts.waitForSelector=null] CSS selector to wait for
|
|
805
|
+
* @param {boolean} [opts.extractLinks=false] Also return all page links
|
|
806
|
+
* @returns {Promise<SourceResult>}
|
|
807
|
+
*/
|
|
808
|
+
export async function playwrightScrape(url, config, opts = {}) {
|
|
809
|
+
const chromium = await getPlaywright();
|
|
810
|
+
|
|
811
|
+
if (!chromium) {
|
|
812
|
+
// Graceful fallback — no hard dependency on playwright
|
|
813
|
+
return webFetch(url, config);
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
const { waitMs = 2000, waitForSelector = null, extractLinks = false } = opts;
|
|
817
|
+
|
|
818
|
+
let browser;
|
|
819
|
+
try {
|
|
820
|
+
browser = await chromium.launch({
|
|
821
|
+
headless: true,
|
|
822
|
+
args: [
|
|
823
|
+
'--no-sandbox',
|
|
824
|
+
'--disable-setuid-sandbox',
|
|
825
|
+
'--disable-dev-shm-usage',
|
|
826
|
+
'--disable-gpu',
|
|
827
|
+
'--disable-extensions',
|
|
828
|
+
],
|
|
829
|
+
});
|
|
830
|
+
|
|
831
|
+
const context = await browser.newContext({
|
|
832
|
+
userAgent: config.userAgent,
|
|
833
|
+
viewport: { width: 1280, height: 800 },
|
|
834
|
+
// Block images, fonts, media to speed up scraping
|
|
835
|
+
extraHTTPHeaders: { 'Accept-Language': 'en-US,en;q=0.9' },
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
// Block resource types that waste bandwidth during scraping
|
|
839
|
+
await context.route('**/*', (route) => {
|
|
840
|
+
const type = route.request().resourceType();
|
|
841
|
+
if (['image', 'media', 'font', 'stylesheet'].includes(type)) {
|
|
842
|
+
route.abort();
|
|
843
|
+
} else {
|
|
844
|
+
route.continue();
|
|
845
|
+
}
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
const page = await context.newPage();
|
|
849
|
+
|
|
850
|
+
// Navigate with timeout matching config
|
|
851
|
+
await page.goto(url, {
|
|
852
|
+
waitUntil: 'domcontentloaded',
|
|
853
|
+
timeout: config.fetchTimeoutMs || 15000,
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
// Wait for JS rendering
|
|
857
|
+
if (waitForSelector) {
|
|
858
|
+
await page.waitForSelector(waitForSelector, { timeout: 5000 }).catch(() => {});
|
|
859
|
+
} else if (waitMs > 0) {
|
|
860
|
+
await page.waitForTimeout(waitMs);
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
// Extract page title
|
|
864
|
+
const title = await page.title();
|
|
865
|
+
|
|
866
|
+
// Extract readable text — prioritise article/main content
|
|
867
|
+
const fullText = await page.evaluate(() => {
|
|
868
|
+
// Try to get main content area first
|
|
869
|
+
const selectors = ['article', 'main', '[role="main"]', '.content', '#content', 'body'];
|
|
870
|
+
for (const sel of selectors) {
|
|
871
|
+
const el = document.querySelector(sel);
|
|
872
|
+
if (el && el.innerText && el.innerText.length > 200) {
|
|
873
|
+
return el.innerText.replace(/\s+/g, ' ').trim().slice(0, 8000);
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
return document.body?.innerText?.replace(/\s+/g, ' ').trim().slice(0, 8000) || '';
|
|
877
|
+
});
|
|
878
|
+
|
|
879
|
+
// Optionally extract all links
|
|
880
|
+
let links = [];
|
|
881
|
+
if (extractLinks) {
|
|
882
|
+
links = await page.evaluate(() =>
|
|
883
|
+
Array.from(document.querySelectorAll('a[href]'))
|
|
884
|
+
.map(a => ({ text: a.innerText?.trim(), href: a.href }))
|
|
885
|
+
.filter(l => l.href.startsWith('http') && l.text)
|
|
886
|
+
.slice(0, 50),
|
|
887
|
+
);
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
return {
|
|
891
|
+
source: 'playwright',
|
|
892
|
+
type: 'webpage-rendered',
|
|
893
|
+
title: title || url,
|
|
894
|
+
url,
|
|
895
|
+
snippet: fullText.slice(0, 1000),
|
|
896
|
+
fullText,
|
|
897
|
+
published: null,
|
|
898
|
+
relevance: 0.85, // Higher than plain fetch — JS content is more complete
|
|
899
|
+
extra: { links },
|
|
900
|
+
};
|
|
901
|
+
|
|
902
|
+
} finally {
|
|
903
|
+
if (browser) await browser.close().catch(() => {});
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/**
|
|
908
|
+
* Smart fetch: tries Playwright first for JS-heavy pages, falls back to plain fetch.
|
|
909
|
+
* Use this when you don't know if a page requires JS rendering.
|
|
910
|
+
*
|
|
911
|
+
* @param {string} url
|
|
912
|
+
* @param {object} config
|
|
913
|
+
* @returns {Promise<SourceResult>}
|
|
914
|
+
*/
|
|
915
|
+
export async function smartFetch(url, config) {
|
|
916
|
+
const chromium = await getPlaywright();
|
|
917
|
+
|
|
918
|
+
// For known JS-heavy domains, always use Playwright
|
|
919
|
+
const jsHeavyDomains = [
|
|
920
|
+
'github.com', 'npmjs.com', 'medium.com', 'substack.com',
|
|
921
|
+
'notion.so', 'hashnode.dev', 'dev.to', 'stackoverflow.com',
|
|
922
|
+
];
|
|
923
|
+
const usePlaywright = chromium && jsHeavyDomains.some(d => url.includes(d));
|
|
924
|
+
|
|
925
|
+
if (usePlaywright) {
|
|
926
|
+
try {
|
|
927
|
+
return await playwrightScrape(url, config, { waitMs: 1500 });
|
|
928
|
+
} catch (err) {
|
|
929
|
+
// Fall through to plain fetch
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
return webFetch(url, config);
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
/**
|
|
937
|
+
* Normalizes fuzzy, relative, or partial date/time strings into a standardized ISO 8601 timestamp.
|
|
938
|
+
* Supports:
|
|
939
|
+
* - Relative time: "3 hours ago", "5 days ago", "yesterday", "just now", "2 weeks ago"
|
|
940
|
+
* - Partial date: "April 2026", "2026"
|
|
941
|
+
* - Standard formats: "2026-05-21", "2026-05-21T13:40:12Z", "May 21, 2026"
|
|
942
|
+
*
|
|
943
|
+
* @param {string|null|undefined} rawDateStr
|
|
944
|
+
* @param {Date|string|number} [referenceTime=new Date()] - dynamic system execution context
|
|
945
|
+
* @returns {string|null} ISO 8601 timestamp or null if unparseable
|
|
946
|
+
*/
|
|
947
|
+
export function normalizePublishedDate(rawDateStr, referenceTime = new Date()) {
|
|
948
|
+
if (!rawDateStr || typeof rawDateStr !== 'string') return null;
|
|
949
|
+
const clean = rawDateStr.trim().toLowerCase();
|
|
950
|
+
if (!clean) return null;
|
|
951
|
+
|
|
952
|
+
const ref = referenceTime instanceof Date ? referenceTime : new Date(referenceTime);
|
|
953
|
+
if (isNaN(ref.getTime())) return null;
|
|
954
|
+
|
|
955
|
+
// Check relative time patterns
|
|
956
|
+
let match;
|
|
957
|
+
|
|
958
|
+
if (clean === 'yesterday') {
|
|
959
|
+
return new Date(ref.getTime() - 86400 * 1000).toISOString();
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
if (clean === 'today' || clean === 'now' || clean === 'just now') {
|
|
963
|
+
return ref.toISOString();
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
match = clean.match(/^(\d+)\s+(minute|min|m)s?\s+ago$/);
|
|
967
|
+
if (match) {
|
|
968
|
+
const mins = parseInt(match[1], 10);
|
|
969
|
+
return new Date(ref.getTime() - mins * 60 * 1000).toISOString();
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
match = clean.match(/^(\d+)\s+(hour|hr|h)s?\s+ago$/);
|
|
973
|
+
if (match) {
|
|
974
|
+
const hrs = parseInt(match[1], 10);
|
|
975
|
+
return new Date(ref.getTime() - hrs * 3600 * 1000).toISOString();
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
match = clean.match(/^(\d+)\s+(day|d)s?\s+ago$/);
|
|
979
|
+
if (match) {
|
|
980
|
+
const days = parseInt(match[1], 10);
|
|
981
|
+
return new Date(ref.getTime() - days * 86400 * 1000).toISOString();
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
match = clean.match(/^(\d+)\s+(week|wk|w)s?\s+ago$/);
|
|
985
|
+
if (match) {
|
|
986
|
+
const weeks = parseInt(match[1], 10);
|
|
987
|
+
return new Date(ref.getTime() - weeks * 7 * 86400 * 1000).toISOString();
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
match = clean.match(/^(\d+)\s+(month|mo)s?\s+ago$/);
|
|
991
|
+
if (match) {
|
|
992
|
+
const months = parseInt(match[1], 10);
|
|
993
|
+
return new Date(ref.getTime() - months * 30.4 * 86400 * 1000).toISOString();
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
match = clean.match(/^(\d+)\s+(year|yr|y)s?\s+ago$/);
|
|
997
|
+
if (match) {
|
|
998
|
+
const years = parseInt(match[1], 10);
|
|
999
|
+
return new Date(ref.getTime() - years * 365.25 * 86400 * 1000).toISOString();
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
// Handle Month YYYY or YYYY formats
|
|
1003
|
+
const monthNames = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];
|
|
1004
|
+
const monthRegex = new RegExp(`^(${monthNames.join('|')})\\w*\\s+(\\d{4})$`);
|
|
1005
|
+
match = clean.match(monthRegex);
|
|
1006
|
+
if (match) {
|
|
1007
|
+
const monthIdx = monthNames.indexOf(match[1]);
|
|
1008
|
+
const year = parseInt(match[2], 10);
|
|
1009
|
+
const d = new Date(Date.UTC(year, monthIdx, 1, 0, 0, 0, 0));
|
|
1010
|
+
return d.toISOString();
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
if (/^\d{4}$/.test(clean)) {
|
|
1014
|
+
const year = parseInt(clean, 10);
|
|
1015
|
+
const d = new Date(Date.UTC(year, 0, 1, 0, 0, 0, 0));
|
|
1016
|
+
return d.toISOString();
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
// Standard parsing via JS Date
|
|
1020
|
+
const parsed = Date.parse(rawDateStr);
|
|
1021
|
+
if (!isNaN(parsed)) {
|
|
1022
|
+
return new Date(parsed).toISOString();
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
return null;
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
/**
|
|
1029
|
+
* @typedef {object} SourceResult
|
|
1030
|
+
* @property {string} source - Source adapter name
|
|
1031
|
+
* @property {string} type - Content type: web, research-paper, package, webpage-rendered, etc.
|
|
1032
|
+
* @property {string} title
|
|
1033
|
+
* @property {string} url
|
|
1034
|
+
* @property {string} snippet
|
|
1035
|
+
* @property {string|null} published - ISO date or null
|
|
1036
|
+
* @property {number} relevance - 0.0–1.0
|
|
1037
|
+
* @property {object} [extra] - Source-specific extra data
|
|
1038
|
+
*/
|