@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.
- package/connection/message-router.js +1 -24
- package/package.json +1 -1
- package/unify/cli.js +5 -84
- package/unify/engine.js +22 -130
- package/unify/features/summary.js +15 -98
- package/unify/index.js +0 -2
- package/unify/memory/consolidate.js +10 -125
- package/unify/prompts.js +3 -19
- package/unify/session.js +1 -21
- package/unify/stop-hooks.js +8 -44
- package/unify/tools/index.js +0 -11
- package/unify/tools/web-search.js +216 -40
- package/unify/web-bridge.js +0 -98
- package/unify/memory/dream-shard.js +0 -722
- package/unify/memory/extract.js +0 -101
- package/unify/memory/layout.js +0 -358
- package/unify/memory/schema.js +0 -166
- package/unify/memory/shard-store.js +0 -373
- package/unify/memory/store.js +0 -578
- package/unify/memory/types.js +0 -139
- package/unify/memory/user-memory-store.js +0 -452
- package/unify/tools/memory-query.js +0 -134
- package/unify/tools/memory-read.js +0 -90
- package/unify/tools/memory-search.js +0 -140
- package/unify/tools/memory-trace.js +0 -135
- package/unify/tools/memory-write.js +0 -113
- package/unify/user-memory.js +0 -107
|
@@ -1,22 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* consolidate.js —
|
|
2
|
+
* consolidate.js — Hot-window budget partitioning utilities.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
*
|
|
10
|
-
* -
|
|
11
|
-
*
|
|
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
|
-
*
|
|
14
|
-
*
|
|
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 →
|
|
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,
|
package/unify/stop-hooks.js
CHANGED
|
@@ -3,17 +3,17 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Runs after each query loop completes:
|
|
5
5
|
* 1. Persist messages to conversation/messages/
|
|
6
|
-
*
|
|
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.
|
|
137
|
-
|
|
138
|
-
|
|
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
|
*/
|
package/unify/tools/index.js
CHANGED
|
@@ -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
|
-
*
|
|
5
|
-
*
|
|
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
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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(/&/g, '&')
|
|
250
|
+
.replace(/</g, '<')
|
|
251
|
+
.replace(/>/g, '>')
|
|
252
|
+
.replace(/"/g, '"')
|
|
253
|
+
.replace(/'/g, "'")
|
|
254
|
+
.replace(/ /g, ' ');
|
|
255
|
+
}
|