@heretek-ai/epistemic-swarm 0.7.16 → 0.7.17
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/.claude-plugin/plugin.json +1 -1
- package/README.md +20 -0
- package/install.sh +233 -14
- package/package.json +1 -1
- package/plugins/opencode/index.js +598 -1
- package/plugins/socratic-grilling/skills/grilling/socratic_tree.py +159 -24
- package/runner/__pycache__/__init__.cpython-311.pyc +0 -0
- package/runner/__pycache__/auctioneer.cpython-311.pyc +0 -0
- package/runner/__pycache__/auditor_engine.cpython-311.pyc +0 -0
- package/runner/__pycache__/claim_store.cpython-311.pyc +0 -0
- package/runner/__pycache__/claim_witness.cpython-311.pyc +0 -0
- package/runner/__pycache__/darkharvest_claims.cpython-311.pyc +0 -0
- package/runner/__pycache__/errors.cpython-311.pyc +0 -0
- package/runner/__pycache__/living_dossiers.cpython-311.pyc +0 -0
- package/runner/__pycache__/mcp_protocol.cpython-311.pyc +0 -0
- package/runner/__pycache__/mcp_server.cpython-311.pyc +0 -0
- package/runner/__pycache__/path_safety.cpython-311.pyc +0 -0
- package/runner/__pycache__/pcrb.cpython-311.pyc +0 -0
- package/runner/__pycache__/pcrb_verify.cpython-311.pyc +0 -0
- package/runner/__pycache__/preflight.cpython-311.pyc +0 -0
- package/runner/__pycache__/refinement.cpython-311.pyc +0 -0
- package/runner/__pycache__/research_swarm.cpython-311.pyc +0 -0
- package/runner/__pycache__/retrieval_log.cpython-311.pyc +0 -0
- package/runner/__pycache__/schema_validate.cpython-311.pyc +0 -0
- package/runner/__pycache__/schemas.cpython-311.pyc +0 -0
- package/runner/__pycache__/state_machine.cpython-311.pyc +0 -0
- package/runner/mcp_server.py +79 -28
- package/runner/preflight.py +829 -12
- package/runner/research_swarm.py +68 -5
- package/runner/tests/__pycache__/test_auction_order.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_backends.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_bet1_spike.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_claim_store.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_claim_witness.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_claude_plugin.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_contract_tooling.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_darkharvest_audit.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_domain_packs.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_dossier_contract.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_factory.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_fleet_seam.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_frontier_concurrency.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_frontier_mirror_standalone.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_grounding.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_living_dossiers.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_manifest_concurrency.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_mcp_server.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_opencode_ux.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_opencode_v2_registrars.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_pcrb.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_refinement.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_search_anomaly.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_search_provider.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_search_provider_plugin.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_swarm.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_swarm_config.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_sweep_regressions.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_webcache.cpython-311.pyc +0 -0
- package/runner/tests/fixtures/ddg_anomaly_page.html +176 -0
- package/runner/tests/fixtures/ddg_results_page.html +28 -0
- package/runner/tests/test_frontier_concurrency.py +288 -0
- package/runner/tests/test_frontier_mirror_standalone.py +156 -0
- package/runner/tests/test_search_anomaly.py +277 -0
- package/runner/tests/test_search_provider.py +1464 -0
- package/runner/tests/test_search_provider_plugin.py +401 -0
- package/scripts/__pycache__/bet1_advisory_spike.cpython-311.pyc +0 -0
- package/scripts/__pycache__/build_adapters.cpython-311.pyc +0 -0
- package/scripts/__pycache__/check_docs.cpython-311.pyc +0 -0
- package/scripts/__pycache__/divergence_experiment.cpython-311.pyc +0 -0
- package/scripts/__pycache__/gen_schemas.cpython-311.pyc +0 -0
- package/skills/epistemic_search/SKILL.md +32 -0
- package/skills/epistemic_search/scripts/__pycache__/search.cpython-311.pyc +0 -0
- package/skills/epistemic_search/scripts/search.py +232 -19
- package/skills/grilling/__pycache__/__init__.cpython-311.pyc +0 -0
- package/skills/grilling/__pycache__/socratic_tree.cpython-311.pyc +0 -0
- package/skills/grilling/socratic_tree.py +159 -24
- package/skills/research_cache/__pycache__/__init__.cpython-311.pyc +0 -0
- package/skills/research_cache/__pycache__/hasher.cpython-311.pyc +0 -0
- package/skills/swarm_config/__pycache__/__init__.cpython-311.pyc +0 -0
- package/skills/swarm_config/__pycache__/configure.cpython-311.pyc +0 -0
|
@@ -12,7 +12,17 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { spawn } from 'node:child_process';
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
accessSync,
|
|
17
|
+
appendFileSync,
|
|
18
|
+
constants,
|
|
19
|
+
mkdirSync,
|
|
20
|
+
readFileSync,
|
|
21
|
+
realpathSync,
|
|
22
|
+
rmSync,
|
|
23
|
+
statSync,
|
|
24
|
+
writeFileSync,
|
|
25
|
+
} from 'node:fs';
|
|
16
26
|
import path from 'node:path';
|
|
17
27
|
import { fileURLToPath } from 'node:url';
|
|
18
28
|
|
|
@@ -1008,6 +1018,588 @@ async function registerHostTools(host) {
|
|
|
1008
1018
|
return registration ? [registration] : [];
|
|
1009
1019
|
}
|
|
1010
1020
|
|
|
1021
|
+
// ---------------------------------------------------------------------------
|
|
1022
|
+
// Cached search provider (Phase 03-search-anomaly, deliverables 2-4)
|
|
1023
|
+
// ---------------------------------------------------------------------------
|
|
1024
|
+
//
|
|
1025
|
+
// OpenCode V2 ships four provider-backed websearch engines, each keyed by an
|
|
1026
|
+
// env var: exa/FIRECRAWL/parallel/tavily
|
|
1027
|
+
// [VERIFIED: 7414d12951b5bf3ca0f26853f562e6450dc1f4734e5330763b420fe173e20003].
|
|
1028
|
+
// Console hosted websearch bills $0.01 per successful search
|
|
1029
|
+
// [VERIFIED: 6301b3c686f9e404b0e2147ec42255c1ab923e030e1f656c90478b54481ebab6].
|
|
1030
|
+
// A plugin registers a provider and selects it via `ctx.websearch.transform`
|
|
1031
|
+
// with `editor.add(...)` / `editor.default.set(...)`
|
|
1032
|
+
// [VERIFIED: dc72ef876fa6d90d288597b81806ef5129bc87deec7562081881901764bed2bd].
|
|
1033
|
+
//
|
|
1034
|
+
// We register ONE provider that routes through the free-first ladder:
|
|
1035
|
+
//
|
|
1036
|
+
// 0. host-connected provider (`/connect` integration store) -> metered
|
|
1037
|
+
// 1. BYO provider key (exa/firecrawl/parallel/tavily/tinyfish) -> metered
|
|
1038
|
+
// 2. self-hosted SearXNG (SEARXNG_URL) -> free
|
|
1039
|
+
// 3. DuckDuckGo Lite (zero-key) -> free, via our Python path
|
|
1040
|
+
// 4. Console (hosted) -> metered, never implicit
|
|
1041
|
+
//
|
|
1042
|
+
// NAMING HONESTY: the provider id/name say "cached", but `execute` performs no
|
|
1043
|
+
// in-memory memoisation — caching happens in the SURROUNDING evidence pipeline
|
|
1044
|
+
// (`hasher.py cache` content-addresses the full page after an explicit host
|
|
1045
|
+
// `webfetch`). What this provider guarantees is telemetry: every rung it runs
|
|
1046
|
+
// appends a `query` event (with provider + status) to
|
|
1047
|
+
// `.research/retrieval.jsonl`, so the preflight cost line is truthful for
|
|
1048
|
+
// metered rungs and not just the DDG path.
|
|
1049
|
+
//
|
|
1050
|
+
// Whatever the rung, `execute` returns *hits* (title/url/content) only. Full
|
|
1051
|
+
// page content MUST be fetched explicitly by the agent via host `webfetch` and
|
|
1052
|
+
// cached through `python3 skills/research_cache/hasher.py cache ...`; a snippet
|
|
1053
|
+
// alone can never witness a [VERIFIED: <hash>] claim.
|
|
1054
|
+
//
|
|
1055
|
+
// We never override a user's deliberate `websearch.provider` OR a provider they
|
|
1056
|
+
// connected with `/connect`: `default.set` is called ONLY when the editor
|
|
1057
|
+
// reports no default at all AND we know of no explicit/connected choice.
|
|
1058
|
+
|
|
1059
|
+
export const WEBSEARCH_PROVIDER_ID = 'iumbtems-cached';
|
|
1060
|
+
export const WEBSEARCH_PROVIDER_NAME = 'IUMBTEMS search (cached evidence pipeline)';
|
|
1061
|
+
|
|
1062
|
+
const BYO_PROVIDER_KEYS = {
|
|
1063
|
+
exa: 'EXA_API_KEY',
|
|
1064
|
+
firecrawl: 'FIRECRAWL_API_KEY',
|
|
1065
|
+
parallel: 'PARALLEL_API_KEY',
|
|
1066
|
+
tavily: 'TAVILY_API_KEY',
|
|
1067
|
+
tinyfish: 'TINYFISH_API_KEY',
|
|
1068
|
+
};
|
|
1069
|
+
const FREE_WEBSEARCH_PROVIDERS = new Set(['duckduckgo', 'ddg', 'searxng']);
|
|
1070
|
+
const METERED_WEBSEARCH_PROVIDERS = new Set([
|
|
1071
|
+
'brave',
|
|
1072
|
+
'console',
|
|
1073
|
+
...Object.keys(BYO_PROVIDER_KEYS),
|
|
1074
|
+
]);
|
|
1075
|
+
const CONSOLE_WEBSEARCH_PROVIDER = 'console';
|
|
1076
|
+
const RETRIEVAL_LOG_NAME = 'retrieval.jsonl';
|
|
1077
|
+
const CONNECTED_STATE_NAME = 'websearch-state.json';
|
|
1078
|
+
// Bound an execute() that never settles so a hung upstream cannot leave the
|
|
1079
|
+
// host waiting forever (the host may not supply its own abort signal).
|
|
1080
|
+
export const DEFAULT_EXECUTE_TIMEOUT_MS = 20000;
|
|
1081
|
+
|
|
1082
|
+
/** "free" | "metered" | "unknown" — mirrors runner/preflight.py. */
|
|
1083
|
+
export function providerClassification(provider) {
|
|
1084
|
+
const key = String(provider || '').trim().toLowerCase();
|
|
1085
|
+
if (FREE_WEBSEARCH_PROVIDERS.has(key)) return 'free';
|
|
1086
|
+
if (METERED_WEBSEARCH_PROVIDERS.has(key)) return 'metered';
|
|
1087
|
+
return 'unknown';
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
/** First BYO provider whose API key is present, in ladder order. */
|
|
1091
|
+
export function detectByoProvider(env = process.env) {
|
|
1092
|
+
for (const [provider, key] of Object.entries(BYO_PROVIDER_KEYS)) {
|
|
1093
|
+
if (String(env?.[key] || '').trim()) return provider;
|
|
1094
|
+
}
|
|
1095
|
+
return null;
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
/**
|
|
1099
|
+
* Free-first ladder. Mirrors `runner/preflight.py:resolve_search_provider` so
|
|
1100
|
+
* the plugin's choice and the preflight cost line agree. Console is never
|
|
1101
|
+
* chosen implicitly — it only surfaces when `explicit` names it. A provider the
|
|
1102
|
+
* user connected via `/connect` (`connected`) beats an env key because its
|
|
1103
|
+
* credential lives in the host's integration store, not our env.
|
|
1104
|
+
*/
|
|
1105
|
+
export function resolveWebsearchUpstream(env = process.env, explicit = undefined, connected = undefined) {
|
|
1106
|
+
const norm = (v) => String(v || '').trim().toLowerCase();
|
|
1107
|
+
const explicitId = norm(explicit);
|
|
1108
|
+
if (explicitId) return { provider: explicitId, explicit: true, source: 'configured' };
|
|
1109
|
+
const connectedList = (Array.isArray(connected) ? connected : [connected])
|
|
1110
|
+
.map(norm)
|
|
1111
|
+
.filter(Boolean);
|
|
1112
|
+
for (const id of connectedList) {
|
|
1113
|
+
if (id && id !== 'duckduckgo' && id !== 'ddg' && id !== 'searxng' && id !== CONSOLE_WEBSEARCH_PROVIDER) {
|
|
1114
|
+
return { provider: id, explicit: false, source: 'host-connected' };
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
const byo = detectByoProvider(env);
|
|
1118
|
+
if (byo) return { provider: byo, explicit: false, source: 'byo-env-key' };
|
|
1119
|
+
if (norm(env?.SEARXNG_URL)) {
|
|
1120
|
+
return { provider: 'searxng', explicit: false, source: 'searxng-url' };
|
|
1121
|
+
}
|
|
1122
|
+
return { provider: 'duckduckgo', explicit: false, source: 'zero-key-default' };
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
const KNOWN_WEBSEARCH_PROVIDER_IDS = [
|
|
1126
|
+
...Object.keys(BYO_PROVIDER_KEYS),
|
|
1127
|
+
'searxng',
|
|
1128
|
+
CONSOLE_WEBSEARCH_PROVIDER,
|
|
1129
|
+
];
|
|
1130
|
+
|
|
1131
|
+
/**
|
|
1132
|
+
* Ask the host's integration/connection store which websearch provider(s) the
|
|
1133
|
+
* user connected with `/connect`. Credentials live in that store, so env-var
|
|
1134
|
+
* only detection would miss them and could halt a run whose provider is usable.
|
|
1135
|
+
* Best-effort + shape-tolerant: a host without the API (or an error) yields [].
|
|
1136
|
+
*/
|
|
1137
|
+
export async function readConnectedWebsearchProviders(host, context = {}) {
|
|
1138
|
+
const connection =
|
|
1139
|
+
host?.integration?.connection || context?.integration?.connection;
|
|
1140
|
+
if (!connection || typeof connection.active !== 'function') return [];
|
|
1141
|
+
const found = [];
|
|
1142
|
+
for (const id of KNOWN_WEBSEARCH_PROVIDER_IDS) {
|
|
1143
|
+
try {
|
|
1144
|
+
const active = await connection.active(id);
|
|
1145
|
+
if (active) found.push(id);
|
|
1146
|
+
} catch {
|
|
1147
|
+
/* provider not connected, or the host lacks the store */
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
return found;
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
/** Single-provider convenience for the ladder. */
|
|
1154
|
+
export async function readConnectedWebsearchProvider(host, context = {}) {
|
|
1155
|
+
const found = await readConnectedWebsearchProviders(host, context);
|
|
1156
|
+
return found[0];
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
/** Append one retrieval event to `<cwd>/.research/retrieval.jsonl`. No throw. */
|
|
1160
|
+
export function appendRetrievalEvent(cwd, event) {
|
|
1161
|
+
if (!cwd) return;
|
|
1162
|
+
try {
|
|
1163
|
+
const dir = path.join(cwd, '.research');
|
|
1164
|
+
mkdirSync(dir, { recursive: true });
|
|
1165
|
+
const record = { kind: 'query', ts: new Date().toISOString(), ...(event || {}) };
|
|
1166
|
+
appendFileSync(path.join(dir, RETRIEVAL_LOG_NAME), `${JSON.stringify(record)}\n`, 'utf-8');
|
|
1167
|
+
} catch {
|
|
1168
|
+
/* telemetry must never break a search */
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
/**
|
|
1173
|
+
* Resolve to the promise's value, or `undefined` after `ms` (never rejects).
|
|
1174
|
+
* `onTimeout` runs when the bound elapses (e.g. to abort the upstream).
|
|
1175
|
+
*/
|
|
1176
|
+
function withTimeout(promise, ms, onTimeout) {
|
|
1177
|
+
return new Promise((resolve) => {
|
|
1178
|
+
let done = false;
|
|
1179
|
+
// NOTE: intentionally NOT unref'd — an unref'd timer lets the event loop
|
|
1180
|
+
// drain while a hung upstream settles, so the timeout would never fire.
|
|
1181
|
+
const timer = setTimeout(() => {
|
|
1182
|
+
if (!done) {
|
|
1183
|
+
done = true;
|
|
1184
|
+
try {
|
|
1185
|
+
if (typeof onTimeout === 'function') onTimeout();
|
|
1186
|
+
} catch {
|
|
1187
|
+
/* abort is best-effort */
|
|
1188
|
+
}
|
|
1189
|
+
resolve(undefined);
|
|
1190
|
+
}
|
|
1191
|
+
}, ms);
|
|
1192
|
+
Promise.resolve(promise).then(
|
|
1193
|
+
(value) => {
|
|
1194
|
+
if (!done) {
|
|
1195
|
+
done = true;
|
|
1196
|
+
clearTimeout(timer);
|
|
1197
|
+
resolve(value);
|
|
1198
|
+
}
|
|
1199
|
+
},
|
|
1200
|
+
() => {
|
|
1201
|
+
if (!done) {
|
|
1202
|
+
done = true;
|
|
1203
|
+
clearTimeout(timer);
|
|
1204
|
+
resolve(undefined);
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
);
|
|
1208
|
+
});
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
/** Normalise an upstream hit into OpenCode's WebSearch.Result shape. */
|
|
1212
|
+
function normalizeHit(hit) {
|
|
1213
|
+
return {
|
|
1214
|
+
url: String(hit?.url || hit?.link || ''),
|
|
1215
|
+
title: String(hit?.title || hit?.name || ''),
|
|
1216
|
+
content: String(
|
|
1217
|
+
hit?.content ?? hit?.snippet ?? hit?.description ?? hit?.text ?? ''
|
|
1218
|
+
),
|
|
1219
|
+
time: hit?.time && typeof hit.time === 'object' ? hit.time : {},
|
|
1220
|
+
};
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
function firstArray(...candidates) {
|
|
1224
|
+
for (const c of candidates) if (Array.isArray(c)) return c;
|
|
1225
|
+
return [];
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
async function postJson(fetchImpl, url, body, headers, signal) {
|
|
1229
|
+
const res = await fetchImpl(url, {
|
|
1230
|
+
method: 'POST',
|
|
1231
|
+
headers: { 'content-type': 'application/json', ...(headers || {}) },
|
|
1232
|
+
body: JSON.stringify(body),
|
|
1233
|
+
signal,
|
|
1234
|
+
});
|
|
1235
|
+
if (!res || !res.ok) return null;
|
|
1236
|
+
return res.json();
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
/** One upstream rung. Errors are swallowed into an empty hit list.
|
|
1240
|
+
*
|
|
1241
|
+
* ``ctx.statusRef`` (optional) records why a rung produced nothing so the
|
|
1242
|
+
* caller can log an honest `provider`/`status` event; errors land as
|
|
1243
|
+
* ``error`` rather than masquerading as a legitimate empty result set.
|
|
1244
|
+
*/
|
|
1245
|
+
async function runUpstream(upstream, query, ctx = {}) {
|
|
1246
|
+
const { fetchImpl, hostQuery, runPythonSearch, signal } = ctx;
|
|
1247
|
+
const provider = upstream?.provider;
|
|
1248
|
+
const env = ctx.env || process.env;
|
|
1249
|
+
const fail = (reason) => {
|
|
1250
|
+
if (ctx.statusRef) {
|
|
1251
|
+
ctx.statusRef.status = 'error';
|
|
1252
|
+
ctx.statusRef.error = reason;
|
|
1253
|
+
}
|
|
1254
|
+
return [];
|
|
1255
|
+
};
|
|
1256
|
+
try {
|
|
1257
|
+
if (provider === 'duckduckgo' || provider === 'ddg') {
|
|
1258
|
+
// Reuse our own path: anomaly detection, telemetry, domain filtering.
|
|
1259
|
+
if (typeof runPythonSearch !== 'function') return fail('no-python-search');
|
|
1260
|
+
const raw = await runPythonSearch(query, { signal });
|
|
1261
|
+
return firstArray(raw).map(normalizeHit);
|
|
1262
|
+
}
|
|
1263
|
+
if (provider === 'searxng') {
|
|
1264
|
+
if (typeof fetchImpl !== 'function') return fail('no-fetch');
|
|
1265
|
+
// Strip trailing slashes without a regex: /\/+$/ on uncontrolled input
|
|
1266
|
+
// trips CodeQL js/polynomial-redos; this loop is linear by construction.
|
|
1267
|
+
let base = String(env?.SEARXNG_URL || 'http://localhost:8080');
|
|
1268
|
+
while (base.length > 1 && base.endsWith('/')) base = base.slice(0, -1);
|
|
1269
|
+
const url = `${base}/search?q=${encodeURIComponent(query)}&format=json`;
|
|
1270
|
+
const res = await fetchImpl(url, { signal });
|
|
1271
|
+
if (!res || !res.ok) return fail('searxng-http');
|
|
1272
|
+
const data = await res.json();
|
|
1273
|
+
return firstArray(data?.results).map(normalizeHit);
|
|
1274
|
+
}
|
|
1275
|
+
if (provider === 'exa') {
|
|
1276
|
+
if (typeof fetchImpl !== 'function') return fail('no-fetch');
|
|
1277
|
+
const data = await postJson(
|
|
1278
|
+
fetchImpl,
|
|
1279
|
+
'https://api.exa.ai/search',
|
|
1280
|
+
{ query, numResults: 8 },
|
|
1281
|
+
{ 'x-api-key': String(env?.EXA_API_KEY || '') },
|
|
1282
|
+
signal
|
|
1283
|
+
);
|
|
1284
|
+
return firstArray(data?.results).map(normalizeHit);
|
|
1285
|
+
}
|
|
1286
|
+
if (provider === 'tavily') {
|
|
1287
|
+
if (typeof fetchImpl !== 'function') return fail('no-fetch');
|
|
1288
|
+
const data = await postJson(
|
|
1289
|
+
fetchImpl,
|
|
1290
|
+
'https://api.tavily.com/search',
|
|
1291
|
+
{ api_key: String(env?.TAVILY_API_KEY || ''), query, max_results: 8 },
|
|
1292
|
+
{},
|
|
1293
|
+
signal
|
|
1294
|
+
);
|
|
1295
|
+
return firstArray(data?.results).map(normalizeHit);
|
|
1296
|
+
}
|
|
1297
|
+
if (provider === 'firecrawl') {
|
|
1298
|
+
if (typeof fetchImpl !== 'function') return fail('no-fetch');
|
|
1299
|
+
const data = await postJson(
|
|
1300
|
+
fetchImpl,
|
|
1301
|
+
'https://api.firecrawl.dev/v1/search',
|
|
1302
|
+
{ query, limit: 8 },
|
|
1303
|
+
{ authorization: `Bearer ${String(env?.FIRECRAWL_API_KEY || '')}` },
|
|
1304
|
+
signal
|
|
1305
|
+
);
|
|
1306
|
+
return firstArray(data?.data).map(normalizeHit);
|
|
1307
|
+
}
|
|
1308
|
+
if (provider === 'parallel') {
|
|
1309
|
+
if (typeof fetchImpl !== 'function') return fail('no-fetch');
|
|
1310
|
+
const data = await postJson(
|
|
1311
|
+
fetchImpl,
|
|
1312
|
+
'https://api.parallel.ai/v1beta/search',
|
|
1313
|
+
{ search_queries: [query], max_results: 8 },
|
|
1314
|
+
{ 'x-api-key': String(env?.PARALLEL_API_KEY || '') },
|
|
1315
|
+
signal
|
|
1316
|
+
);
|
|
1317
|
+
return firstArray(data?.results).map((r) =>
|
|
1318
|
+
normalizeHit({
|
|
1319
|
+
url: r?.url,
|
|
1320
|
+
title: r?.title,
|
|
1321
|
+
content: firstArray(r?.excerpts).join(' ') || r?.snippet,
|
|
1322
|
+
})
|
|
1323
|
+
);
|
|
1324
|
+
}
|
|
1325
|
+
if (provider === 'tinyfish') {
|
|
1326
|
+
if (typeof fetchImpl !== 'function') return fail('no-fetch');
|
|
1327
|
+
const data = await postJson(
|
|
1328
|
+
fetchImpl,
|
|
1329
|
+
'https://api.tinyfish.ai/v1/search',
|
|
1330
|
+
{ query, max_results: 8 },
|
|
1331
|
+
{ 'x-api-key': String(env?.TINYFISH_API_KEY || '') },
|
|
1332
|
+
signal
|
|
1333
|
+
);
|
|
1334
|
+
return firstArray(data?.results, data?.data).map(normalizeHit);
|
|
1335
|
+
}
|
|
1336
|
+
if (provider === CONSOLE_WEBSEARCH_PROVIDER) {
|
|
1337
|
+
// Console is the host's own metered provider; delegate rather than
|
|
1338
|
+
// re-implement it, and only when explicitly selected.
|
|
1339
|
+
if (typeof hostQuery !== 'function') return fail('no-host-query');
|
|
1340
|
+
const out = await hostQuery({ query, providerID: CONSOLE_WEBSEARCH_PROVIDER });
|
|
1341
|
+
const results = firstArray(out?.results, out?.data, out);
|
|
1342
|
+
return results.map(normalizeHit);
|
|
1343
|
+
}
|
|
1344
|
+
return fail('unknown-provider');
|
|
1345
|
+
} catch (err) {
|
|
1346
|
+
return fail(String(err?.message || 'upstream-error'));
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
/** Spawn our Python DDG path so its telemetry + anomaly detection apply. */
|
|
1351
|
+
function spawnPythonWebsearch(query, { cwd, signal } = {}) {
|
|
1352
|
+
return new Promise((resolve) => {
|
|
1353
|
+
let stdout = '';
|
|
1354
|
+
let child;
|
|
1355
|
+
try {
|
|
1356
|
+
child = spawn(
|
|
1357
|
+
resolveBinary('python3'),
|
|
1358
|
+
[
|
|
1359
|
+
path.join(PKG_ROOT, 'skills', 'epistemic_search', 'scripts', 'search.py'),
|
|
1360
|
+
'--json',
|
|
1361
|
+
String(query),
|
|
1362
|
+
],
|
|
1363
|
+
{
|
|
1364
|
+
cwd: cwd || process.cwd(),
|
|
1365
|
+
env: {
|
|
1366
|
+
...process.env,
|
|
1367
|
+
PYTHONPATH: PKG_ROOT,
|
|
1368
|
+
IUMBTEMS_PROJECT_DIR: cwd || process.cwd(),
|
|
1369
|
+
IUMBTEMS_RESEARCH_DIR: path.join(cwd || process.cwd(), '.research'),
|
|
1370
|
+
},
|
|
1371
|
+
}
|
|
1372
|
+
);
|
|
1373
|
+
} catch {
|
|
1374
|
+
resolve([]);
|
|
1375
|
+
return;
|
|
1376
|
+
}
|
|
1377
|
+
child.stdout?.on('data', (d) => { stdout += String(d); });
|
|
1378
|
+
child.on('error', () => resolve([]));
|
|
1379
|
+
if (signal && typeof signal.addEventListener === 'function') {
|
|
1380
|
+
signal.addEventListener('abort', () => { try { child.kill(); } catch { /* noop */ } }, { once: true });
|
|
1381
|
+
}
|
|
1382
|
+
child.on('close', () => {
|
|
1383
|
+
try {
|
|
1384
|
+
const parsed = JSON.parse(stdout);
|
|
1385
|
+
resolve(Array.isArray(parsed) ? parsed.map(normalizeHit) : []);
|
|
1386
|
+
} catch {
|
|
1387
|
+
resolve([]);
|
|
1388
|
+
}
|
|
1389
|
+
});
|
|
1390
|
+
});
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
/** Build the provider definition. Everything network-touching is injectable. */
|
|
1394
|
+
export function websearchProviderDefinition(options = {}) {
|
|
1395
|
+
const {
|
|
1396
|
+
env = process.env,
|
|
1397
|
+
explicit = undefined,
|
|
1398
|
+
connected = [],
|
|
1399
|
+
cwd = undefined,
|
|
1400
|
+
fetchImpl = typeof fetch === 'function' ? fetch : undefined,
|
|
1401
|
+
hostQuery = undefined,
|
|
1402
|
+
runPythonSearch = undefined,
|
|
1403
|
+
logEvent = appendRetrievalEvent,
|
|
1404
|
+
timeoutMs = DEFAULT_EXECUTE_TIMEOUT_MS,
|
|
1405
|
+
} = options;
|
|
1406
|
+
return {
|
|
1407
|
+
id: WEBSEARCH_PROVIDER_ID,
|
|
1408
|
+
name: WEBSEARCH_PROVIDER_NAME,
|
|
1409
|
+
execute: async ({ query } = {}, opts = {}) => {
|
|
1410
|
+
const signal = opts?.signal;
|
|
1411
|
+
// A pre-aborted request must not touch the network at all.
|
|
1412
|
+
if (signal && signal.aborted) return [];
|
|
1413
|
+
const text = String(query || '');
|
|
1414
|
+
if (!text) return [];
|
|
1415
|
+
const upstream = resolveWebsearchUpstream(env, explicit, connected);
|
|
1416
|
+
const statusRef = { status: 'ok' };
|
|
1417
|
+
// Link the host signal to an internal controller so the timeout can abort
|
|
1418
|
+
// a hung upstream / child process, not merely stop waiting on it.
|
|
1419
|
+
const controller = typeof AbortController === 'function' ? new AbortController() : null;
|
|
1420
|
+
if (controller && signal && typeof signal.addEventListener === 'function') {
|
|
1421
|
+
signal.addEventListener('abort', () => controller.abort(), { once: true });
|
|
1422
|
+
}
|
|
1423
|
+
// Bounded: a never-settling upstream resolves to [] rather than hanging
|
|
1424
|
+
// the host forever.
|
|
1425
|
+
const timed = await withTimeout(
|
|
1426
|
+
runUpstream(upstream, text, {
|
|
1427
|
+
env,
|
|
1428
|
+
fetchImpl,
|
|
1429
|
+
hostQuery,
|
|
1430
|
+
runPythonSearch,
|
|
1431
|
+
signal: controller ? controller.signal : signal,
|
|
1432
|
+
statusRef,
|
|
1433
|
+
}),
|
|
1434
|
+
timeoutMs,
|
|
1435
|
+
() => controller?.abort()
|
|
1436
|
+
);
|
|
1437
|
+
const hits = Array.isArray(timed) ? timed : [];
|
|
1438
|
+
if (timed === undefined) statusRef.status = 'timeout';
|
|
1439
|
+
else if (statusRef.status === 'ok') statusRef.status = hits.length ? 'ok' : 'empty';
|
|
1440
|
+
// Every rung on OUR path is recorded with its provider + status, so a
|
|
1441
|
+
// metered-only run cannot read as `0 searches (~$0.00 est)`. The DDG rung
|
|
1442
|
+
// is exempt: it routes through `search.py`, which already logs a richer
|
|
1443
|
+
// event (blocked/empty/error), and logging here too would double-count.
|
|
1444
|
+
if (upstream.provider !== 'duckduckgo' && upstream.provider !== 'ddg') {
|
|
1445
|
+
try {
|
|
1446
|
+
logEvent(cwd, {
|
|
1447
|
+
query: text,
|
|
1448
|
+
results: hits.length,
|
|
1449
|
+
status: statusRef.status,
|
|
1450
|
+
provider: upstream.provider,
|
|
1451
|
+
...(statusRef.error ? { error: statusRef.error } : {}),
|
|
1452
|
+
});
|
|
1453
|
+
} catch {
|
|
1454
|
+
/* telemetry must never break a search */
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
return hits;
|
|
1458
|
+
},
|
|
1459
|
+
};
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
/** Read the user's explicit selection from the host config shapes, if any. */
|
|
1463
|
+
function readExplicitWebsearchProvider(host, context) {
|
|
1464
|
+
const candidates = [
|
|
1465
|
+
host?.config?.websearch?.provider,
|
|
1466
|
+
host?.websearch?.provider,
|
|
1467
|
+
context?.config?.websearch?.provider,
|
|
1468
|
+
context?.options?.websearch?.provider,
|
|
1469
|
+
host?.options?.websearch?.provider,
|
|
1470
|
+
];
|
|
1471
|
+
for (const c of candidates) {
|
|
1472
|
+
if (typeof c === 'string' && c.trim()) return c.trim();
|
|
1473
|
+
}
|
|
1474
|
+
return undefined;
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
/**
|
|
1478
|
+
* Read the editor's current default. Distinguishes "no default configured"
|
|
1479
|
+
* (safe to set ours) from "cannot tell" (never override — do not set).
|
|
1480
|
+
*/
|
|
1481
|
+
function readWebsearchDefault(editor) {
|
|
1482
|
+
try {
|
|
1483
|
+
const d = editor?.default;
|
|
1484
|
+
if (d && typeof d.get === 'function') return { known: true, value: d.get() };
|
|
1485
|
+
} catch {
|
|
1486
|
+
/* fall through to unknown */
|
|
1487
|
+
}
|
|
1488
|
+
return { known: false, value: undefined };
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
/**
|
|
1492
|
+
* Register the cached provider via the V2 `websearch.transform` domain.
|
|
1493
|
+
*
|
|
1494
|
+
* Shape-tolerant: a host build without `websearch` yields `[]` and the plugin
|
|
1495
|
+
* carries on. The transform callback is wrapped so a registration fault can
|
|
1496
|
+
* never disable the whole plugin the way the AgentEditor `add` bug did.
|
|
1497
|
+
*/
|
|
1498
|
+
export async function registerHostWebsearch(host, context = {}) {
|
|
1499
|
+
if (typeof host?.websearch?.transform !== 'function') return [];
|
|
1500
|
+
const cwd = host?.location?.directory || context?.location?.directory || undefined;
|
|
1501
|
+
const explicit = readExplicitWebsearchProvider(host, context);
|
|
1502
|
+
// Consult the host's integration store: a `/connect`ed provider is usable
|
|
1503
|
+
// even without a `websearch.provider` or an env key. Best-effort.
|
|
1504
|
+
let connected = [];
|
|
1505
|
+
try {
|
|
1506
|
+
connected = await readConnectedWebsearchProviders(host, context);
|
|
1507
|
+
} catch (err) {
|
|
1508
|
+
log(host, 'warn', 'integration store probe failed', errDetail(err));
|
|
1509
|
+
}
|
|
1510
|
+
// Record the choice for the runner's preflight (the integration store is only
|
|
1511
|
+
// reachable from inside the host process). Two best-effort channels:
|
|
1512
|
+
// 1. `<cwd>/.research/websearch-state.json` for a run pointed at this tree,
|
|
1513
|
+
// 2. the spawn env, since the plugin starts the MCP runner with
|
|
1514
|
+
// `...process.env` — so the banner names the active provider and never
|
|
1515
|
+
// halts a run whose `/connect`ed provider is usable.
|
|
1516
|
+
persistConnectedState(cwd, connected);
|
|
1517
|
+
try {
|
|
1518
|
+
if (explicit) process.env.IUMBTEMS_WEBSEARCH_PROVIDER = explicit;
|
|
1519
|
+
else delete process.env.IUMBTEMS_WEBSEARCH_PROVIDER;
|
|
1520
|
+
if (connected.length) {
|
|
1521
|
+
process.env.IUMBTEMS_CONNECTED_WEBSEARCH_PROVIDER = connected[0];
|
|
1522
|
+
} else {
|
|
1523
|
+
delete process.env.IUMBTEMS_CONNECTED_WEBSEARCH_PROVIDER;
|
|
1524
|
+
}
|
|
1525
|
+
} catch {
|
|
1526
|
+
/* some sandboxes expose a read-only process.env */
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
const definition = websearchProviderDefinition({
|
|
1530
|
+
env: process.env,
|
|
1531
|
+
explicit,
|
|
1532
|
+
connected,
|
|
1533
|
+
cwd,
|
|
1534
|
+
hostQuery:
|
|
1535
|
+
typeof host?.websearch?.query === 'function'
|
|
1536
|
+
? (input) => host.websearch.query(input)
|
|
1537
|
+
: undefined,
|
|
1538
|
+
runPythonSearch: (query, opts) => spawnPythonWebsearch(query, { ...(opts || {}), cwd }),
|
|
1539
|
+
});
|
|
1540
|
+
|
|
1541
|
+
const registration = await host.websearch.transform((editor) => {
|
|
1542
|
+
try {
|
|
1543
|
+
editor.add(definition);
|
|
1544
|
+
const current = readWebsearchDefault(editor);
|
|
1545
|
+
// ONLY choose our default when we can prove the user made no explicit
|
|
1546
|
+
// `websearch.provider` choice (and did not `/connect` one) AND the editor
|
|
1547
|
+
// reports no default at all.
|
|
1548
|
+
if (
|
|
1549
|
+
!explicit &&
|
|
1550
|
+
!connected.length &&
|
|
1551
|
+
current.known &&
|
|
1552
|
+
current.value === undefined &&
|
|
1553
|
+
typeof editor?.default?.set === 'function'
|
|
1554
|
+
) {
|
|
1555
|
+
editor.default.set(WEBSEARCH_PROVIDER_ID);
|
|
1556
|
+
}
|
|
1557
|
+
} catch (err) {
|
|
1558
|
+
log(host, 'warn', 'websearch provider registration failed', errDetail(err));
|
|
1559
|
+
}
|
|
1560
|
+
});
|
|
1561
|
+
log(host, 'info', 'cached search provider registered', {
|
|
1562
|
+
id: WEBSEARCH_PROVIDER_ID,
|
|
1563
|
+
explicitProvider: explicit || null,
|
|
1564
|
+
connectedProviders: connected,
|
|
1565
|
+
});
|
|
1566
|
+
return registration ? [registration] : [];
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
/** Write the host-reported connections where the runner's preflight can read them. */
|
|
1570
|
+
function persistConnectedState(cwd, connected) {
|
|
1571
|
+
if (!cwd) return;
|
|
1572
|
+
const dir = path.join(cwd, '.research');
|
|
1573
|
+
const file = path.join(dir, CONNECTED_STATE_NAME);
|
|
1574
|
+
const list = Array.isArray(connected) ? connected.filter(Boolean) : [];
|
|
1575
|
+
try {
|
|
1576
|
+
if (list.length === 0) {
|
|
1577
|
+
// The host reports NO `/connect`ed provider: remove any stale record so a
|
|
1578
|
+
// previous connection (or a forged file) cannot keep advertising a
|
|
1579
|
+
// provider that is gone. The runner treats the file as advisory, but it
|
|
1580
|
+
// must not outlive the connection either.
|
|
1581
|
+
rmSync(file, { force: true });
|
|
1582
|
+
return;
|
|
1583
|
+
}
|
|
1584
|
+
mkdirSync(dir, { recursive: true });
|
|
1585
|
+
writeFileSync(
|
|
1586
|
+
file,
|
|
1587
|
+
`${JSON.stringify(
|
|
1588
|
+
{
|
|
1589
|
+
connected_provider: list[0],
|
|
1590
|
+
connected_providers: list,
|
|
1591
|
+
updated: new Date().toISOString(),
|
|
1592
|
+
},
|
|
1593
|
+
null,
|
|
1594
|
+
2
|
|
1595
|
+
)}\n`,
|
|
1596
|
+
'utf-8'
|
|
1597
|
+
);
|
|
1598
|
+
} catch {
|
|
1599
|
+
/* best-effort channel; never break registration */
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1011
1603
|
/**
|
|
1012
1604
|
* V2 compaction hook: keep .research state across context compaction.
|
|
1013
1605
|
*
|
|
@@ -1384,6 +1976,11 @@ export function createOpenCodePlugin(context = {}) {
|
|
|
1384
1976
|
} catch (err) {
|
|
1385
1977
|
log(host, 'error', 'registering tools failed', errDetail(err));
|
|
1386
1978
|
}
|
|
1979
|
+
try {
|
|
1980
|
+
adopt(await registerHostWebsearch(host, context));
|
|
1981
|
+
} catch (err) {
|
|
1982
|
+
log(host, 'error', 'registering cached search provider failed', errDetail(err));
|
|
1983
|
+
}
|
|
1387
1984
|
try {
|
|
1388
1985
|
adopt(await registerCompactionHook(host, context));
|
|
1389
1986
|
} catch (err) {
|