@yeaft/webchat-agent 0.1.664 → 0.1.666

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.
@@ -1,22 +1,19 @@
1
1
  /**
2
- * consolidate.js — Consolidate = compact + extract (one LLM call)
2
+ * consolidate.js — Hot-window budget partitioning utilities.
3
3
  *
4
- * Triggered when hot_tokens > MESSAGE_TOKEN_BUDGET.
5
- * One LLM call does two things simultaneously:
6
- * 1. Generate compact summary append to compact.md ("short-term memory")
7
- * 2. Extract memory entries → write to entries/ ("long-term memory")
4
+ * Reduced surface (PR-B rip): the legacy LLM-driven consolidate() pipeline
5
+ * (compact summary + entries-store extraction) has been retired. The only
6
+ * survivors are the pure functions used by the compact orchestrator:
8
7
  *
9
- * After consolidation:
10
- * - Processed messages moved from messages/ to cold/
11
- * - index.md + scopes.md updated
8
+ * - shouldConsolidate(store, budget) — decide when to compact
9
+ * - partitionMessages(messages, budget) split hot messages into
10
+ * toArchive / toKeep based on token budget
12
11
  *
13
- * Reference: yeaft-unify-core-systems.md §3.1, §4.2
14
- * yeaft-unify-design.md §6.1
12
+ * Memory extraction is now owned by Dream V2 (per-group diff -> triage ->
13
+ * merge by target scope -> apply via segment-store + summary-store).
14
+ * Conversation summarisation lives in compact/orchestrator.js's hooks.
15
15
  */
16
16
 
17
- import { extractMemories } from './extract.js';
18
- import { pickEffort } from '../effort.js';
19
-
20
17
  // ─── Constants ──────────────────────────────────────────────────
21
18
 
22
19
  /** Default MESSAGE_TOKEN_BUDGET (context * 4%, default ~8192). */
@@ -28,8 +25,6 @@ export const COMPACT_KEEP_RATIO = 0.4;
28
25
  /** Minimum messages to keep hot (newest). */
29
26
  const MIN_KEEP_MESSAGES = 3;
30
27
 
31
- // ─── Consolidate ────────────────────────────────────────────────
32
-
33
28
  /**
34
29
  * Check if consolidation should be triggered.
35
30
  *
@@ -81,113 +76,3 @@ export function partitionMessages(messages, budget = DEFAULT_MESSAGE_TOKEN_BUDGE
81
76
  toKeep: messages.slice(keepStart),
82
77
  };
83
78
  }
84
-
85
- /**
86
- * Generate a compact summary of messages.
87
- *
88
- * @param {object[]} messages — messages to summarize
89
- * @param {object} adapter — LLM adapter with .call()
90
- * @param {object} config — { model }
91
- * @returns {Promise<string>} — compact summary text
92
- */
93
- async function generateSummary(messages, adapter, config) {
94
- const conversation = messages.map(m => {
95
- const prefix = m.role === 'user' ? 'User' : m.role === 'assistant' ? 'Assistant' : m.role;
96
- return `[${prefix}]: ${(m.content || '').slice(0, 500)}`;
97
- }).join('\n\n');
98
-
99
- const system = 'You are a conversation summarizer. Summarize the conversation concisely in 2-3 paragraphs, preserving key decisions, facts, and context. Write in the same language as the conversation.';
100
-
101
- try {
102
- const result = await adapter.call({
103
- model: config.model,
104
- system,
105
- messages: [{ role: 'user', content: `Summarize this conversation:\n\n${conversation}` }],
106
- maxTokens: 1024,
107
- // task-327c: consolidate is a high-complexity side-query; flag as
108
- // 'max' effort so supported models use extended thinking / reasoning.
109
- // Router/adapter silently drops the param for models that don't
110
- // support thinking, or when UNIFY_THINKING_V1 is off.
111
- effort: pickEffort({ scenario: 'consolidate' }),
112
- });
113
- return result.text.trim();
114
- } catch {
115
- // Fallback: simple concatenation of first/last messages
116
- const first = messages[0]?.content?.slice(0, 200) || '';
117
- const last = messages[messages.length - 1]?.content?.slice(0, 200) || '';
118
- return `[Auto-summary failed] Started with: ${first}... Ended with: ${last}`;
119
- }
120
- }
121
-
122
- /**
123
- * Run the full Consolidate pipeline.
124
- *
125
- * 1. Partition messages (what to archive vs keep)
126
- * 2. Generate compact summary (LLM call)
127
- * 3. Extract memory entries (LLM call)
128
- * 4. Move archived messages to cold/
129
- * 5. Update compact.md, index.md, scopes.md
130
- *
131
- * @param {{
132
- * conversationStore: import('../conversation/persist.js').ConversationStore,
133
- * memoryStore: import('./store.js').MemoryStore,
134
- * adapter: object,
135
- * config: object,
136
- * budget?: number
137
- * }} params
138
- * @returns {Promise<{ compactSummary: string, extractedEntries: string[], archivedCount: number }>}
139
- */
140
- export async function consolidate({ conversationStore, memoryStore, adapter, config, budget = DEFAULT_MESSAGE_TOKEN_BUDGET }) {
141
- // Load all hot messages
142
- const messages = conversationStore.loadAll();
143
-
144
- if (messages.length <= MIN_KEEP_MESSAGES) {
145
- return { compactSummary: '', extractedEntries: [], archivedCount: 0 };
146
- }
147
-
148
- // Step 1: Partition
149
- const { toArchive, toKeep } = partitionMessages(messages, budget);
150
-
151
- if (toArchive.length === 0) {
152
- return { compactSummary: '', extractedEntries: [], archivedCount: 0 };
153
- }
154
-
155
- // Step 2: Generate compact summary
156
- const compactSummary = await generateSummary(toArchive, adapter, config);
157
-
158
- // Step 3: Extract memory entries
159
- const extracted = await extractMemories({ messages: toArchive, adapter, config });
160
-
161
- // Step 4: Move archived messages to cold
162
- const archiveIds = toArchive.map(m => m.id).filter(Boolean);
163
- conversationStore.moveToColdBatch(archiveIds);
164
-
165
- // Step 5a: Update compact.md
166
- if (compactSummary) {
167
- conversationStore.updateCompactSummary(compactSummary);
168
- }
169
-
170
- // Step 5b: Write extracted memory entries
171
- const entryNames = [];
172
- for (const entry of extracted) {
173
- const slug = memoryStore.writeEntry(entry);
174
- entryNames.push(slug);
175
- }
176
-
177
- // Step 5c: Update index.md
178
- const lastMsg = toKeep[toKeep.length - 1];
179
- conversationStore.updateIndex({
180
- lastMessageId: lastMsg?.id || null,
181
- });
182
-
183
- // Step 5d: Rebuild scopes.md
184
- if (entryNames.length > 0) {
185
- memoryStore.rebuildScopes();
186
- }
187
-
188
- return {
189
- compactSummary,
190
- extractedEntries: entryNames,
191
- archivedCount: archiveIds.length,
192
- };
193
- }
package/unify/prompts.js CHANGED
@@ -368,27 +368,11 @@ export function buildSystemPrompt({
368
368
  }
369
369
 
370
370
  // ─── 6. Memory Section ─────────────────────────────────
371
+ // FTS5 pre-flow recall + AMS snapshot are concatenated upstream by the
372
+ // engine into a single `memoryInjection` block. The legacy entries-based
373
+ // memory.profile / memory.entries shape was retired in the H2-AMS rip.
371
374
  if (memoryInjection && memoryInjection.trim()) {
372
- // New path (task-287): prebuilt injection from memory/layout.buildMemoryInjection()
373
- // Contains index.md + user-preferences.md + optional project header excerpt.
374
375
  parts.push(memoryInjection.trim());
375
- } else if (memory && (memory.profile || (memory.entries && memory.entries.length > 0))) {
376
- // Legacy path — kept for callers (tests, CLI) that have not migrated yet.
377
- const memoryParts = [lang.memoryHeader];
378
-
379
- if (memory.profile) {
380
- memoryParts.push(`${lang.profileHeader}\n${memory.profile}`);
381
- }
382
-
383
- if (memory.entries && memory.entries.length > 0) {
384
- const entryLines = memory.entries.map(e => {
385
- const tags = (e.tags && e.tags.length > 0) ? ` [${e.tags.join(', ')}]` : '';
386
- return `- **${e.name}** (${e.kind}): ${e.content}${tags}`;
387
- });
388
- memoryParts.push(`${lang.recalledHeader}\n${entryLines.join('\n')}`);
389
- }
390
-
391
- parts.push(memoryParts.join('\n\n'));
392
376
  }
393
377
 
394
378
  // ─── 7. Compact Summary Section ────────────────────────
package/unify/session.js CHANGED
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * Wires all subsystems together:
7
7
  * initYeaftDir → loadConfig → createTrace → createLLMAdapter →
8
- * ConversationStore → MemoryStore → SkillManager → MCPManager →
8
+ * ConversationStore → SkillManager → MCPManager →
9
9
  * ToolRegistry → Engine → Session
10
10
  *
11
11
  * The ~/.yeaft/ directory is the agent's persistent workspace.
@@ -18,8 +18,6 @@ import { loadConfig, loadMCPConfig } from './config.js';
18
18
  import { createTrace } from './debug-trace.js';
19
19
  import { createLLMAdapter } from './llm/adapter.js';
20
20
  import { ConversationStore } from './conversation/persist.js';
21
- import { MemoryStore } from './memory/store.js';
22
- import { openMemoryShardStore } from './memory/shard-store.js';
23
21
  import { SkillManager, createSkillManager } from './skills.js';
24
22
  import { MCPManager } from './mcp.js';
25
23
  import { createFullRegistry } from './tools/index.js';
@@ -68,7 +66,6 @@ import { existsSync as existsSyncSafe, readFileSync as readFileSyncSafe } from '
68
66
  * @property {import('./llm/adapter.js').LLMAdapter} adapter — The LLM adapter
69
67
  * @property {object} config — Resolved configuration
70
68
  * @property {ConversationStore} conversationStore — Conversation persistence
71
- * @property {MemoryStore} memoryStore — Memory persistence
72
69
  * @property {SkillManager} skillManager — Skill manager
73
70
  * @property {MCPManager} mcpManager — MCP manager
74
71
  * @property {import('./tools/registry.js').ToolRegistry} toolRegistry — Tool registry
@@ -169,19 +166,6 @@ export async function loadSession(options = {}) {
169
166
 
170
167
  // ─── 5. Create stores ──────────────────────────────────
171
168
  const conversationStore = new ConversationStore(yeaftDir);
172
- const memoryStore = new MemoryStore(yeaftDir);
173
-
174
- // ─── 5-shard. Open R6 memory shard store (task-334f) ──────
175
- // VP-level memory shard store rooted at ~/.yeaft/memory/vp/default.
176
- // The 'default' VP matches the single-user Unify mode; R6 multi-VP
177
- // callers open per-VP stores via openMemoryShardStore() directly.
178
- const memoryShardDir = join(yeaftDir, 'memory', 'vp', 'default');
179
- let memoryShardStore = null;
180
- try {
181
- memoryShardStore = openMemoryShardStore(memoryShardDir, 'vp');
182
- } catch (err) {
183
- console.warn(`[Yeaft] Failed to open R6 memory shard store: ${err?.message || err}`);
184
- }
185
169
 
186
170
  // ─── 5-fts. (GC.1) Open SegmentIndex for FTS pre-flow ────
187
171
  // When config.memoryV2 is on, build a SQLite FTS5 index over
@@ -290,8 +274,6 @@ export async function loadSession(options = {}) {
290
274
  trace,
291
275
  config,
292
276
  conversationStore,
293
- memoryStore,
294
- memoryShardStore,
295
277
  memoryIndex,
296
278
  amsRegistry,
297
279
  toolRegistry,
@@ -363,8 +345,6 @@ export async function loadSession(options = {}) {
363
345
  adapter,
364
346
  config,
365
347
  conversationStore,
366
- memoryStore,
367
- memoryShardStore,
368
348
  dreamScheduler,
369
349
  skillManager,
370
350
  mcpManager,
@@ -3,17 +3,17 @@
3
3
  *
4
4
  * Runs after each query loop completes:
5
5
  * 1. Persist messages to conversation/messages/
6
- * 2. Consolidate check (compact + extract) — only when budget exceeded
6
+ *
7
+ * Consolidation (compact orchestrator) is driven by the engine itself
8
+ * via `#maybeConsolidate`; the legacy LLM-driven `consolidate()` plus
9
+ * entries-store extraction was retired in the H2-AMS rip.
7
10
  *
8
11
  * Dream V2 owns all background memory maintenance (scope summaries +
9
- * memory writes via dream-v2/session-wiring.js → createV2DreamScheduler);
10
- * the legacy `memory/dream.js` gate that used to fire here was retired
11
- * alongside recall-r6.
12
+ * memory writes via dream-v2/session-wiring.js → createV2DreamScheduler).
12
13
  *
13
14
  * Reference: yeaft-unify-core-systems.md §4.4
14
15
  */
15
16
 
16
- import { shouldConsolidate, consolidate } from './memory/consolidate.js';
17
17
  import { isPermissionError } from './init.js';
18
18
 
19
19
  /** Track whether we've already warned about permission issues in stop hooks. */
@@ -26,7 +26,6 @@ let _permissionWarned = false;
26
26
  * yeaftDir: string,
27
27
  * mode: string,
28
28
  * conversationStore: import('./conversation/persist.js').ConversationStore,
29
- * memoryStore: import('./memory/store.js').MemoryStore,
30
29
  * adapter: object,
31
30
  * config: object,
32
31
  * primaryModel?: string,
@@ -42,7 +41,6 @@ export async function runStopHooks(context) {
42
41
  yeaftDir,
43
42
  mode,
44
43
  conversationStore,
45
- memoryStore,
46
44
  adapter,
47
45
  config,
48
46
  primaryModel,
@@ -60,7 +58,6 @@ export async function runStopHooks(context) {
60
58
 
61
59
  const result = {
62
60
  messagesPersisted: 0,
63
- consolidated: false,
64
61
  errors: [],
65
62
  };
66
63
 
@@ -133,41 +130,9 @@ export async function runStopHooks(context) {
133
130
  }
134
131
  }
135
132
 
136
- // 2. Consolidate check (non-blocking, but awaited for correctness)
137
- try {
138
- if (conversationStore && memoryStore && adapter) {
139
- if (shouldConsolidate(conversationStore, config.messageTokenBudget)) {
140
- const consolidated = await consolidate({
141
- conversationStore,
142
- memoryStore,
143
- adapter,
144
- config,
145
- budget: config.messageTokenBudget,
146
- });
147
- result.consolidated = true;
148
- trace?.logEvent({
149
- eventType: 'consolidate',
150
- eventData: {
151
- archivedCount: consolidated.archivedCount,
152
- extractedEntries: consolidated.extractedEntries.length,
153
- },
154
- });
155
- }
156
- }
157
- } catch (err) {
158
- if (isPermissionError(err)) {
159
- if (!_permissionWarned) {
160
- result.errors.push('Cannot write to ~/.yeaft/ — consolidation skipped');
161
- _permissionWarned = true;
162
- }
163
- } else {
164
- result.errors.push(`Consolidate failed: ${err.message}`);
165
- }
166
- }
167
-
168
- // 3. Dream V2 owns background scope-memory maintenance via the session
169
- // dream scheduler (createV2DreamScheduler). No legacy dream gate is
170
- // invoked here; the scheduler decides when to run on its own cadence.
133
+ // 2. Consolidation is owned by the engine (#maybeConsolidate → compact
134
+ // orchestrator). Dream V2 owns background scope-memory maintenance
135
+ // via the session dream scheduler (createV2DreamScheduler).
171
136
 
172
137
  return result;
173
138
  }
@@ -175,6 +140,5 @@ export async function runStopHooks(context) {
175
140
  /**
176
141
  * @typedef {Object} StopHookResult
177
142
  * @property {number} messagesPersisted — how many messages were persisted
178
- * @property {boolean} consolidated — whether consolidation ran
179
143
  * @property {string[]} errors — any non-fatal errors
180
144
  */
@@ -18,11 +18,6 @@ import exitWorktree from './exit-worktree.js';
18
18
 
19
19
  // --- P0 Core tools ---
20
20
  import askUser from './ask-user.js';
21
- import memoryRead from './memory-read.js';
22
- import memoryWrite from './memory-write.js';
23
- import memorySearch, { memorySearchAlias } from './memory-search.js';
24
- import memoryQuery from './memory-query.js';
25
- import memoryTrace from './memory-trace.js';
26
21
  import openSourceMessage from './open-source-message.js';
27
22
  import webSearch from './web-search.js';
28
23
  import webFetch from './web-fetch.js';
@@ -89,12 +84,6 @@ export const allTools = [
89
84
 
90
85
  // P0 Core
91
86
  askUser,
92
- memoryRead,
93
- memoryWrite,
94
- memorySearch,
95
- memorySearchAlias,
96
- memoryQuery,
97
- memoryTrace,
98
87
  openSourceMessage,
99
88
  webSearch,
100
89
  webFetch,
@@ -1,8 +1,24 @@
1
1
  /**
2
2
  * web-search.js — Web search tool.
3
3
  *
4
- * Delegates to an external search API or LLM-based web search.
5
- * Supports configurable search providers via Yeaft config.
4
+ * Strategy (in order):
5
+ * 1. Tavily API (default; configured via ~/.yeaft/config.json → search.tavilyApiKey)
6
+ * 2. Generic searchApiUrl (legacy; user-supplied JSON-returning endpoint)
7
+ * 3. HTML-scrape fallback: DuckDuckGo lite then Bing
8
+ * (works on residential IPs; cloud IPs are usually flagged as bots)
9
+ *
10
+ * Config shape in ~/.yeaft/config.json:
11
+ * {
12
+ * "search": {
13
+ * "tavilyApiKey": "tvly-...",
14
+ * "searchApiUrl": "https://...", // optional, alternative JSON endpoint
15
+ * "disableHtmlFallback": false // optional, opt-out of scraping
16
+ * }
17
+ * }
18
+ *
19
+ * The result is JSON-stringified so the LLM can parse it. We intentionally
20
+ * keep the output shape consistent across providers: { provider, query,
21
+ * answer?, results: [{title, url, snippet}] }.
6
22
  */
7
23
 
8
24
  import { defineTool } from './types.js';
@@ -36,44 +52,204 @@ Guidelines:
36
52
  isReadOnly: () => true,
37
53
  async execute(input, ctx) {
38
54
  const { query, limit = 5 } = input;
39
- if (!query) return JSON.stringify({ error: 'query is required' });
40
-
41
- try {
42
- // Check if adapter supports web search natively (some LLM providers have built-in search)
43
- const adapter = ctx?.adapter;
44
- if (adapter && typeof adapter.webSearch === 'function') {
45
- const results = await adapter.webSearch(query, limit);
46
- return JSON.stringify(results, null, 2);
47
- }
48
-
49
- // Check config for search API endpoint
50
- const searchUrl = ctx?.config?.searchApiUrl;
51
- if (searchUrl) {
52
- const url = new URL(searchUrl);
53
- url.searchParams.set('q', query);
54
- url.searchParams.set('limit', String(limit));
55
-
56
- const response = await fetch(url.toString(), {
57
- signal: ctx?.signal,
58
- headers: { 'User-Agent': 'Yeaft/1.0' },
59
- });
60
-
61
- if (!response.ok) {
62
- return JSON.stringify({ error: `Search API returned ${response.status}: ${response.statusText}` });
63
- }
64
-
65
- const data = await response.json();
66
- return JSON.stringify(data, null, 2);
67
- }
68
-
69
- // Fallback: no search provider configured
70
- return JSON.stringify({
71
- error: 'No web search provider configured.',
72
- hint: 'Configure searchApiUrl in ~/.yeaft/config.json or use an LLM provider with built-in search.',
73
- });
74
- } catch (err) {
75
- if (err.name === 'AbortError') return JSON.stringify({ error: 'Search cancelled' });
76
- return JSON.stringify({ error: `Web search failed: ${err.message}` });
55
+ if (!query || typeof query !== 'string') {
56
+ return JSON.stringify({ error: 'query is required' });
57
+ }
58
+
59
+ const search = ctx?.config?.search || {};
60
+ const signal = ctx?.signal;
61
+ const errors = [];
62
+
63
+ // 1. Tavily — default, fast, structured.
64
+ if (search.tavilyApiKey) {
65
+ const r = await tryTavily(query, limit, search.tavilyApiKey, signal);
66
+ if (r.ok) return JSON.stringify(r.data, null, 2);
67
+ errors.push(`tavily: ${r.error}`);
68
+ }
69
+
70
+ // 2. Generic JSON endpoint (legacy escape hatch — SearXNG, custom proxy, etc).
71
+ const genericUrl = search.searchApiUrl || ctx?.config?.searchApiUrl;
72
+ if (genericUrl) {
73
+ const r = await tryGenericApi(query, limit, genericUrl, signal);
74
+ if (r.ok) return JSON.stringify(r.data, null, 2);
75
+ errors.push(`searchApiUrl: ${r.error}`);
76
+ }
77
+
78
+ // 3. HTML-scrape fallback. Often blocked on cloud IPs; useful for
79
+ // self-hosted / residential setups with no API key.
80
+ if (!search.disableHtmlFallback) {
81
+ const r = await tryHtmlScrape(query, limit, signal);
82
+ if (r.ok) return JSON.stringify(r.data, null, 2);
83
+ errors.push(`html: ${r.error}`);
77
84
  }
85
+
86
+ return JSON.stringify({
87
+ error: 'No web search backend succeeded.',
88
+ attempted: errors,
89
+ hint: 'Set search.tavilyApiKey in ~/.yeaft/config.json (free tier: https://tavily.com).',
90
+ });
78
91
  },
79
92
  });
93
+
94
+ // ─── Backend implementations ────────────────────────────────────────
95
+
96
+ async function tryTavily(query, limit, apiKey, signal) {
97
+ try {
98
+ const res = await fetch('https://api.tavily.com/search', {
99
+ method: 'POST',
100
+ signal,
101
+ headers: { 'Content-Type': 'application/json' },
102
+ body: JSON.stringify({
103
+ api_key: apiKey,
104
+ query,
105
+ max_results: Math.max(1, Math.min(limit, 10)),
106
+ include_answer: true,
107
+ search_depth: 'basic',
108
+ }),
109
+ });
110
+ if (!res.ok) {
111
+ const text = await res.text().catch(() => '');
112
+ return { ok: false, error: `${res.status} ${res.statusText} ${text.slice(0, 200)}` };
113
+ }
114
+ const data = await res.json();
115
+ return {
116
+ ok: true,
117
+ data: {
118
+ provider: 'tavily',
119
+ query,
120
+ answer: data.answer || null,
121
+ results: (data.results || []).slice(0, limit).map((r) => ({
122
+ title: r.title,
123
+ url: r.url,
124
+ snippet: r.content,
125
+ score: r.score,
126
+ })),
127
+ },
128
+ };
129
+ } catch (err) {
130
+ if (err?.name === 'AbortError') return { ok: false, error: 'cancelled' };
131
+ return { ok: false, error: err.message || String(err) };
132
+ }
133
+ }
134
+
135
+ async function tryGenericApi(query, limit, urlStr, signal) {
136
+ try {
137
+ const url = new URL(urlStr);
138
+ url.searchParams.set('q', query);
139
+ url.searchParams.set('limit', String(limit));
140
+ const res = await fetch(url.toString(), {
141
+ signal,
142
+ headers: { 'User-Agent': 'Yeaft/1.0', Accept: 'application/json' },
143
+ });
144
+ if (!res.ok) return { ok: false, error: `${res.status} ${res.statusText}` };
145
+ const data = await res.json();
146
+ return { ok: true, data: { provider: 'generic', query, ...data } };
147
+ } catch (err) {
148
+ if (err?.name === 'AbortError') return { ok: false, error: 'cancelled' };
149
+ return { ok: false, error: err.message || String(err) };
150
+ }
151
+ }
152
+
153
+ /**
154
+ * HTML-scrape fallback. Tries DuckDuckGo's lite HTML endpoint first
155
+ * (smaller markup, but more aggressive bot detection on cloud IPs),
156
+ * then Bing. We intentionally keep the regex-based parsers minimal —
157
+ * they break less than full DOM selectors when sites tweak markup.
158
+ */
159
+ async function tryHtmlScrape(query, limit, signal) {
160
+ const ua = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
161
+ const headers = { 'User-Agent': ua, 'Accept-Language': 'en-US,en;q=0.9' };
162
+
163
+ try {
164
+ const ddg = await fetch(`https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`, { signal, headers });
165
+ if (ddg.ok) {
166
+ const html = await ddg.text();
167
+ const results = parseDdgHtml(html, limit);
168
+ if (results.length) return { ok: true, data: { provider: 'duckduckgo-html', query, results } };
169
+ }
170
+ } catch (err) {
171
+ if (err?.name === 'AbortError') return { ok: false, error: 'cancelled' };
172
+ }
173
+
174
+ try {
175
+ const bing = await fetch(`https://www.bing.com/search?q=${encodeURIComponent(query)}`, { signal, headers });
176
+ if (bing.ok) {
177
+ const html = await bing.text();
178
+ const results = parseBingHtml(html, limit);
179
+ if (results.length) return { ok: true, data: { provider: 'bing-html', query, results } };
180
+ }
181
+ } catch (err) {
182
+ if (err?.name === 'AbortError') return { ok: false, error: 'cancelled' };
183
+ }
184
+
185
+ return { ok: false, error: 'all HTML scrape backends returned 0 results (likely bot-blocked)' };
186
+ }
187
+
188
+ /**
189
+ * Parse DDG lite HTML. Each result is wrapped in
190
+ * <a class="result__a" href="…">title</a>
191
+ * <a class="result__snippet">snippet</a>
192
+ * Hash classes are not used here, so plain regex is fine.
193
+ */
194
+ function parseDdgHtml(html, limit) {
195
+ const results = [];
196
+ const linkRe = /<a[^>]+class="result__a"[^>]+href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/g;
197
+ const snippetRe = /<a[^>]+class="result__snippet"[^>]*>([\s\S]*?)<\/a>/g;
198
+ const links = [...html.matchAll(linkRe)];
199
+ const snippets = [...html.matchAll(snippetRe)];
200
+ for (let i = 0; i < links.length && results.length < limit; i++) {
201
+ const url = decodeDdgUrl(links[i][1]);
202
+ const title = stripTags(links[i][2]).trim();
203
+ const snippet = snippets[i] ? stripTags(snippets[i][1]).trim() : '';
204
+ if (url && title) results.push({ title, url, snippet });
205
+ }
206
+ return results;
207
+ }
208
+
209
+ /**
210
+ * DDG often wraps outbound URLs in `/l/?uddg=…` redirects. Unwrap.
211
+ */
212
+ function decodeDdgUrl(href) {
213
+ try {
214
+ if (href.startsWith('//')) href = 'https:' + href;
215
+ const u = new URL(href, 'https://duckduckgo.com');
216
+ const target = u.searchParams.get('uddg');
217
+ return target ? decodeURIComponent(target) : u.toString();
218
+ } catch {
219
+ return href;
220
+ }
221
+ }
222
+
223
+ /**
224
+ * Parse Bing search HTML. Result blocks: <li class="b_algo"> with
225
+ * <h2><a href="…">title</a></h2> and <p>snippet</p>. The class names
226
+ * have been stable for years; if Bing rotates them this will fail
227
+ * gracefully (no results extracted) and we'll surface the error upstream.
228
+ */
229
+ function parseBingHtml(html, limit) {
230
+ const results = [];
231
+ const blockRe = /<li[^>]+class="[^"]*\bb_algo\b[^"]*"[^>]*>([\s\S]*?)<\/li>/g;
232
+ for (const m of html.matchAll(blockRe)) {
233
+ if (results.length >= limit) break;
234
+ const block = m[1];
235
+ const linkM = block.match(/<h2[^>]*>\s*<a[^>]+href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/);
236
+ if (!linkM) continue;
237
+ const url = linkM[1];
238
+ const title = stripTags(linkM[2]).trim();
239
+ const pM = block.match(/<p[^>]*>([\s\S]*?)<\/p>/);
240
+ const snippet = pM ? stripTags(pM[1]).trim() : '';
241
+ if (url && title) results.push({ title, url, snippet });
242
+ }
243
+ return results;
244
+ }
245
+
246
+ function stripTags(s) {
247
+ return s
248
+ .replace(/<[^>]+>/g, '')
249
+ .replace(/&amp;/g, '&')
250
+ .replace(/&lt;/g, '<')
251
+ .replace(/&gt;/g, '>')
252
+ .replace(/&quot;/g, '"')
253
+ .replace(/&#39;/g, "'")
254
+ .replace(/&nbsp;/g, ' ');
255
+ }