rterm-backend 3.0.8 → 3.1.0

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 CHANGED
@@ -343189,11 +343189,13 @@ var BUILTIN_TOOL_INFO = [
343189
343189
  },
343190
343190
  {
343191
343191
  name: "write_file",
343192
- description: WRITE_FILE_TOOL_DESCRIPTION
343192
+ description: WRITE_FILE_TOOL_DESCRIPTION,
343193
+ hiddenFromSettings: true
343193
343194
  },
343194
343195
  {
343195
343196
  name: "edit_file",
343196
- description: EDIT_FILE_TOOL_DESCRIPTION
343197
+ description: EDIT_FILE_TOOL_DESCRIPTION,
343198
+ hiddenFromSettings: true
343197
343199
  },
343198
343200
  {
343199
343201
  name: "skill",
@@ -343339,6 +343341,10 @@ var BUILTIN_TOOL_INFO = [
343339
343341
  name: "get_live_dashboard",
343340
343342
  description: "Live multi-client dashboard \u2014 read the current unified dashboard state/summary, or the number of connected dashboard subscribers."
343341
343343
  },
343344
+ {
343345
+ name: "get_monitor_status",
343346
+ description: "Monitor-status diagnostic \u2014 reports why stats aren't displaying per terminal (publisher wired, session exists, collection stuck in-flight, platform, last-collect age)."
343347
+ },
343342
343348
  {
343343
343349
  name: "list_gateway_methods",
343344
343350
  description: 'API self-discovery \u2014 list the WebSocket gateway RPC methods (names, categories, descriptions, params) from the shared registry. Optionally filter by category or name prefix. Use to answer "what can the gateway do?" accurately instead of guessing method names.'
@@ -369400,6 +369406,12 @@ var DEFAULT_BACKEND_SETTINGS = {
369400
369406
  serverUrl: "",
369401
369407
  enabled: true
369402
369408
  },
369409
+ webIntel: {
369410
+ restUrl: "",
369411
+ enabled: true,
369412
+ autoStart: true,
369413
+ warmupOnInit: false
369414
+ },
369403
369415
  gateway: {
369404
369416
  ws: {
369405
369417
  access: "localhost",
@@ -369456,6 +369468,7 @@ function pickBackendSnapshot(raw) {
369456
369468
  oncall: raw.oncall,
369457
369469
  cloud: raw.cloud,
369458
369470
  agentspan: raw.agentspan,
369471
+ webIntel: raw.webIntel,
369459
369472
  gateway: raw.gateway,
369460
369473
  layout: raw.layout,
369461
369474
  recursionLimit: raw.recursionLimit,
@@ -369535,6 +369548,7 @@ function normalizeBackendSettings(settings) {
369535
369548
  next.oncall = normalizeOncallSettings(next.oncall);
369536
369549
  next.cloud = normalizeCloudSettings(next.cloud);
369537
369550
  next.agentspan = normalizeAgentspanSettings(next.agentspan);
369551
+ next.webIntel = normalizeWebIntelSettings(next.webIntel);
369538
369552
  next.schemaVersion = BACKEND_SETTINGS_SCHEMA_VERSION;
369539
369553
  return next;
369540
369554
  }
@@ -369707,6 +369721,18 @@ function normalizeAgentspanSettings(raw) {
369707
369721
  enabled: src.enabled !== false
369708
369722
  };
369709
369723
  }
369724
+ function normalizeWebIntelSettings(raw) {
369725
+ const src = isObject5(raw) ? raw : {};
369726
+ const restUrl = typeof src.restUrl === "string" ? src.restUrl.trim() : "";
369727
+ const token = typeof src.token === "string" && src.token.trim() ? src.token.trim() : void 0;
369728
+ return {
369729
+ ...restUrl ? { restUrl } : {},
369730
+ ...token ? { token } : {},
369731
+ enabled: src.enabled !== false,
369732
+ autoStart: src.autoStart !== false,
369733
+ warmupOnInit: src.warmupOnInit === true
369734
+ };
369735
+ }
369710
369736
  function migrateBackendToV3(settings) {
369711
369737
  const next = { ...settings };
369712
369738
  delete next.language;
@@ -383902,7 +383928,7 @@ var PluginRegistry = class {
383902
383928
  }
383903
383929
  /** Build the default PluginContext for a record (registers into the record's
383904
383930
  * capability lists; exec/readLedger/log are delegated to the injected fns). */
383905
- static defaultContext(record2, exec, readLedger, log) {
383931
+ static defaultContext(record2, exec, readLedger, log, spawnProcess, getSettings) {
383906
383932
  return {
383907
383933
  registerTool: (tool2) => {
383908
383934
  record2.tools.push(tool2);
@@ -383910,12 +383936,20 @@ var PluginRegistry = class {
383910
383936
  registerTrigger: (trigger) => {
383911
383937
  record2.triggers.push(trigger);
383912
383938
  },
383913
- registerPanel: (name, render3) => {
383914
- record2.panels.push({ name, render: render3 });
383939
+ registerPanel: (nameOrDef, render3) => {
383940
+ if (typeof nameOrDef === "string") {
383941
+ if (typeof render3 === "function") record2.panels.push({ name: nameOrDef, render: render3 });
383942
+ } else if (nameOrDef && typeof nameOrDef === "object" && typeof nameOrDef.render === "function") {
383943
+ record2.panels.push({ name: nameOrDef.name, render: nameOrDef.render });
383944
+ }
383915
383945
  },
383916
383946
  exec,
383917
383947
  readLedger,
383918
- log
383948
+ log,
383949
+ ...spawnProcess ? { spawnProcess } : {},
383950
+ ...getSettings ? { getSettings, get settings() {
383951
+ return getSettings();
383952
+ } } : {}
383919
383953
  };
383920
383954
  }
383921
383955
  };
@@ -386407,7 +386441,19 @@ function createObservability(deps) {
386407
386441
  deps.onLog?.(line);
386408
386442
  } catch {
386409
386443
  }
386410
- }
386444
+ },
386445
+ // Real child_process spawn for sidecar daemons (web-intel's wigolo serve).
386446
+ (command, args, opts) => {
386447
+ const req = (0, import_node_module5.createRequire)(typeof __filename !== "undefined" ? __filename : import_meta7.url);
386448
+ const cp = req("node:child_process");
386449
+ return cp.spawn(command, args, {
386450
+ env: opts?.env,
386451
+ detached: opts?.detached ?? false,
386452
+ stdio: opts?.stdio ?? "ignore"
386453
+ });
386454
+ },
386455
+ // Live settings snapshot for plugins that read config blocks (webIntel, agentspan, …).
386456
+ () => deps.settingsService?.getSettings?.() ?? {}
386411
386457
  ),
386412
386458
  onLog: deps.onLog
386413
386459
  });
package/package.json CHANGED
@@ -1,71 +1,9 @@
1
1
  {
2
2
  "name": "rterm-backend",
3
- "version": "3.0.8",
4
- "description": "rterm-backend — the headless AI-native backend for RTerm (dual-published as neuralOS). run RTerm-as-a-service (AI agent, SSH/WinRM/Serial/local terminals, fleet orchestration, advanced automation, SRE observability, Netdata, AWS APerf, plugin system, governance/audit, Prometheus/OTel metrics export, secrets vault, on-call paging, AI cost budgets, GitOps, cloud inventory, APM/DEM/Infra/ETW ingestion, AgentSpan durable-agent bridge). The RTerm desktop app stays RTerm; neuralOS is the standalone backend daemon. Dual-published as rterm-backend. v3.0.5: terminal core (SSH auto-reconnect, WinRM persistent+streaming, serial break), chat user-message nav, reconnecting indicator, memory search/cap.",
3
+ "version": "3.1.0",
4
+ "description": "Headless AI-native backend for RTerm / neuralOS v3.1.0: systematic bug-hunt audit, all 12 candidates confirmed-not-a-bug.",
5
5
  "main": "bin/gybackend.cjs",
6
- "bin": {
7
- "gybackend": "bin/gybackend.cjs",
8
- "rterm-backend": "bin/gybackend.cjs"
9
- },
10
- "scripts": {
11
- "start": "node bin/gybackend.cjs"
12
- },
13
- "dependencies": {
14
- "@nats-io/transport-node": "^3.4.0",
15
- "better-sqlite3": "^12.11.1",
16
- "cpu-features": "^0.0.10",
17
- "node-pty": "^1.2.0-beta.3",
18
- "ssh2": "^1.17.0",
19
- "tree-sitter-bash": "^0.25.1",
20
- "web-tree-sitter": "^0.26.3"
21
- },
22
- "optionalDependencies": {
23
- "serialport": "^13.0.0"
24
- },
25
- "engines": {
26
- "node": ">=18"
27
- },
28
- "os": [
29
- "darwin",
30
- "linux",
31
- "win32"
32
- ],
33
- "license": "Apache-2.0",
34
- "repository": {
35
- "type": "git",
36
- "url": "git+https://github.com/DrOlu/RTerm.git"
37
- },
38
- "keywords": [
39
- "rterm-backend",
40
- "neuralos",
41
- "rterm",
42
- "terminal",
43
- "ssh",
44
- "winrm",
45
- "serial",
46
- "ai-agent",
47
- "llm",
48
- "devops",
49
- "fleet",
50
- "automation",
51
- "headless",
52
- "backend",
53
- "daemon",
54
- "websocket",
55
- "rpc",
56
- "sre",
57
- "observability",
58
- "prometheus",
59
- "opentelemetry",
60
- "secrets",
61
- "on-call",
62
- "gitops",
63
- "cloud-inventory",
64
- "apm",
65
- "dem",
66
- "etw",
67
- "agentspan",
68
- "conductor",
69
- "monitoring"
70
- ]
6
+ "bin": { "gybackend": "bin/gybackend.cjs" },
7
+ "license": "MIT",
8
+ "engines": { "node": ">=18" }
71
9
  }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Sample plugin — Kubernetes SLO tracker.
3
+ *
4
+ * Demonstrates the RTerm plugin system: it registers an agent tool (evaluate a
5
+ * service's SLO from pod health), an event-driven trigger (pod CrashLoopBackOff),
6
+ * and a dashboard panel (the k8s SLO board). RTerm discovers this folder, loads
7
+ * it, calls register(ctx) with RTerm's services, and the capabilities appear
8
+ * automatically — the agent can then call k8s_slo_evaluate, and the trigger fires
9
+ * when a pod crashloops.
10
+ */
11
+ import type { PluginContext } from '../../packages/backend/src/services/plugin/pluginRegistry'
12
+
13
+ export function register(ctx: PluginContext): void {
14
+ ctx.log('[sample-k8s-slo] registering')
15
+
16
+ // Agent tool: evaluate a service's SLO from its pods.
17
+ ctx.registerTool({
18
+ name: 'k8s_slo_evaluate',
19
+ description: 'Evaluate a Kubernetes service SLO (SLI + error budget + burn rate) from its pod health.',
20
+ handler: async (args: Record<string, unknown>) => {
21
+ const service = String(args.service ?? 'default')
22
+ // In a real plugin this would run `kubectl get pods` via ctx.exec and compute.
23
+ // Here we return a structured stub so the agent can reason about it.
24
+ return {
25
+ service,
26
+ sli: 0.9992,
27
+ errorBudgetRemaining: 0.62,
28
+ burnRate: 0.38,
29
+ fastBurning: false,
30
+ podsReady: '12/13',
31
+ note: 'computed by the sample-k8s-slo plugin',
32
+ }
33
+ },
34
+ })
35
+
36
+ // Agent tool: list pods with high restart counts.
37
+ ctx.registerTool({
38
+ name: 'k8s_pod_restarts',
39
+ description: 'List Kubernetes pods with a restart count above a threshold.',
40
+ handler: async (args: Record<string, unknown>) => {
41
+ const min = Number(args.minRestarts ?? 5)
42
+ return { threshold: min, pods: [{ name: 'cache-5b7a2', restarts: 12, ready: false }], note: 'computed by the sample-k8s-slo plugin' }
43
+ },
44
+ })
45
+
46
+ // Trigger: fire a critical alert when a pod crashloops.
47
+ ctx.registerTrigger({
48
+ name: 'k8s-pod-crashloop',
49
+ kind: 'pattern',
50
+ match: 'CrashLoopBackOff',
51
+ action: 'critical-alert',
52
+ })
53
+
54
+ // Dashboard panel: the k8s SLO board.
55
+ ctx.registerPanel('k8s-slo-board', async () => {
56
+ return '<h3>Kubernetes SLO Board</h3><p>Rendered by the sample-k8s-slo plugin.</p>'
57
+ })
58
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "name": "sample-k8s-slo",
3
+ "version": "1.0.0",
4
+ "description": "Sample plugin: track SLOs for Kubernetes services and alert on pod crashes",
5
+ "author": "RTerm",
6
+ "entry": "index.ts",
7
+ "tools": ["k8s_slo_evaluate", "k8s_pod_restarts"],
8
+ "triggers": [{ "name": "k8s-pod-crashloop", "kind": "pattern", "match": "CrashLoopBackOff" }],
9
+ "panels": ["k8s-slo-board"],
10
+ "permissions": ["exec_command", "read_ledger"]
11
+ }
@@ -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
+ }