rterm-backend 3.0.8 → 3.0.9
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/bin/gybackend.cjs +45 -5
- package/package.json +2 -2
- package/plugins/web-intel/index.mjs +381 -0
- package/plugins/web-intel/plugin.json +28 -0
- package/plugins/web-intel/sidecar.mjs +134 -0
- package/plugins/web-intel/wigoloClient.mjs +145 -0
package/bin/gybackend.cjs
CHANGED
|
@@ -369400,6 +369400,12 @@ var DEFAULT_BACKEND_SETTINGS = {
|
|
|
369400
369400
|
serverUrl: "",
|
|
369401
369401
|
enabled: true
|
|
369402
369402
|
},
|
|
369403
|
+
webIntel: {
|
|
369404
|
+
restUrl: "",
|
|
369405
|
+
enabled: true,
|
|
369406
|
+
autoStart: true,
|
|
369407
|
+
warmupOnInit: false
|
|
369408
|
+
},
|
|
369403
369409
|
gateway: {
|
|
369404
369410
|
ws: {
|
|
369405
369411
|
access: "localhost",
|
|
@@ -369456,6 +369462,7 @@ function pickBackendSnapshot(raw) {
|
|
|
369456
369462
|
oncall: raw.oncall,
|
|
369457
369463
|
cloud: raw.cloud,
|
|
369458
369464
|
agentspan: raw.agentspan,
|
|
369465
|
+
webIntel: raw.webIntel,
|
|
369459
369466
|
gateway: raw.gateway,
|
|
369460
369467
|
layout: raw.layout,
|
|
369461
369468
|
recursionLimit: raw.recursionLimit,
|
|
@@ -369535,6 +369542,7 @@ function normalizeBackendSettings(settings) {
|
|
|
369535
369542
|
next.oncall = normalizeOncallSettings(next.oncall);
|
|
369536
369543
|
next.cloud = normalizeCloudSettings(next.cloud);
|
|
369537
369544
|
next.agentspan = normalizeAgentspanSettings(next.agentspan);
|
|
369545
|
+
next.webIntel = normalizeWebIntelSettings(next.webIntel);
|
|
369538
369546
|
next.schemaVersion = BACKEND_SETTINGS_SCHEMA_VERSION;
|
|
369539
369547
|
return next;
|
|
369540
369548
|
}
|
|
@@ -369707,6 +369715,18 @@ function normalizeAgentspanSettings(raw) {
|
|
|
369707
369715
|
enabled: src.enabled !== false
|
|
369708
369716
|
};
|
|
369709
369717
|
}
|
|
369718
|
+
function normalizeWebIntelSettings(raw) {
|
|
369719
|
+
const src = isObject5(raw) ? raw : {};
|
|
369720
|
+
const restUrl = typeof src.restUrl === "string" ? src.restUrl.trim() : "";
|
|
369721
|
+
const token = typeof src.token === "string" && src.token.trim() ? src.token.trim() : void 0;
|
|
369722
|
+
return {
|
|
369723
|
+
...restUrl ? { restUrl } : {},
|
|
369724
|
+
...token ? { token } : {},
|
|
369725
|
+
enabled: src.enabled !== false,
|
|
369726
|
+
autoStart: src.autoStart !== false,
|
|
369727
|
+
warmupOnInit: src.warmupOnInit === true
|
|
369728
|
+
};
|
|
369729
|
+
}
|
|
369710
369730
|
function migrateBackendToV3(settings) {
|
|
369711
369731
|
const next = { ...settings };
|
|
369712
369732
|
delete next.language;
|
|
@@ -383902,7 +383922,7 @@ var PluginRegistry = class {
|
|
|
383902
383922
|
}
|
|
383903
383923
|
/** Build the default PluginContext for a record (registers into the record's
|
|
383904
383924
|
* capability lists; exec/readLedger/log are delegated to the injected fns). */
|
|
383905
|
-
static defaultContext(record2, exec, readLedger, log) {
|
|
383925
|
+
static defaultContext(record2, exec, readLedger, log, spawnProcess, getSettings) {
|
|
383906
383926
|
return {
|
|
383907
383927
|
registerTool: (tool2) => {
|
|
383908
383928
|
record2.tools.push(tool2);
|
|
@@ -383910,12 +383930,20 @@ var PluginRegistry = class {
|
|
|
383910
383930
|
registerTrigger: (trigger) => {
|
|
383911
383931
|
record2.triggers.push(trigger);
|
|
383912
383932
|
},
|
|
383913
|
-
registerPanel: (
|
|
383914
|
-
|
|
383933
|
+
registerPanel: (nameOrDef, render3) => {
|
|
383934
|
+
if (typeof nameOrDef === "string") {
|
|
383935
|
+
if (typeof render3 === "function") record2.panels.push({ name: nameOrDef, render: render3 });
|
|
383936
|
+
} else if (nameOrDef && typeof nameOrDef === "object" && typeof nameOrDef.render === "function") {
|
|
383937
|
+
record2.panels.push({ name: nameOrDef.name, render: nameOrDef.render });
|
|
383938
|
+
}
|
|
383915
383939
|
},
|
|
383916
383940
|
exec,
|
|
383917
383941
|
readLedger,
|
|
383918
|
-
log
|
|
383942
|
+
log,
|
|
383943
|
+
...spawnProcess ? { spawnProcess } : {},
|
|
383944
|
+
...getSettings ? { getSettings, get settings() {
|
|
383945
|
+
return getSettings();
|
|
383946
|
+
} } : {}
|
|
383919
383947
|
};
|
|
383920
383948
|
}
|
|
383921
383949
|
};
|
|
@@ -386407,7 +386435,19 @@ function createObservability(deps) {
|
|
|
386407
386435
|
deps.onLog?.(line);
|
|
386408
386436
|
} catch {
|
|
386409
386437
|
}
|
|
386410
|
-
}
|
|
386438
|
+
},
|
|
386439
|
+
// Real child_process spawn for sidecar daemons (web-intel's wigolo serve).
|
|
386440
|
+
(command, args, opts) => {
|
|
386441
|
+
const req = (0, import_node_module5.createRequire)(typeof __filename !== "undefined" ? __filename : import_meta7.url);
|
|
386442
|
+
const cp = req("node:child_process");
|
|
386443
|
+
return cp.spawn(command, args, {
|
|
386444
|
+
env: opts?.env,
|
|
386445
|
+
detached: opts?.detached ?? false,
|
|
386446
|
+
stdio: opts?.stdio ?? "ignore"
|
|
386447
|
+
});
|
|
386448
|
+
},
|
|
386449
|
+
// Live settings snapshot for plugins that read config blocks (webIntel, agentspan, …).
|
|
386450
|
+
() => deps.settingsService?.getSettings?.() ?? {}
|
|
386411
386451
|
),
|
|
386412
386452
|
onLog: deps.onLog
|
|
386413
386453
|
});
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rterm-backend",
|
|
3
|
-
"version": "3.0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "3.0.9",
|
|
4
|
+
"description": "Headless AI-native backend for RTerm / neuralOS — v3.0.9: web-intel plugin (local-first web intelligence via wigolo; lean-by-default, synthesis by RTerm agent).",
|
|
5
5
|
"main": "bin/gybackend.cjs",
|
|
6
6
|
"bin": {
|
|
7
7
|
"gybackend": "bin/gybackend.cjs",
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* web-intel plugin — local-first web intelligence for RTerm's agent via wigolo.
|
|
3
|
+
*
|
|
4
|
+
* Gives the agent first-class web tools it doesn't have: multi-engine search,
|
|
5
|
+
* clean-page fetch, site crawl, structured extract, similar-pages, cache,
|
|
6
|
+
* research, and page-watch → RTerm trigger automation. All local-first, keyless
|
|
7
|
+
* (search/fetch/crawl), and $0/query — the daemon runs on the same box.
|
|
8
|
+
*
|
|
9
|
+
* Synthesis uses RTerm's OWN agent, not a wigolo LLM — so there's NO LLM key to
|
|
10
|
+
* manage. `web_research` asks wigolo for the decomposed evidence + citations and
|
|
11
|
+
* RTerm's agent writes the cited answer from that brief.
|
|
12
|
+
*
|
|
13
|
+
* Lean by default: the daemon starts lazily with WIGOLO_NO_WARMUP=1 so the
|
|
14
|
+
* ~1.5 GB browser engine + on-device models are NOT downloaded at init (search/
|
|
15
|
+
* fetch/crawl work keyless without them). Set `warmupOnInit: true` to pre-fetch
|
|
16
|
+
* them in the background.
|
|
17
|
+
*
|
|
18
|
+
* Config (Settings → webIntel block, resolved from ctx.settings / env):
|
|
19
|
+
* enabled — master switch (default true)
|
|
20
|
+
* restUrl — wigolo daemon base URL (default http://127.0.0.1:3333)
|
|
21
|
+
* token — bearer token (optional; only if the daemon uses WIGOLO_API_TOKEN)
|
|
22
|
+
* autoStart — start the daemon on first use (default true)
|
|
23
|
+
* warmupOnInit — download the full browser engine + models in the background (default false = lean)
|
|
24
|
+
*
|
|
25
|
+
* The plugin never crashes RTerm when the daemon is down — every tool returns a
|
|
26
|
+
* clear {error, hint} result instead of throwing (the agentspan-bridge pattern).
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { WigoloClient, DEFAULT_BASE_URL } from './wigoloClient.mjs'
|
|
30
|
+
import { WigoloSidecar } from './sidecar.mjs'
|
|
31
|
+
|
|
32
|
+
// ─── config resolution ──────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
/** Read the webIntel config block from RTerm settings (ctx.settings) or env. */
|
|
35
|
+
export function resolveConfig(ctx = {}, env = process.env) {
|
|
36
|
+
const s = (typeof ctx.getSettings === 'function' ? ctx.getSettings() : ctx.settings) || {}
|
|
37
|
+
const block = s.webIntel || {}
|
|
38
|
+
return {
|
|
39
|
+
enabled: block.enabled !== false,
|
|
40
|
+
restUrl: String(block.restUrl || env.WIGOLO_REST_URL || DEFAULT_BASE_URL).trim().replace(/\/+$/, ''),
|
|
41
|
+
token: block.token || env.WIGOLO_API_TOKEN || undefined,
|
|
42
|
+
autoStart: block.autoStart !== false,
|
|
43
|
+
warmupOnInit: block.warmupOnInit === true,
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Real fetch adapter (matches wigoloClient's expected {ok,status,text}). */
|
|
48
|
+
async function realFetch(url, init) {
|
|
49
|
+
const res = await fetch(url, { method: init.method, headers: init.headers, body: init.body })
|
|
50
|
+
return { ok: res.ok, status: res.status, text: () => res.text() }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Build a configured WigoloClient from ctx (settings + vault). */
|
|
54
|
+
export function buildClient(ctx = {}, fetchImpl) {
|
|
55
|
+
const cfg = resolveConfig(ctx)
|
|
56
|
+
const impl = typeof fetchImpl === 'function' ? fetchImpl : realFetch
|
|
57
|
+
return { client: new WigoloClient({ baseUrl: cfg.restUrl, token: cfg.token, fetchImpl: impl }), config: cfg }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ─── formatting helpers (pure) ─────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
/** Normalize a search result set into compact rows for the agent + panel. */
|
|
63
|
+
export function toResultRows(payload) {
|
|
64
|
+
const results = payload?.results ?? (Array.isArray(payload) ? payload : [])
|
|
65
|
+
if (!Array.isArray(results)) return []
|
|
66
|
+
return results.map((r) => ({
|
|
67
|
+
title: r.title ?? r.url,
|
|
68
|
+
url: r.url,
|
|
69
|
+
excerpt: (r.excerpt ?? r.description ?? '').slice(0, 240),
|
|
70
|
+
citation: r.citation_id ?? r.id,
|
|
71
|
+
score: r.evidence_score?.final ?? r.score,
|
|
72
|
+
freshness: payload?.freshness_signal?.published ?? r.published,
|
|
73
|
+
}))
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Normalize a fetch/crawl result into a compact summary. */
|
|
77
|
+
export function toPageSummary(payload) {
|
|
78
|
+
if (!payload || typeof payload !== 'object') return {}
|
|
79
|
+
const links = Array.isArray(payload.links) ? payload.links : []
|
|
80
|
+
return {
|
|
81
|
+
url: payload.url ?? payload.final_url,
|
|
82
|
+
title: payload.title,
|
|
83
|
+
markdown: typeof payload.markdown === 'string' ? payload.markdown.slice(0, 4000) : undefined,
|
|
84
|
+
linkCount: links.length,
|
|
85
|
+
links: links.slice(0, 20),
|
|
86
|
+
blocked: payload.blocked_by_challenge === true || payload.status === 'blocked',
|
|
87
|
+
sections: Array.isArray(payload.sections) ? payload.sections.length : undefined,
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Normalize a research result: the evidence + citations RTerm's agent synthesizes from. */
|
|
92
|
+
export function toResearchBrief(payload) {
|
|
93
|
+
if (!payload || typeof payload !== 'object') return { evidence: [] }
|
|
94
|
+
const evidence = payload.evidence ?? payload.results ?? payload.sources ?? []
|
|
95
|
+
const citations = payload.citations ?? []
|
|
96
|
+
return {
|
|
97
|
+
question: payload.question,
|
|
98
|
+
brief: payload.brief ?? payload.summary,
|
|
99
|
+
evidence: (Array.isArray(evidence) ? evidence : []).map((e) => ({
|
|
100
|
+
title: e.title ?? e.url,
|
|
101
|
+
url: e.url,
|
|
102
|
+
excerpt: (e.excerpt ?? e.snippet ?? '').slice(0, 300),
|
|
103
|
+
citation: e.citation_id ?? e.id,
|
|
104
|
+
})),
|
|
105
|
+
citations: Array.isArray(citations) ? citations : [],
|
|
106
|
+
note: 'Synthesis is done by the RTerm agent from this evidence — no LLM key needed.',
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Map a watch list payload into rows. */
|
|
111
|
+
export function toWatchRows(payload) {
|
|
112
|
+
const items = payload?.watches ?? payload?.items ?? (Array.isArray(payload) ? payload : [])
|
|
113
|
+
if (!Array.isArray(items)) return []
|
|
114
|
+
return items.map((w) => ({
|
|
115
|
+
id: w.id ?? w.watch_id,
|
|
116
|
+
url: w.url,
|
|
117
|
+
lastChecked: w.last_checked ?? w.checked_at,
|
|
118
|
+
changed: w.changed === true,
|
|
119
|
+
webhook: w.webhook,
|
|
120
|
+
}))
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Fires when a watch reports a page change. */
|
|
124
|
+
export function isPageChangedEvent(event) {
|
|
125
|
+
if (event?.source !== 'webintel') return false
|
|
126
|
+
return event?.changed === true || event?.kind === 'page_changed'
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ─── unreachable-daemon helper ─────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
async function guarded(fn, log) {
|
|
132
|
+
try {
|
|
133
|
+
return await fn()
|
|
134
|
+
} catch (e) {
|
|
135
|
+
const msg = e?.message ?? String(e)
|
|
136
|
+
log?.(`[web-intel] ${msg}`)
|
|
137
|
+
return { error: msg, hint: 'Is the wigolo daemon running? The web-intel plugin starts it on first use (npx -y wigolo serve), or run it yourself. Configure the URL in Settings → webIntel.' }
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ─── plugin entry ───────────────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
export function register(ctx) {
|
|
144
|
+
const { registerTool, registerTrigger, registerPanel, log } = ctx
|
|
145
|
+
// Allow tests/runtimes to inject a fetch; default to the real one.
|
|
146
|
+
const { client, config } = buildClient(ctx, typeof ctx.fetchImpl === 'function' ? ctx.fetchImpl : undefined)
|
|
147
|
+
|
|
148
|
+
// Sidecar lifecycle (lazy, lean). Spawn is best-effort — in runtimes where a
|
|
149
|
+
// real child_process spawn is available the daemon starts on first use; in
|
|
150
|
+
// tests the spawnImpl is injected/mocked.
|
|
151
|
+
const sidecar = new WigoloSidecar({
|
|
152
|
+
spawnImpl: ctx.spawnProcess,
|
|
153
|
+
log,
|
|
154
|
+
config: { warmup: config.warmupOnInit, token: config.token },
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
async function ensureDaemon() {
|
|
158
|
+
if (!config.enabled) throw new Error('web-intel is disabled in Settings → webIntel')
|
|
159
|
+
// Probe the daemon; start it if it's down and autoStart is on.
|
|
160
|
+
const h = await client.health()
|
|
161
|
+
if (h.ok) return true
|
|
162
|
+
if (!config.autoStart) throw new Error(`wigolo daemon not reachable at ${config.restUrl} and autoStart is off`)
|
|
163
|
+
if (typeof ctx.spawnProcess !== 'function') {
|
|
164
|
+
throw new Error(`wigolo daemon not reachable at ${config.restUrl}. Start it: npx -y wigolo serve`)
|
|
165
|
+
}
|
|
166
|
+
await sidecar.start()
|
|
167
|
+
// Best-effort: wait briefly for it to come up, but never block hard.
|
|
168
|
+
const deadline = Date.now() + 8000
|
|
169
|
+
for (;;) {
|
|
170
|
+
const probe = await client.health()
|
|
171
|
+
if (probe.ok) break
|
|
172
|
+
if (Date.now() > deadline) throw new Error(`wigolo daemon did not become ready at ${config.restUrl} in time`)
|
|
173
|
+
await new Promise((r) => setTimeout(r, 300))
|
|
174
|
+
}
|
|
175
|
+
// Lean default: only pre-download the heavy models if the user opted in.
|
|
176
|
+
if (config.warmupOnInit) void sidecar.warmupInBackground()
|
|
177
|
+
return true
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Tool: webintel_health — is the daemon up + what's its status.
|
|
181
|
+
registerTool({
|
|
182
|
+
name: 'webintel_health',
|
|
183
|
+
description: 'Check the wigolo web-intelligence daemon status: reachable, lean vs full warmup, and whether it auto-started. Use this first if any web_* tool errors.',
|
|
184
|
+
params: {},
|
|
185
|
+
handler: async () => guarded(async () => {
|
|
186
|
+
const h = await client.health()
|
|
187
|
+
return {
|
|
188
|
+
restUrl: config.restUrl,
|
|
189
|
+
enabled: config.enabled,
|
|
190
|
+
autoStart: config.autoStart,
|
|
191
|
+
daemonUp: h.ok,
|
|
192
|
+
sidecar: sidecar.status(),
|
|
193
|
+
...(h.ok ? { daemon: h.status } : { hint: 'Start it: npx -y wigolo serve (the plugin also auto-starts it on first use).' }),
|
|
194
|
+
}
|
|
195
|
+
}, log),
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
// Tool: web_search — multi-engine web search with ranked, citation-carrying results.
|
|
199
|
+
registerTool({
|
|
200
|
+
name: 'web_search',
|
|
201
|
+
description: 'Search the web (multi-engine, ranked, citation-carrying) for an ops question. Pass a query string or an array for parallel breadth. Returns ranked results with excerpts + citations the agent can quote. Keyless, $0.',
|
|
202
|
+
params: {
|
|
203
|
+
query: { type: ['string', 'array'], description: 'Search query (string) or array of queries for parallel breadth' },
|
|
204
|
+
timeRange: { type: 'string', description: "Optional time scope e.g. 'day'|'week'|'month'|'year'", optional: true },
|
|
205
|
+
domain: { type: 'string', description: 'Optional domain to scope the search to', optional: true },
|
|
206
|
+
maxResults: { type: 'number', description: 'Max results (default daemon setting)', optional: true },
|
|
207
|
+
},
|
|
208
|
+
handler: async (p) => guarded(async () => {
|
|
209
|
+
await ensureDaemon()
|
|
210
|
+
if (!p?.query) return { error: 'web_search needs a query' }
|
|
211
|
+
const r = await client.search(p.query, {
|
|
212
|
+
...(p.timeRange ? { time_range: p.timeRange } : {}),
|
|
213
|
+
...(p.domain ? { domain: p.domain } : {}),
|
|
214
|
+
...(typeof p.maxResults === 'number' ? { max_results: p.maxResults } : {}),
|
|
215
|
+
})
|
|
216
|
+
return { results: toResultRows(r), freshness: r?.freshness_signal }
|
|
217
|
+
}, log),
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
// Tool: web_fetch — fetch one URL as clean markdown (handles JS/SPA/anti-bot).
|
|
221
|
+
registerTool({
|
|
222
|
+
name: 'web_fetch',
|
|
223
|
+
description: 'Fetch one URL as clean markdown + metadata + links (tiered router escalates to a browser engine for JS/SPA/anti-bot pages). Use for a specific doc/advisory/page the agent needs to read.',
|
|
224
|
+
params: {
|
|
225
|
+
url: { type: 'string', description: 'The URL to fetch' },
|
|
226
|
+
section: { type: 'string', description: 'Optional single heading/section to extract', optional: true },
|
|
227
|
+
mode: { type: 'string', description: "Optional 'cache'|'default'|'stealth'", optional: true },
|
|
228
|
+
},
|
|
229
|
+
handler: async (p) => guarded(async () => {
|
|
230
|
+
await ensureDaemon()
|
|
231
|
+
if (!p?.url) return { error: 'web_fetch needs a url' }
|
|
232
|
+
const r = await client.fetch(p.url, {
|
|
233
|
+
...(p.section ? { section: p.section } : {}),
|
|
234
|
+
...(p.mode ? { mode: p.mode } : {}),
|
|
235
|
+
})
|
|
236
|
+
return toPageSummary(r)
|
|
237
|
+
}, log),
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
// Tool: web_crawl — multi-page crawl of a site (BFS/DFS/sitemap/map-only).
|
|
241
|
+
registerTool({
|
|
242
|
+
name: 'web_crawl',
|
|
243
|
+
description: 'Crawl a site (BFS/DFS/sitemap/map-only) with rate limits + robots.txt respect. Use to map a docs site or pull many pages. Returns per-page summaries + links.',
|
|
244
|
+
params: {
|
|
245
|
+
url: { type: 'string', description: 'Start URL' },
|
|
246
|
+
strategy: { type: 'string', description: "Optional 'bfs'|'dfs'|'sitemap'|'map'", optional: true },
|
|
247
|
+
maxPages: { type: 'number', description: 'Max pages to crawl', optional: true },
|
|
248
|
+
},
|
|
249
|
+
handler: async (p) => guarded(async () => {
|
|
250
|
+
await ensureDaemon()
|
|
251
|
+
if (!p?.url) return { error: 'web_crawl needs a url' }
|
|
252
|
+
const r = await client.crawl(p.url, {
|
|
253
|
+
...(p.strategy ? { strategy: p.strategy } : {}),
|
|
254
|
+
...(typeof p.maxPages === 'number' ? { max_pages: p.maxPages } : {}),
|
|
255
|
+
})
|
|
256
|
+
const pages = Array.isArray(r?.pages) ? r.pages.map(toPageSummary) : [toPageSummary(r)]
|
|
257
|
+
return { startUrl: p.url, pageCount: pages.length, pages }
|
|
258
|
+
}, log),
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
// Tool: web_research — decompose a question into evidence + citations; RTerm's
|
|
262
|
+
// agent synthesizes the cited answer (NO LLM key needed).
|
|
263
|
+
registerTool({
|
|
264
|
+
name: 'web_research',
|
|
265
|
+
description: 'Research a question across the web: wigolo decomposes it, fans out sub-queries, fetches sources, and returns ranked evidence + citations. RTerm\'s agent then writes the cited answer from the brief — no LLM key needed. Use for current-doc-grounded answers (release notes, CVEs, errors, best practices).',
|
|
266
|
+
params: {
|
|
267
|
+
question: { type: 'string', description: 'The research question' },
|
|
268
|
+
maxSources: { type: 'number', description: 'Max sources to gather', optional: true },
|
|
269
|
+
},
|
|
270
|
+
handler: async (p) => guarded(async () => {
|
|
271
|
+
await ensureDaemon()
|
|
272
|
+
if (!p?.question) return { error: 'web_research needs a question' }
|
|
273
|
+
const r = await client.research(p.question, {
|
|
274
|
+
...(typeof p.maxSources === 'number' ? { max_sources: p.maxSources } : {}),
|
|
275
|
+
})
|
|
276
|
+
return toResearchBrief(r)
|
|
277
|
+
}, log),
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
// Tool: web_find_similar — pages similar to a URL/concept.
|
|
281
|
+
registerTool({
|
|
282
|
+
name: 'web_find_similar',
|
|
283
|
+
description: 'Find pages similar to a URL or concept (keyword + semantic + live web fusion). Use to find related advisories/docs.',
|
|
284
|
+
params: {
|
|
285
|
+
url: { type: 'string', description: 'The reference URL (or concept)', optional: true },
|
|
286
|
+
concept: { type: 'string', description: 'A concept to find similar pages for', optional: true },
|
|
287
|
+
maxResults: { type: 'number', optional: true },
|
|
288
|
+
},
|
|
289
|
+
handler: async (p) => guarded(async () => {
|
|
290
|
+
await ensureDaemon()
|
|
291
|
+
const input = p?.url ?? p?.concept
|
|
292
|
+
if (!input) return { error: 'web_find_similar needs a url or concept' }
|
|
293
|
+
const r = await client.findSimilar(input, {
|
|
294
|
+
...(typeof p.maxResults === 'number' ? { max_results: p.maxResults } : {}),
|
|
295
|
+
})
|
|
296
|
+
return { results: toResultRows(r) }
|
|
297
|
+
}, log),
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
// Tool: web_watch_add — watch a page for changes; fires webintel_page_changed.
|
|
301
|
+
registerTool({
|
|
302
|
+
name: 'web_watch_add',
|
|
303
|
+
description: 'Watch a page (vendor advisory, CVE, status page, doc) for changes. When it changes, the webintel_page_changed trigger fires so you can run a playbook or propose a change. Deliver to a webhook or poll.',
|
|
304
|
+
params: {
|
|
305
|
+
url: { type: 'string', description: 'The page URL to watch' },
|
|
306
|
+
interval: { type: 'string', description: "Optional check interval e.g. '1h'|'6h'|'1d'", optional: true },
|
|
307
|
+
webhook: { type: 'string', description: 'Optional webhook URL to deliver changes to', optional: true },
|
|
308
|
+
},
|
|
309
|
+
handler: async (p) => guarded(async () => {
|
|
310
|
+
await ensureDaemon()
|
|
311
|
+
if (!p?.url) return { error: 'web_watch_add needs a url' }
|
|
312
|
+
const r = await client.watch('create', {
|
|
313
|
+
url: p.url,
|
|
314
|
+
...(p.interval ? { interval: p.interval } : {}),
|
|
315
|
+
...(p.webhook ? { webhook: p.webhook } : {}),
|
|
316
|
+
})
|
|
317
|
+
return { created: true, watch: r }
|
|
318
|
+
}, log),
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
// Tool: web_watch_list — list active page watches.
|
|
322
|
+
registerTool({
|
|
323
|
+
name: 'web_watch_list',
|
|
324
|
+
description: 'List active page watches (url, last-checked, changed flag).',
|
|
325
|
+
params: {},
|
|
326
|
+
handler: async () => guarded(async () => {
|
|
327
|
+
await ensureDaemon()
|
|
328
|
+
const r = await client.watch('list')
|
|
329
|
+
return { watches: toWatchRows(r) }
|
|
330
|
+
}, log),
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
// Tool: web_watch_remove — stop watching a page.
|
|
334
|
+
registerTool({
|
|
335
|
+
name: 'web_watch_remove',
|
|
336
|
+
description: 'Stop watching a page by watch id or URL.',
|
|
337
|
+
params: {
|
|
338
|
+
id: { type: 'string', description: 'The watch id (or URL)', optional: true },
|
|
339
|
+
url: { type: 'string', description: 'The watched URL', optional: true },
|
|
340
|
+
},
|
|
341
|
+
handler: async (p) => guarded(async () => {
|
|
342
|
+
await ensureDaemon()
|
|
343
|
+
if (!p?.id && !p?.url) return { error: 'web_watch_remove needs an id or url' }
|
|
344
|
+
const r = await client.watch('remove', { ...(p.id ? { id: p.id } : {}), ...(p.url ? { url: p.url } : {}) })
|
|
345
|
+
return { removed: true, result: r }
|
|
346
|
+
}, log),
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
// Trigger: webintel_page_changed — fires when a watched page changes.
|
|
350
|
+
registerTrigger({
|
|
351
|
+
name: 'webintel_page_changed',
|
|
352
|
+
description: 'Fires when a watched page (web_watch_add) reports a change. Use for auto-remediation, CVE/vendor-advisory response, or doc-change playbooks.',
|
|
353
|
+
match: (event) => isPageChangedEvent(event),
|
|
354
|
+
action: 'propose-change',
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
// Panel: web-intel — watched pages + daemon status.
|
|
358
|
+
registerPanel({
|
|
359
|
+
name: 'web-intel',
|
|
360
|
+
title: 'Web Intelligence',
|
|
361
|
+
render: (data) => {
|
|
362
|
+
const watches = (Array.isArray(data?.watches) ? data.watches : [])
|
|
363
|
+
.map((w) => `<tr><td>${w.url ?? ''}</td><td>${w.changed ? 'changed' : '—'}</td><td>${w.lastChecked ?? ''}</td></tr>`)
|
|
364
|
+
.join('')
|
|
365
|
+
return `<div class="web-intel"><h3>Web Intelligence (wigolo)</h3><p>Daemon: ${config.restUrl} · warmup: ${config.warmupOnInit ? 'full' : 'lean'} · synthesis: RTerm agent (no LLM key)</p><h4>Watched pages</h4><table><thead><tr><th>URL</th><th>Changed</th><th>Last checked</th></tr></thead><tbody>${watches || '<tr><td colspan="3">No watches yet — web_watch_add to monitor a page.</td></tr>'}</tbody></table></div>`
|
|
366
|
+
},
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
log(`[web-intel] web-intel registered: 8 tools, 1 trigger, 1 panel (daemon=${config.restUrl}, warmup=${config.warmupOnInit ? 'full' : 'lean'})`)
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
export default {
|
|
373
|
+
register,
|
|
374
|
+
resolveConfig,
|
|
375
|
+
buildClient,
|
|
376
|
+
toResultRows,
|
|
377
|
+
toPageSummary,
|
|
378
|
+
toResearchBrief,
|
|
379
|
+
toWatchRows,
|
|
380
|
+
isPageChangedEvent,
|
|
381
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "web-intel",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Local-first web intelligence for RTerm's agent via wigolo — multi-engine web search, clean-page fetch, site crawl, structured extract, similar-pages, cache, research, and page-watch → RTerm trigger automation. Keyless search/fetch/crawl, $0/query, local-first. Synthesis uses RTerm's own agent (no LLM key needed). Lean by default: the daemon starts lazily with no browser-engine/model warmup (~1.5 GB stays opt-in).",
|
|
5
|
+
"entry": "index.mjs",
|
|
6
|
+
"tools": [
|
|
7
|
+
"webintel_health",
|
|
8
|
+
"web_search",
|
|
9
|
+
"web_fetch",
|
|
10
|
+
"web_crawl",
|
|
11
|
+
"web_research",
|
|
12
|
+
"web_find_similar",
|
|
13
|
+
"web_watch_add",
|
|
14
|
+
"web_watch_list",
|
|
15
|
+
"web_watch_remove"
|
|
16
|
+
],
|
|
17
|
+
"triggers": [
|
|
18
|
+
"webintel_page_changed"
|
|
19
|
+
],
|
|
20
|
+
"panels": [
|
|
21
|
+
"web-intel"
|
|
22
|
+
],
|
|
23
|
+
"permissions": [
|
|
24
|
+
"exec",
|
|
25
|
+
"spawnProcess",
|
|
26
|
+
"readLedger:metrics"
|
|
27
|
+
]
|
|
28
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sidecar.mjs — manages the wigolo daemon lifecycle for RTerm's web-intel
|
|
3
|
+
* plugin: lazily start `wigolo serve` on first use, keep a stock RTerm install
|
|
4
|
+
* lean (no browser-engine/on-device-model download unless the user opts in),
|
|
5
|
+
* and report status. Pure + injectable: process spawning and health-probing are
|
|
6
|
+
* injected so it's fully unit-testable offline.
|
|
7
|
+
*
|
|
8
|
+
* Lean-by-default: we start the daemon with WIGOLO_NO_WARMUP=1 so the ~1.5 GB
|
|
9
|
+
* browser engine + on-device models are NOT downloaded at init — search/fetch/
|
|
10
|
+
* crawl work keyless without them. The heavier models download in the background
|
|
11
|
+
* on first use that actually needs them. `warmupOnInit: true` opts into the full
|
|
12
|
+
* upfront download (a background `wigolo init` run).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export const DEFAULT_PORT = 3333
|
|
16
|
+
export const DEFAULT_HOST = '127.0.0.1'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Build the spawn plan for the wigolo daemon.
|
|
20
|
+
* @param {{ port?: number, host?: string, warmup?: boolean, token?: string }} cfg
|
|
21
|
+
* @returns {{ command: string, args: string[], env: Record<string,string> }}
|
|
22
|
+
*/
|
|
23
|
+
export function buildServePlan(cfg = {}) {
|
|
24
|
+
const port = cfg.port ?? DEFAULT_PORT
|
|
25
|
+
const host = cfg.host ?? DEFAULT_HOST
|
|
26
|
+
const env = { ...process.env }
|
|
27
|
+
// Lean by default: skip the browser-engine/model warmup unless explicitly on.
|
|
28
|
+
if (cfg.warmup !== true) env.WIGOLO_NO_WARMUP = '1'
|
|
29
|
+
if (cfg.token) env.WIGOLO_API_TOKEN = cfg.token
|
|
30
|
+
return {
|
|
31
|
+
command: 'npx',
|
|
32
|
+
args: ['-y', 'wigolo', 'serve', '--port', String(port), '--host', host],
|
|
33
|
+
env,
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Build the background `wigolo init` plan (the full ~1.5 GB warmup). */
|
|
38
|
+
export function buildInitPlan(cfg = {}) {
|
|
39
|
+
const env = { ...process.env }
|
|
40
|
+
if (cfg.token) env.WIGOLO_API_TOKEN = cfg.token
|
|
41
|
+
return { command: 'npx', args: ['-y', 'wigolo', 'init'], env }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export class WigoloSidecar {
|
|
45
|
+
/**
|
|
46
|
+
* @param {{
|
|
47
|
+
* spawnImpl?: (cmd: string, args: string[], opts: object) => any,
|
|
48
|
+
* healthImpl?: () => Promise<boolean>,
|
|
49
|
+
* log?: (line: string) => void,
|
|
50
|
+
* config?: { port?: number, host?: string, warmup?: boolean, token?: string, autoStart?: boolean },
|
|
51
|
+
* now?: () => number,
|
|
52
|
+
* }} deps — all injectable; defaults are real (child_process + a health probe).
|
|
53
|
+
*/
|
|
54
|
+
constructor(deps = {}) {
|
|
55
|
+
this.config = deps.config ?? {}
|
|
56
|
+
this.spawnImpl = deps.spawnImpl
|
|
57
|
+
this.healthImpl = deps.healthImpl
|
|
58
|
+
this.log = deps.log ?? (() => {})
|
|
59
|
+
this.now = deps.now ?? (() => Date.now())
|
|
60
|
+
this.process = null
|
|
61
|
+
this.startedAt = 0
|
|
62
|
+
this.lastError = undefined
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Whether the daemon process is believed to be running. */
|
|
66
|
+
isRunning() {
|
|
67
|
+
return this.process != null
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Start the daemon (idempotent). Returns the base URL it's expected on. */
|
|
71
|
+
async start() {
|
|
72
|
+
if (this.process) return this.baseUrl()
|
|
73
|
+
if (typeof this.spawnImpl !== 'function') {
|
|
74
|
+
this.lastError = 'no spawnImpl (sidecar spawn not available in this runtime)'
|
|
75
|
+
throw new Error(this.lastError)
|
|
76
|
+
}
|
|
77
|
+
const plan = buildServePlan(this.config)
|
|
78
|
+
this.log(`[web-intel] starting wigolo daemon: ${plan.command} ${plan.args.join(' ')}`)
|
|
79
|
+
try {
|
|
80
|
+
this.process = this.spawnImpl(plan.command, plan.args, {
|
|
81
|
+
env: plan.env,
|
|
82
|
+
detached: true,
|
|
83
|
+
stdio: 'ignore',
|
|
84
|
+
})
|
|
85
|
+
// Detach so the daemon outlives the plugin turn (it serves many agents).
|
|
86
|
+
this.process?.unref?.()
|
|
87
|
+
this.startedAt = this.now()
|
|
88
|
+
this.lastError = undefined
|
|
89
|
+
} catch (e) {
|
|
90
|
+
this.lastError = e?.message ?? String(e)
|
|
91
|
+
this.process = null
|
|
92
|
+
throw e
|
|
93
|
+
}
|
|
94
|
+
return this.baseUrl()
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Kick off the full ~1.5 GB warmup in the background (opt-in). */
|
|
98
|
+
async warmupInBackground() {
|
|
99
|
+
if (typeof this.spawnImpl !== 'function') return false
|
|
100
|
+
const plan = buildInitPlan(this.config)
|
|
101
|
+
try {
|
|
102
|
+
const p = this.spawnImpl(plan.command, plan.args, { env: plan.env, detached: true, stdio: 'ignore' })
|
|
103
|
+
p?.unref?.()
|
|
104
|
+
this.log('[web-intel] background wigolo init (browser engine + models) started')
|
|
105
|
+
return true
|
|
106
|
+
} catch {
|
|
107
|
+
return false
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Stop the daemon. */
|
|
112
|
+
async stop() {
|
|
113
|
+
if (!this.process) return
|
|
114
|
+
try { this.process.kill?.() } catch { /* best-effort */ }
|
|
115
|
+
this.process = null
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Status snapshot for the health tool / panel. */
|
|
119
|
+
status() {
|
|
120
|
+
return {
|
|
121
|
+
running: this.isRunning(),
|
|
122
|
+
baseUrl: this.baseUrl(),
|
|
123
|
+
startedAt: this.startedAt || undefined,
|
|
124
|
+
lastError: this.lastError,
|
|
125
|
+
warmup: this.config.warmup === true ? 'full' : 'lean (no warmup)',
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
baseUrl() {
|
|
130
|
+
const host = this.config.host ?? DEFAULT_HOST
|
|
131
|
+
const port = this.config.port ?? DEFAULT_PORT
|
|
132
|
+
return `http://${host}:${port}`
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* wigoloClient.mjs — a minimal, dependency-free HTTP client for the wigolo web
|
|
3
|
+
* intelligence daemon (`wigolo serve`, default http://127.0.0.1:3333).
|
|
4
|
+
*
|
|
5
|
+
* wigolo exposes one REST route per tool: POST /v1/{search,fetch,crawl,cache,
|
|
6
|
+
* extract,find_similar,research,agent,diff,watch}, GET /health, GET /v1/tools.
|
|
7
|
+
* This client is pure + injectable (a `fetchImpl` is passed in) so it is fully
|
|
8
|
+
* unit-testable offline with a mocked fetch — no runtime network baked in.
|
|
9
|
+
*
|
|
10
|
+
* Auth: when the daemon is started with WIGOLO_API_TOKEN, every /v1 request
|
|
11
|
+
* needs `Authorization: Bearer <token>` (/health stays open). The client sends
|
|
12
|
+
* the header only when a token is provided.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export const DEFAULT_BASE_URL = 'http://127.0.0.1:3333'
|
|
16
|
+
|
|
17
|
+
/** Build request headers (adds the bearer token only when set). */
|
|
18
|
+
export function buildHeaders(token) {
|
|
19
|
+
const h = { 'content-type': 'application/json', accept: 'application/json' }
|
|
20
|
+
if (token) h.authorization = `Bearer ${token}`
|
|
21
|
+
return h
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Join a base URL + path safely (single slash). */
|
|
25
|
+
export function joinUrl(base, path) {
|
|
26
|
+
const b = String(base || DEFAULT_BASE_URL).replace(/\/+$/, '')
|
|
27
|
+
const p = String(path || '').startsWith('/') ? String(path) : `/${path}`
|
|
28
|
+
return `${b}${p}`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function parseBody(res) {
|
|
32
|
+
const text = await res.text()
|
|
33
|
+
if (!text) return null
|
|
34
|
+
try { return JSON.parse(text) } catch { return text }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export class WigoloApiError extends Error {
|
|
38
|
+
constructor(status, path, body) {
|
|
39
|
+
super(`wigolo ${status} ${path}: ${typeof body === 'string' ? body.slice(0, 300) : JSON.stringify(body)?.slice(0, 300)}`)
|
|
40
|
+
this.status = status
|
|
41
|
+
this.path = path
|
|
42
|
+
this.body = body
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export class WigoloClient {
|
|
47
|
+
/**
|
|
48
|
+
* @param {{ baseUrl?: string, token?: string, fetchImpl: Function }} opts
|
|
49
|
+
* fetchImpl(url, {method, headers, body}) -> Promise<{ok,status,text:()=>Promise<string>}>
|
|
50
|
+
*/
|
|
51
|
+
constructor(opts = {}) {
|
|
52
|
+
if (typeof opts.fetchImpl !== 'function') throw new Error('WigoloClient needs a fetchImpl')
|
|
53
|
+
this.baseUrl = opts.baseUrl || DEFAULT_BASE_URL
|
|
54
|
+
this.token = opts.token
|
|
55
|
+
this.fetchImpl = opts.fetchImpl
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async #post(path, payload) {
|
|
59
|
+
const res = await this.fetchImpl(joinUrl(this.baseUrl, path), {
|
|
60
|
+
method: 'POST',
|
|
61
|
+
headers: buildHeaders(this.token),
|
|
62
|
+
body: JSON.stringify(payload ?? {}),
|
|
63
|
+
})
|
|
64
|
+
const body = await parseBody(res)
|
|
65
|
+
if (!res.ok) throw new WigoloApiError(res.status, path, body)
|
|
66
|
+
return body
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async #get(path) {
|
|
70
|
+
const res = await this.fetchImpl(joinUrl(this.baseUrl, path), {
|
|
71
|
+
method: 'GET',
|
|
72
|
+
headers: buildHeaders(this.token),
|
|
73
|
+
})
|
|
74
|
+
const body = await parseBody(res)
|
|
75
|
+
if (!res.ok) throw new WigoloApiError(res.status, path, body)
|
|
76
|
+
return body
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Liveness + component status. Always open (no token). */
|
|
80
|
+
async health() {
|
|
81
|
+
try {
|
|
82
|
+
const body = await this.#get('/health')
|
|
83
|
+
return { ok: true, status: body }
|
|
84
|
+
} catch (e) {
|
|
85
|
+
return { ok: false, error: e?.message ?? String(e) }
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** List the daemon's tools (descriptions + endpoints). */
|
|
90
|
+
async tools() {
|
|
91
|
+
return this.#get('/v1/tools')
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Multi-engine web search. `query` is a string or an array (parallel breadth). */
|
|
95
|
+
async search(query, opts = {}) {
|
|
96
|
+
return this.#post('/v1/search', { query, ...opts })
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Fetch one URL as clean markdown (tiered router escalates to the browser engine). */
|
|
100
|
+
async fetch(url, opts = {}) {
|
|
101
|
+
return this.#post('/v1/fetch', { url, ...opts })
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Multi-page crawl (BFS/DFS/sitemap/map-only). */
|
|
105
|
+
async crawl(url, opts = {}) {
|
|
106
|
+
return this.#post('/v1/crawl', { url, ...opts })
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Structured extraction (tables, metadata, JSON-LD, named/custom schema). */
|
|
110
|
+
async extract(url, opts = {}) {
|
|
111
|
+
return this.#post('/v1/extract', { url, ...opts })
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Pages similar to a URL/concept (keyword + semantic + live web fusion). */
|
|
115
|
+
async findSimilar(input, opts = {}) {
|
|
116
|
+
return this.#post('/v1/find_similar', typeof input === 'string' ? { url: input, ...opts } : { ...input, ...opts })
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Query the local cache of everything already seen (keyword or hybrid semantic). */
|
|
120
|
+
async cache(opts = {}) {
|
|
121
|
+
return this.#post('/v1/cache', opts)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Decompose → fan out → fetch → return a structured brief + evidence.
|
|
125
|
+
* (Synthesis is done by the HOST agent, not wigolo's LLM — we pass no LLM key,
|
|
126
|
+
* so wigolo returns the raw brief + evidence and RTerm's agent writes the answer.) */
|
|
127
|
+
async research(question, opts = {}) {
|
|
128
|
+
return this.#post('/v1/research', { question, ...opts })
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Autonomous gather loop (plan → search → fetch → extract) with a step log. */
|
|
132
|
+
async agent(goal, opts = {}) {
|
|
133
|
+
return this.#post('/v1/agent', { goal, ...opts })
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Diff two page snapshots (or a page vs its last-seen cached version). */
|
|
137
|
+
async diff(input, opts = {}) {
|
|
138
|
+
return this.#post('/v1/diff', typeof input === 'string' ? { url: input, ...opts } : { ...input, ...opts })
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Watch management: action=create|list|remove. */
|
|
142
|
+
async watch(action, opts = {}) {
|
|
143
|
+
return this.#post('/v1/watch', { action, ...opts })
|
|
144
|
+
}
|
|
145
|
+
}
|