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.
Files changed (170) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +173 -0
  3. package/bin/antigravity.mjs +212 -0
  4. package/bin/init.mjs +36 -0
  5. package/bin/oci-sniper.sh +69 -0
  6. package/bin/total-recall.mjs +229 -0
  7. package/docs/ARCHITECTURE.md +251 -0
  8. package/docs/IDE_INTEGRATION.md +112 -0
  9. package/package.json +71 -0
  10. package/scaffold/.agent/skills/total-recall/SKILL.md +195 -0
  11. package/scaffold/.agent/skills/total-recall/evals/evals.json +20 -0
  12. package/scaffold/.agent/skills/total-recall/memory-vault/concepts/cli-help-reference.md +145 -0
  13. package/scaffold/.agent/skills/total-recall/memory-vault/invariants/operating-instructions.md +75 -0
  14. package/scaffold/.agent/skills/total-recall/memory-vault/preferences/always-websearch-gap.md +16 -0
  15. package/scaffold/.agent/skills/total-recall/memory-vault/preferences/topic-research-sop.md +17 -0
  16. package/scaffold/.agent/skills/total-recall/references/api-reference.md +187 -0
  17. package/scaffold/.agent/skills/total-recall/references/architecture-reference.md +81 -0
  18. package/scaffold/.agent/skills/total-recall/references/cli-reference.md +103 -0
  19. package/scaffold/.agent/skills/total-recall/references/ssss-reference.md +88 -0
  20. package/scaffold/.agent/skills/total-recall/references/troubleshooting.md +68 -0
  21. package/scaffold/.agent/skills/total-recall/scripts/sync-repo.mjs +83 -0
  22. package/scaffold/.agent/skills/total-recall/skills/cli-agents/SKILL.md +122 -0
  23. package/scaffold/.agent/skills/total-recall/skills/cli-agents/agents.yml +27 -0
  24. package/scaffold/.agent/skills/total-recall/skills/cli-agents/references/antigravity.txt +20 -0
  25. package/scaffold/.agent/skills/total-recall/skills/cli-agents/references/claude.txt +58 -0
  26. package/scaffold/.agent/skills/total-recall/skills/cli-agents/references/codex.txt +81 -0
  27. package/scaffold/.agent/skills/total-recall/skills/cli-agents/references/gemini.txt +44 -0
  28. package/scaffold/.agent/skills/total-recall/skills/research/SKILL.md +337 -0
  29. package/scaffold/.agent/skills/total-recall/skills/skill/SKILL.md +281 -0
  30. package/scaffold/.agent/skills/total-recall/skills/skill/assets/skill-template/SKILL.md +20 -0
  31. package/scaffold/.agent/skills/total-recall/skills/skill/evals/evals.json +20 -0
  32. package/scaffold/.agent/skills/total-recall/skills/skill/hooks/on-skill-add.sh +14 -0
  33. package/scaffold/.agent/skills/total-recall/skills/skill/hooks/on-skill-delete.sh +14 -0
  34. package/scaffold/.agent/skills/total-recall/skills/skill/learnings/.gitkeep +0 -0
  35. package/scaffold/.agent/skills/total-recall/skills/skill/references/.gitkeep +0 -0
  36. package/scaffold/.agent/skills/total-recall/skills/skill/scripts/.gitkeep +0 -0
  37. package/scaffold/.agent/skills/total-recall/skills/skill/scripts/create-skill.sh +65 -0
  38. package/scaffold/.agent/skills/total-recall/skills/skill/scripts/enforce-skill-optimization.mjs +135 -0
  39. package/scaffold/.agent/skills/total-recall/skills/skill/scripts/find-skills.mjs +124 -0
  40. package/scaffold/.agent/skills/total-recall/skills/skill/scripts/scan-skill.mjs +207 -0
  41. package/scaffold/.agent/skills/total-recall/skills/skill/scripts/skill-watcher.mjs +64 -0
  42. package/scaffold/.agent/skills/total-recall/skills/skill/subagents/audit-all-skills.md +62 -0
  43. package/scaffold/.agent/skills/total-recall/skills/ssss/SKILL.md +27 -0
  44. package/scaffold/.agent/skills/total-recall/skills/ssss/evals/evals.json +17 -0
  45. package/scaffold/.agent/skills/total-recall/skills/ssss/references/ssss-spec.md +117 -0
  46. package/scaffold/.agent/skills/total-recall/skills/ssss/scripts/validate-schema.mjs +54 -0
  47. package/scaffold/.agent/skills/total-recall/skills/ssss/subagents/audit-ssss-node.md +9 -0
  48. package/scaffold/.agent/skills/total-recall/subagents/total-recall-diagnostician.md +13 -0
  49. package/src/cli/agent-dir.mjs +105 -0
  50. package/src/cli/backup.mjs +313 -0
  51. package/src/cli/brain.mjs +224 -0
  52. package/src/cli/chat.mjs +74 -0
  53. package/src/cli/config.mjs +247 -0
  54. package/src/cli/connect.mjs +872 -0
  55. package/src/cli/daemon.mjs +228 -0
  56. package/src/cli/deploy-ui.mjs +1793 -0
  57. package/src/cli/deploy.mjs +1726 -0
  58. package/src/cli/doctor.mjs +254 -0
  59. package/src/cli/dream.mjs +80 -0
  60. package/src/cli/forget.mjs +92 -0
  61. package/src/cli/friction.mjs +21 -0
  62. package/src/cli/generate-pat.mjs +65 -0
  63. package/src/cli/hash-password.mjs +22 -0
  64. package/src/cli/help.mjs +153 -0
  65. package/src/cli/import-rules.mjs +102 -0
  66. package/src/cli/ingest/index.mjs +318 -0
  67. package/src/cli/ingest/parsers/chrome-bookmarks.mjs +238 -0
  68. package/src/cli/ingest/parsers/google-keep.mjs +147 -0
  69. package/src/cli/ingest/parsers/search-history.mjs +142 -0
  70. package/src/cli/ingest/parsers/youtube-history.mjs +181 -0
  71. package/src/cli/ingest/utils/dedup.mjs +69 -0
  72. package/src/cli/ingest/utils/takeout-walker.mjs +101 -0
  73. package/src/cli/ingest.mjs +102 -0
  74. package/src/cli/init.mjs +736 -0
  75. package/src/cli/integration-dispatcher.mjs +128 -0
  76. package/src/cli/lint.mjs +166 -0
  77. package/src/cli/map.mjs +194 -0
  78. package/src/cli/migrate.mjs +87 -0
  79. package/src/cli/rebuild.mjs +88 -0
  80. package/src/cli/recall.mjs +219 -0
  81. package/src/cli/relay.mjs +518 -0
  82. package/src/cli/remember.mjs +306 -0
  83. package/src/cli/research.mjs +381 -0
  84. package/src/cli/reset-password.mjs +75 -0
  85. package/src/cli/restore.mjs +135 -0
  86. package/src/cli/setup.mjs +616 -0
  87. package/src/cli/share.mjs +180 -0
  88. package/src/cli/skill.mjs +313 -0
  89. package/src/cli/snapshot.mjs +108 -0
  90. package/src/cli/start.mjs +63 -0
  91. package/src/cli/status.mjs +296 -0
  92. package/src/cli/sync.mjs +383 -0
  93. package/src/cli/uninstall.mjs +613 -0
  94. package/src/cli/upgrade.mjs +141 -0
  95. package/src/cli/wizard.html +3682 -0
  96. package/src/core/blackboard.mjs +54 -0
  97. package/src/core/clarity-rewriter.mjs +770 -0
  98. package/src/core/conclusion-writer.mjs +259 -0
  99. package/src/core/config.mjs +277 -0
  100. package/src/core/conflict-detector.mjs +536 -0
  101. package/src/core/crypto.mjs +107 -0
  102. package/src/core/daemon-control.mjs +158 -0
  103. package/src/core/daemon-loop.mjs +364 -0
  104. package/src/core/dream.mjs +347 -0
  105. package/src/core/drift-detector.mjs +143 -0
  106. package/src/core/embeddings.mjs +632 -0
  107. package/src/core/emergency-alerts.mjs +137 -0
  108. package/src/core/evolution.mjs +107 -0
  109. package/src/core/fact-seeker.mjs +1602 -0
  110. package/src/core/friction.mjs +95 -0
  111. package/src/core/import-rules.mjs +308 -0
  112. package/src/core/inference-engine.mjs +367 -0
  113. package/src/core/logger.mjs +75 -0
  114. package/src/core/memory-layers.mjs +96 -0
  115. package/src/core/migrate.mjs +116 -0
  116. package/src/core/notifications.mjs +77 -0
  117. package/src/core/operation-validator.mjs +272 -0
  118. package/src/core/optimizer.mjs +330 -0
  119. package/src/core/pattern_detector.mjs +113 -0
  120. package/src/core/post-mortem.mjs +413 -0
  121. package/src/core/protect-instructions.mjs +148 -0
  122. package/src/core/quick-capture.mjs +145 -0
  123. package/src/core/research-queue.mjs +431 -0
  124. package/src/core/research.mjs +461 -0
  125. package/src/core/runtime.mjs +515 -0
  126. package/src/core/sandbox.mjs +163 -0
  127. package/src/core/scheduler.mjs +569 -0
  128. package/src/core/schema.mjs +333 -0
  129. package/src/core/search.mjs +219 -0
  130. package/src/core/semantic-index.mjs +276 -0
  131. package/src/core/session-watcher.mjs +850 -0
  132. package/src/core/snapshot.mjs +133 -0
  133. package/src/core/source-adapters.mjs +1038 -0
  134. package/src/core/steering.mjs +137 -0
  135. package/src/core/surface.mjs +444 -0
  136. package/src/core/task_runner.mjs +125 -0
  137. package/src/core/tts.mjs +142 -0
  138. package/src/core/usage-tracker.mjs +349 -0
  139. package/src/core/vault-cache.mjs +76 -0
  140. package/src/core/vault.mjs +273 -0
  141. package/src/core/watchdog.mjs +236 -0
  142. package/src/server/api.mjs +944 -0
  143. package/src/server/auth.mjs +410 -0
  144. package/src/server/index.mjs +868 -0
  145. package/src/server/keys.mjs +181 -0
  146. package/src/server/rest.mjs +1652 -0
  147. package/src/server/route-manifest.json +77 -0
  148. package/src/server/routes/_shared.mjs +93 -0
  149. package/src/server/routes/keys.mjs +55 -0
  150. package/src/server/routes/memory.mjs +249 -0
  151. package/src/server/routes/sessions.mjs +336 -0
  152. package/src/server/routes/share.mjs +138 -0
  153. package/src/server/tools.mjs +618 -0
  154. package/templates/Caddyfile +37 -0
  155. package/templates/com.totalrecall.backup.plist +42 -0
  156. package/templates/com.totalrecall.daemon.plist +40 -0
  157. package/templates/com.totalrecall.duckdns.plist +23 -0
  158. package/templates/com.totalrecall.server.plist +46 -0
  159. package/templates/default-config/budget.yml +6 -0
  160. package/templates/default-config/runtime.yml +11 -0
  161. package/templates/default-config/security.yml +53 -0
  162. package/templates/default-config/voice.yml +34 -0
  163. package/templates/obsidian-queries/conflicts.md +26 -0
  164. package/templates/obsidian-queries/daily.md +17 -0
  165. package/templates/obsidian-queries/index.md +38 -0
  166. package/templates/obsidian-queries/skills.md +14 -0
  167. package/templates/onboarding-interview.md +72 -0
  168. package/templates/skill_engineering.md +40 -0
  169. package/templates/total-recall-daemon.service +21 -0
  170. 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(/&nbsp;/g, ' ')
139
+ .replace(/&amp;/g, '&')
140
+ .replace(/&lt;/g, '<')
141
+ .replace(/&gt;/g, '>')
142
+ .replace(/&quot;/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
+ */