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 +53 -7
- package/package.json +5 -67
- package/plugins/sample-k8s-slo/index.ts +58 -0
- package/plugins/sample-k8s-slo/plugin.json +11 -0
- 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/LICENSE.md +0 -201
- package/README.md +0 -31
- package/plugins/agentspan-bridge/agentspan-bridge.extreme.spec.ts +0 -328
- package/plugins/agentspan-bridge/agentspan-bridge.phase2.extreme.spec.ts +0 -282
- package/plugins/netdata-rterm/netdata-rterm.extreme.spec.ts +0 -252
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: (
|
|
383914
|
-
|
|
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
|
|
4
|
-
"description": "
|
|
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
|
-
|
|
8
|
-
|
|
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
|
+
}
|