@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.
Files changed (80) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +20 -0
  3. package/install.sh +233 -14
  4. package/package.json +1 -1
  5. package/plugins/opencode/index.js +598 -1
  6. package/plugins/socratic-grilling/skills/grilling/socratic_tree.py +159 -24
  7. package/runner/__pycache__/__init__.cpython-311.pyc +0 -0
  8. package/runner/__pycache__/auctioneer.cpython-311.pyc +0 -0
  9. package/runner/__pycache__/auditor_engine.cpython-311.pyc +0 -0
  10. package/runner/__pycache__/claim_store.cpython-311.pyc +0 -0
  11. package/runner/__pycache__/claim_witness.cpython-311.pyc +0 -0
  12. package/runner/__pycache__/darkharvest_claims.cpython-311.pyc +0 -0
  13. package/runner/__pycache__/errors.cpython-311.pyc +0 -0
  14. package/runner/__pycache__/living_dossiers.cpython-311.pyc +0 -0
  15. package/runner/__pycache__/mcp_protocol.cpython-311.pyc +0 -0
  16. package/runner/__pycache__/mcp_server.cpython-311.pyc +0 -0
  17. package/runner/__pycache__/path_safety.cpython-311.pyc +0 -0
  18. package/runner/__pycache__/pcrb.cpython-311.pyc +0 -0
  19. package/runner/__pycache__/pcrb_verify.cpython-311.pyc +0 -0
  20. package/runner/__pycache__/preflight.cpython-311.pyc +0 -0
  21. package/runner/__pycache__/refinement.cpython-311.pyc +0 -0
  22. package/runner/__pycache__/research_swarm.cpython-311.pyc +0 -0
  23. package/runner/__pycache__/retrieval_log.cpython-311.pyc +0 -0
  24. package/runner/__pycache__/schema_validate.cpython-311.pyc +0 -0
  25. package/runner/__pycache__/schemas.cpython-311.pyc +0 -0
  26. package/runner/__pycache__/state_machine.cpython-311.pyc +0 -0
  27. package/runner/mcp_server.py +79 -28
  28. package/runner/preflight.py +829 -12
  29. package/runner/research_swarm.py +68 -5
  30. package/runner/tests/__pycache__/test_auction_order.cpython-311.pyc +0 -0
  31. package/runner/tests/__pycache__/test_backends.cpython-311.pyc +0 -0
  32. package/runner/tests/__pycache__/test_bet1_spike.cpython-311.pyc +0 -0
  33. package/runner/tests/__pycache__/test_claim_store.cpython-311.pyc +0 -0
  34. package/runner/tests/__pycache__/test_claim_witness.cpython-311.pyc +0 -0
  35. package/runner/tests/__pycache__/test_claude_plugin.cpython-311.pyc +0 -0
  36. package/runner/tests/__pycache__/test_contract_tooling.cpython-311.pyc +0 -0
  37. package/runner/tests/__pycache__/test_darkharvest_audit.cpython-311.pyc +0 -0
  38. package/runner/tests/__pycache__/test_domain_packs.cpython-311.pyc +0 -0
  39. package/runner/tests/__pycache__/test_dossier_contract.cpython-311.pyc +0 -0
  40. package/runner/tests/__pycache__/test_factory.cpython-311.pyc +0 -0
  41. package/runner/tests/__pycache__/test_fleet_seam.cpython-311.pyc +0 -0
  42. package/runner/tests/__pycache__/test_frontier_concurrency.cpython-311.pyc +0 -0
  43. package/runner/tests/__pycache__/test_frontier_mirror_standalone.cpython-311.pyc +0 -0
  44. package/runner/tests/__pycache__/test_grounding.cpython-311.pyc +0 -0
  45. package/runner/tests/__pycache__/test_living_dossiers.cpython-311.pyc +0 -0
  46. package/runner/tests/__pycache__/test_manifest_concurrency.cpython-311.pyc +0 -0
  47. package/runner/tests/__pycache__/test_mcp_server.cpython-311.pyc +0 -0
  48. package/runner/tests/__pycache__/test_opencode_ux.cpython-311.pyc +0 -0
  49. package/runner/tests/__pycache__/test_opencode_v2_registrars.cpython-311.pyc +0 -0
  50. package/runner/tests/__pycache__/test_pcrb.cpython-311.pyc +0 -0
  51. package/runner/tests/__pycache__/test_refinement.cpython-311.pyc +0 -0
  52. package/runner/tests/__pycache__/test_search_anomaly.cpython-311.pyc +0 -0
  53. package/runner/tests/__pycache__/test_search_provider.cpython-311.pyc +0 -0
  54. package/runner/tests/__pycache__/test_search_provider_plugin.cpython-311.pyc +0 -0
  55. package/runner/tests/__pycache__/test_swarm.cpython-311.pyc +0 -0
  56. package/runner/tests/__pycache__/test_swarm_config.cpython-311.pyc +0 -0
  57. package/runner/tests/__pycache__/test_sweep_regressions.cpython-311.pyc +0 -0
  58. package/runner/tests/__pycache__/test_webcache.cpython-311.pyc +0 -0
  59. package/runner/tests/fixtures/ddg_anomaly_page.html +176 -0
  60. package/runner/tests/fixtures/ddg_results_page.html +28 -0
  61. package/runner/tests/test_frontier_concurrency.py +288 -0
  62. package/runner/tests/test_frontier_mirror_standalone.py +156 -0
  63. package/runner/tests/test_search_anomaly.py +277 -0
  64. package/runner/tests/test_search_provider.py +1464 -0
  65. package/runner/tests/test_search_provider_plugin.py +401 -0
  66. package/scripts/__pycache__/bet1_advisory_spike.cpython-311.pyc +0 -0
  67. package/scripts/__pycache__/build_adapters.cpython-311.pyc +0 -0
  68. package/scripts/__pycache__/check_docs.cpython-311.pyc +0 -0
  69. package/scripts/__pycache__/divergence_experiment.cpython-311.pyc +0 -0
  70. package/scripts/__pycache__/gen_schemas.cpython-311.pyc +0 -0
  71. package/skills/epistemic_search/SKILL.md +32 -0
  72. package/skills/epistemic_search/scripts/__pycache__/search.cpython-311.pyc +0 -0
  73. package/skills/epistemic_search/scripts/search.py +232 -19
  74. package/skills/grilling/__pycache__/__init__.cpython-311.pyc +0 -0
  75. package/skills/grilling/__pycache__/socratic_tree.cpython-311.pyc +0 -0
  76. package/skills/grilling/socratic_tree.py +159 -24
  77. package/skills/research_cache/__pycache__/__init__.cpython-311.pyc +0 -0
  78. package/skills/research_cache/__pycache__/hasher.cpython-311.pyc +0 -0
  79. package/skills/swarm_config/__pycache__/__init__.cpython-311.pyc +0 -0
  80. 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 { accessSync, constants, readFileSync, realpathSync, statSync } from 'node:fs';
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) {