ocuclaw 1.2.4 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +21 -6
  2. package/dist/config/runtime-config.js +84 -3
  3. package/dist/domain/activity-status-adapter.js +138 -605
  4. package/dist/domain/activity-status-arbiter.js +109 -0
  5. package/dist/domain/activity-status-labels.js +906 -0
  6. package/dist/domain/code-span-regions.js +103 -0
  7. package/dist/domain/conversation-state.js +14 -1
  8. package/dist/domain/debug-store.js +56 -182
  9. package/dist/domain/glasses-ui-content-summary.js +62 -0
  10. package/dist/domain/glasses-ui-system-prompt.js +28 -0
  11. package/dist/domain/message-emoji-allowlist.js +16 -0
  12. package/dist/domain/message-emoji-filter.js +33 -55
  13. package/dist/domain/neural-emoji-reactor-system-prompt.js +43 -0
  14. package/dist/domain/neural-emoji-reactor-tag-config.js +56 -0
  15. package/dist/domain/neural-pace-modulator-system-prompt.js +32 -0
  16. package/dist/domain/neural-pace-modulator-tag-config.js +51 -0
  17. package/dist/domain/tagged-span-parser.js +121 -0
  18. package/dist/domain/tagged-span-strip.js +38 -0
  19. package/dist/even-ai/even-ai-endpoint.js +91 -0
  20. package/dist/even-ai/even-ai-run-waiter.js +14 -0
  21. package/dist/even-ai/even-ai-settings-store.js +14 -0
  22. package/dist/gateway/gateway-bridge.js +14 -2
  23. package/dist/gateway/gateway-timing-ledger.js +457 -0
  24. package/dist/gateway/openclaw-client.js +462 -38
  25. package/dist/index.js +28 -1
  26. package/dist/runtime/downstream-handler.js +754 -83
  27. package/dist/runtime/ocuclaw-settings-store.js +74 -31
  28. package/dist/runtime/plugin-version-service.js +23 -0
  29. package/dist/runtime/protocol-adapter.js +9 -0
  30. package/dist/runtime/provider-usage-select.js +168 -0
  31. package/dist/runtime/relay-client-nudge-controller.js +553 -0
  32. package/dist/runtime/relay-core.js +1293 -225
  33. package/dist/runtime/relay-health-monitor.js +172 -0
  34. package/dist/runtime/relay-operation-registry.js +263 -0
  35. package/dist/runtime/relay-service.js +201 -1
  36. package/dist/runtime/relay-worker-approval-replay-cache.js +68 -0
  37. package/dist/runtime/relay-worker-entry.js +32 -0
  38. package/dist/runtime/relay-worker-health.js +272 -0
  39. package/dist/runtime/relay-worker-protocol.js +281 -0
  40. package/dist/runtime/relay-worker-queue.js +202 -0
  41. package/dist/runtime/relay-worker-supervisor.js +1004 -0
  42. package/dist/runtime/relay-worker-transport.js +1051 -0
  43. package/dist/runtime/session-context-service.js +189 -0
  44. package/dist/runtime/session-service.js +638 -27
  45. package/dist/runtime/upstream-runtime.js +1167 -60
  46. package/dist/tools/device-info-tool.js +242 -0
  47. package/dist/tools/glasses-ui-cron.js +427 -0
  48. package/dist/tools/glasses-ui-descriptors.js +261 -0
  49. package/dist/tools/glasses-ui-limits.js +21 -0
  50. package/dist/tools/glasses-ui-paint-floor.js +99 -0
  51. package/dist/tools/glasses-ui-recipes.js +581 -0
  52. package/dist/tools/glasses-ui-surfaces.js +278 -0
  53. package/dist/tools/glasses-ui-template.js +182 -0
  54. package/dist/tools/glasses-ui-tool.js +1111 -0
  55. package/dist/tools/session-title-tool.js +209 -0
  56. package/dist/version.js +2 -0
  57. package/openclaw.plugin.json +163 -15
  58. package/package.json +14 -5
  59. package/skills/glasses-ui/SKILL.md +156 -0
  60. package/dist/runtime/downstream-server.js +0 -1891
@@ -0,0 +1,581 @@
1
+ // Recipe executors for glasses_ui_refresh. Kinds: http (in-process fetch),
2
+ // llm (two HTTP API backends behind one dispatcher), and system-stats
3
+ // (in-process node:os reads). All return either { output } or
4
+ // { error: <string> }. The shell (spawn bash) and llm claude-cli
5
+ // (spawn claude) backends were removed to drop the plugin's last
6
+ // child_process spawn — see the backends comment in config/runtime-config.ts.
7
+ //
8
+ // output is `string` for plain text and `object` for JSON. The cron engine
9
+ // hands `output` to the template engine which handles both shapes.
10
+
11
+ import { totalmem, freemem, loadavg, cpus } from "node:os";
12
+ import * as dns from "node:dns";
13
+ import { Agent } from "undici";
14
+
15
+ const DEFAULT_TIMEOUT_MS = 10_000;
16
+ const DEFAULT_OUTPUT_CAP_BYTES = 64 * 1024;
17
+
18
+ const SYSTEM_STATS_WINDOW_DEFAULT_MS = 200;
19
+ const SYSTEM_STATS_WINDOW_MIN_MS = 50;
20
+ const SYSTEM_STATS_WINDOW_MAX_MS = 1000;
21
+
22
+ function parseJsonIfPossible(text) {
23
+ if (typeof text !== "string" || text.length === 0) return text;
24
+ const trimmed = text.trim();
25
+ if (
26
+ !(trimmed.startsWith("{") && trimmed.endsWith("}")) &&
27
+ !(trimmed.startsWith("[") && trimmed.endsWith("]"))
28
+ ) {
29
+ return text;
30
+ }
31
+ try {
32
+ return JSON.parse(trimmed);
33
+ } catch (_) {
34
+ return text;
35
+ }
36
+ }
37
+
38
+ function checkIpv4Tuple(a, b) {
39
+ if (a === 127) return "loopback IPv4 blocked";
40
+ if (a === 10) return "RFC1918 IPv4 blocked";
41
+ if (a === 172 && b >= 16 && b <= 31) return "RFC1918 IPv4 blocked";
42
+ if (a === 192 && b === 168) return "RFC1918 IPv4 blocked";
43
+ if (a === 169 && b === 254) return "link-local / cloud-metadata IPv4 blocked";
44
+ if (a === 0) return "zero-network IPv4 blocked";
45
+ if (a >= 224) return "multicast/reserved IPv4 blocked";
46
+ return null;
47
+ }
48
+
49
+ // Classify a resolved IP literal (as returned by dns.lookup). Reused both
50
+ // by the URL-form check below and by the per-connection lookup that fires
51
+ // when undici opens the socket — see safeLookup. For IPv4 we route through
52
+ // checkIpv4Tuple; for IPv6 we additionally peel IPv4-mapped (::ffff:a.b.c.d)
53
+ // and the hex-compressed IPv4-compat form that Node sometimes returns.
54
+ function checkResolvedIp(address, family) {
55
+ if (typeof address !== "string") return null;
56
+ if (family === 4) {
57
+ const m = address.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
58
+ if (!m) return null;
59
+ return checkIpv4Tuple(Number(m[1]), Number(m[2]));
60
+ }
61
+ if (family !== 6) return null;
62
+ const addr = address.toLowerCase();
63
+ if (addr === "::" || addr === "::1") return "IPv6 loopback/unspecified blocked";
64
+ const mappedDotted = addr.match(/^::ffff:(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
65
+ if (mappedDotted) {
66
+ const r = checkIpv4Tuple(Number(mappedDotted[1]), Number(mappedDotted[2]));
67
+ return r ? `IPv4-mapped IPv6 (${r})` : null;
68
+ }
69
+ const mappedHex = addr.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
70
+ if (mappedHex) {
71
+ const high = parseInt(mappedHex[1], 16);
72
+ const r = checkIpv4Tuple((high >> 8) & 0xff, high & 0xff);
73
+ return r ? `IPv4-mapped IPv6 (${r})` : null;
74
+ }
75
+ const compatHex = addr.match(/^::([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
76
+ if (compatHex) {
77
+ const high = parseInt(compatHex[1], 16);
78
+ const r = checkIpv4Tuple((high >> 8) & 0xff, high & 0xff);
79
+ return r ? `IPv4-compatible IPv6 (${r})` : null;
80
+ }
81
+ // fe80::/10 spans fe80:: through febf:: — the top 10 bits are 1111111010,
82
+ // so the first byte is always 0xfe and the second byte ranges 0x80-0xbf.
83
+ // The canonical first hextet is therefore fe[89ab][0-9a-f] (the leading
84
+ // 'fe' is fixed, the third hex char is 8/9/a/b for the high two bits 10).
85
+ // The old startsWith("fe80:") check missed fe81::* through febf::*.
86
+ if (/^fe[89ab][0-9a-f]:/.test(addr)) return "IPv6 link-local blocked";
87
+ if (/^f[cd][0-9a-f]{2}:/.test(addr)) return "IPv6 ULA blocked";
88
+ return null;
89
+ }
90
+
91
+ // Per-connection DNS lookup that undici calls just before opening the socket.
92
+ // We resolve the hostname via the same resolver fetch would use, then check
93
+ // EVERY returned address: if any resolves into a private/loopback/link-local
94
+ // range, reject the whole hostname. Strict — split-horizon DNS that returns
95
+ // public IPs at one moment and private at another can't slip past, because
96
+ // we only ever connect via this lookup's returned address (no TOCTOU). TLS
97
+ // SNI and certificate verification keep using the URL hostname downstream,
98
+ // so https://public.example still validates against its public cert.
99
+ export function makeSafeLookup(dnsLookup) {
100
+ return function safeLookup(hostname, opts, cb) {
101
+ const family = opts && typeof opts.family === "number" ? opts.family : 0;
102
+ Promise.resolve()
103
+ .then(() => dnsLookup(hostname, { all: true, family: 0 }))
104
+ .then((records) => {
105
+ if (!Array.isArray(records) || records.length === 0) {
106
+ cb(new Error(`SSRF guard: no DNS records for ${hostname}`));
107
+ return;
108
+ }
109
+ for (const r of records) {
110
+ const reason = checkResolvedIp(r.address, r.family);
111
+ if (reason) {
112
+ cb(new Error(`SSRF guard: ${hostname} resolves to ${r.address} (${reason})`));
113
+ return;
114
+ }
115
+ }
116
+ const picked =
117
+ family === 4 || family === 6
118
+ ? records.find((r) => r.family === family) || records[0]
119
+ : records[0];
120
+ cb(null, picked.address, picked.family);
121
+ })
122
+ .catch((err) => cb(err));
123
+ };
124
+ }
125
+
126
+ const ssrfSafeDispatcher = new Agent({
127
+ connect: { lookup: makeSafeLookup(dns.promises.lookup) },
128
+ });
129
+
130
+ // Block direct-IP requests to loopback, link-local (incl. 169.254.169.254
131
+ // cloud-metadata IPs), RFC1918 private space, IPv6 ULA/link-local. This is
132
+ // the cheap URL-form check that runs before any DNS — it rejects literal IPs
133
+ // (including IPv4-mapped/compat IPv6) without a round-trip.
134
+ //
135
+ // Hostnames pass through this URL-form check by design; the second layer is
136
+ // safeLookup() above, which undici invokes just before opening the socket.
137
+ // That layer closes the real attack surface here:
138
+ // 1. Static A record. evil.example with `A 127.0.0.1` (or 169.254.169.254,
139
+ // or an RFC1918 IP) — no DNS rebinding required, the agent just owns
140
+ // a domain.
141
+ // 2. Operator's resolver context. evenclaw deployments may resolve names
142
+ // like grafana.internal or vault to private services the operator
143
+ // didn't realize the plugin could reach.
144
+ // 3. Cloud-metadata uniformity. evil.example → 169.254.169.254 works on
145
+ // every AWS/GCP/Azure VM running OcuClaw.
146
+ // safeLookup rejects the hostname if ANY resolved IP is private — strict, so
147
+ // split-horizon DNS can't slip past either.
148
+ //
149
+ // Non-http(s) schemes (file:, gopher:, ftp:, data:) are rejected outright.
150
+ function isForbiddenHttpDestination(urlString) {
151
+ let parsed;
152
+ try {
153
+ parsed = new URL(urlString);
154
+ } catch (_) {
155
+ return "invalid url";
156
+ }
157
+ const proto = parsed.protocol;
158
+ if (proto !== "http:" && proto !== "https:") {
159
+ return `disallowed scheme: ${proto}`;
160
+ }
161
+ // Node's WHATWG URL.hostname may or may not strip the IPv6 brackets across
162
+ // versions; normalize by stripping them ourselves so the IPv6 checks below
163
+ // work regardless.
164
+ const rawHost = parsed.hostname.toLowerCase();
165
+ const host = rawHost.startsWith("[") && rawHost.endsWith("]")
166
+ ? rawHost.slice(1, -1)
167
+ : rawHost;
168
+ // Common loopback aliases.
169
+ if (host === "localhost" || host === "ip6-localhost" || host === "ip6-loopback") {
170
+ return "loopback hostname blocked";
171
+ }
172
+ // IPv4 dotted quad?
173
+ const v4 = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
174
+ if (v4) {
175
+ return checkIpv4Tuple(Number(v4[1]), Number(v4[2]));
176
+ }
177
+ // Bare hex IPv4 like 0x7f000001 — refuse anything that looks numeric.
178
+ if (/^[0-9a-f.x]+$/.test(host) && /^\d/.test(host) && !host.includes(":")) {
179
+ return "ambiguous numeric host blocked";
180
+ }
181
+ // IPv6? URL.hostname strips brackets but keeps colons.
182
+ if (host.includes(":")) {
183
+ if (host === "::" || host === "::1") return "IPv6 loopback/unspecified blocked";
184
+ // IPv4-mapped IPv6 (RFC 4291 §2.5.5.2): ::ffff:a.b.c.d (dotted) or
185
+ // ::ffff:XXXX:YYYY (hex). Also IPv4-compatible (deprecated): ::a.b.c.d.
186
+ // All of these resolve to an IPv4 destination — extract the embedded
187
+ // IPv4 and run it through the IPv4 rules so the bypass closes.
188
+ const mappedDotted = host.match(/^::(?:ffff:)?(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
189
+ if (mappedDotted) {
190
+ const reason = checkIpv4Tuple(Number(mappedDotted[1]), Number(mappedDotted[2]));
191
+ if (reason) return `IPv4-mapped IPv6 blocked (${reason})`;
192
+ return null;
193
+ }
194
+ const mappedHex = host.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
195
+ if (mappedHex) {
196
+ const high = parseInt(mappedHex[1], 16);
197
+ const a = (high >> 8) & 0xff;
198
+ const b = high & 0xff;
199
+ const reason = checkIpv4Tuple(a, b);
200
+ if (reason) return `IPv4-mapped IPv6 blocked (${reason})`;
201
+ return null;
202
+ }
203
+ // IPv4-compatible IPv6 (deprecated, RFC 4291 §2.5.5.1): ::XXXX:YYYY
204
+ // Node's URL parser normalizes ::a.b.c.d to this compressed-hex form, so
205
+ // the dotted regex above never fires for compat addresses — handle it
206
+ // here. Excludes the IPv4-mapped case (caught above) and the IPv6
207
+ // loopback ::1 (caught earlier as an exact match).
208
+ const compatHex = host.match(/^::([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
209
+ if (compatHex) {
210
+ const high = parseInt(compatHex[1], 16);
211
+ const a = (high >> 8) & 0xff;
212
+ const b = high & 0xff;
213
+ const reason = checkIpv4Tuple(a, b);
214
+ if (reason) return `IPv4-compatible IPv6 blocked (${reason})`;
215
+ return null;
216
+ }
217
+ // fe80::/10 spans fe80:: through febf:: — see comment in checkResolvedIp.
218
+ if (/^fe[89ab][0-9a-f]:/.test(host)) return "IPv6 link-local blocked";
219
+ if (/^f[cd][0-9a-f]{2}:/.test(host)) return "IPv6 ULA blocked";
220
+ return null;
221
+ }
222
+ return null;
223
+ }
224
+
225
+ function resolveJsonPath(value, jsonPath) {
226
+ if (!jsonPath || typeof jsonPath !== "string") return value;
227
+ // Minimal JSONPath: only "$.a.b.c" and "$.a[0].b" shapes.
228
+ const expr = jsonPath.trim();
229
+ if (!expr.startsWith("$")) return value;
230
+ const rest = expr.slice(1).replace(/\[(\d+)\]/g, ".$1");
231
+ const segments = rest.split(".").filter(Boolean);
232
+ let cursor = value;
233
+ for (const seg of segments) {
234
+ if (cursor === null || cursor === undefined) return undefined;
235
+ if (Array.isArray(cursor)) {
236
+ const idx = Number(seg);
237
+ cursor = Number.isInteger(idx) ? cursor[idx] : undefined;
238
+ } else if (typeof cursor === "object") {
239
+ cursor = cursor[seg];
240
+ } else {
241
+ return undefined;
242
+ }
243
+ }
244
+ return cursor;
245
+ }
246
+
247
+ export async function executeHttpRecipe(params, opts) {
248
+ const url = params && typeof params.url === "string" ? params.url : "";
249
+ if (!url) return { error: "http recipe missing url" };
250
+ // The SSRF guard is enabled by default for all production callers (the cron
251
+ // engine invokes executeHttpRecipe(recipe) with no second argument). Tests
252
+ // that spin up their own loopback HTTP server opt out via
253
+ // executeHttpRecipe(recipe, { allowPrivateNetworks: true }). The recipe
254
+ // schema deliberately does NOT include this flag, so agents can't bypass.
255
+ if (!(opts && opts.allowPrivateNetworks === true)) {
256
+ const forbidden = isForbiddenHttpDestination(url);
257
+ if (forbidden) {
258
+ return { error: `http recipe destination blocked: ${forbidden}` };
259
+ }
260
+ }
261
+ const method = params && typeof params.method === "string" ? params.method.toUpperCase() : "GET";
262
+ const headers = params && params.headers && typeof params.headers === "object" ? params.headers : {};
263
+ const body = method !== "GET" && method !== "HEAD" ? params && params.body : undefined;
264
+ const timeoutMs = Number.isFinite(params && params.timeoutMs)
265
+ ? params.timeoutMs
266
+ : DEFAULT_TIMEOUT_MS;
267
+ const outputCapBytes = Number.isFinite(params && params.outputCapBytes)
268
+ ? params.outputCapBytes
269
+ : DEFAULT_OUTPUT_CAP_BYTES;
270
+ const jsonPath = params && typeof params.jsonPath === "string" ? params.jsonPath : "";
271
+
272
+ const fetchFn = opts && typeof opts.fetch === "function" ? opts.fetch : fetch;
273
+ // The dispatcher carries the per-connection DNS lookup (safeLookup) that
274
+ // resolves hostnames and blocks any that point at private IPs — see the
275
+ // comment block on isForbiddenHttpDestination. Production callers default
276
+ // to the module-level ssrfSafeDispatcher; the test seam accepts an
277
+ // injected dispatcher (e.g. one built with a canned lookup) so the lookup
278
+ // check can be exercised without real DNS.
279
+ const dispatcher =
280
+ opts && opts.dispatcher !== undefined ? opts.dispatcher : ssrfSafeDispatcher;
281
+ const controller = new AbortController();
282
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
283
+ const allowPrivate = opts && opts.allowPrivateNetworks === true;
284
+ const MAX_REDIRECTS = 3;
285
+ try {
286
+ // Manual redirect handling — fetch's default redirect: "follow" lets a
287
+ // public attacker-controlled domain return a 302 → http://169.254.169.254/
288
+ // after the one-shot URL check. Validate every hop. Capped at 3 to
289
+ // bound effort and prevent loops.
290
+ let currentUrl = url;
291
+ let response = null;
292
+ let hop = 0;
293
+ while (true) {
294
+ const fetchInit = {
295
+ method,
296
+ headers,
297
+ body,
298
+ signal: controller.signal,
299
+ redirect: "manual",
300
+ };
301
+ // Only attach a dispatcher when it's defined and non-null. Injected
302
+ // tests that pass `dispatcher: null` explicitly opt out (e.g. to use
303
+ // a mock fetch with no networking at all).
304
+ if (dispatcher) fetchInit.dispatcher = dispatcher;
305
+ response = await fetchFn(currentUrl, fetchInit);
306
+ if (response.status >= 300 && response.status < 400) {
307
+ const location = response.headers.get("location");
308
+ if (!location) {
309
+ return { error: `http recipe got ${response.status} with no Location header` };
310
+ }
311
+ if (hop >= MAX_REDIRECTS) {
312
+ return { error: `http recipe exceeded ${MAX_REDIRECTS} redirects` };
313
+ }
314
+ let nextUrl;
315
+ try {
316
+ nextUrl = new URL(location, currentUrl).toString();
317
+ } catch (_) {
318
+ return { error: `http recipe got invalid redirect Location: ${location}` };
319
+ }
320
+ if (!allowPrivate) {
321
+ const forbidden = isForbiddenHttpDestination(nextUrl);
322
+ if (forbidden) {
323
+ return { error: `http recipe redirect destination blocked: ${forbidden}` };
324
+ }
325
+ }
326
+ // Drain the redirect body to free the socket before the next hop.
327
+ try { await response.arrayBuffer(); } catch (_) {}
328
+ currentUrl = nextUrl;
329
+ hop += 1;
330
+ continue;
331
+ }
332
+ break;
333
+ }
334
+ if (response.status < 200 || response.status >= 300) {
335
+ return { error: `http recipe got status ${response.status}` };
336
+ }
337
+ const reader = response.body && response.body.getReader ? response.body.getReader() : null;
338
+ let bytes = 0;
339
+ const chunks = [];
340
+ if (reader) {
341
+ while (true) {
342
+ const { done, value } = await reader.read();
343
+ if (done) break;
344
+ if (bytes + value.length > outputCapBytes) {
345
+ chunks.push(value.subarray(0, outputCapBytes - bytes));
346
+ bytes = outputCapBytes;
347
+ try { await reader.cancel(); } catch (_) {}
348
+ break;
349
+ }
350
+ chunks.push(value);
351
+ bytes += value.length;
352
+ }
353
+ } else {
354
+ const text = await response.text();
355
+ chunks.push(Buffer.from(text.slice(0, outputCapBytes), "utf8"));
356
+ }
357
+ const totalLength = chunks.reduce((sum, c) => sum + c.length, 0);
358
+ const merged = Buffer.alloc(totalLength);
359
+ let offset = 0;
360
+ for (const c of chunks) {
361
+ merged.set(c, offset);
362
+ offset += c.length;
363
+ }
364
+ const text = merged.toString("utf8");
365
+ const ct = response.headers.get("content-type") || "";
366
+ let parsed;
367
+ if (ct.toLowerCase().includes("json")) {
368
+ try { parsed = JSON.parse(text); } catch (_) { parsed = text; }
369
+ } else {
370
+ parsed = parseJsonIfPossible(text);
371
+ }
372
+ const extracted = jsonPath ? resolveJsonPath(parsed, jsonPath) : parsed;
373
+ return { output: extracted };
374
+ } catch (err) {
375
+ if (err && err.name === "AbortError") {
376
+ return { error: `http recipe timeout after ${timeoutMs}ms` };
377
+ }
378
+ // undici wraps connect-time errors (including our safeLookup rejections)
379
+ // as `TypeError: fetch failed` with the real reason on err.cause. Surface
380
+ // the cause so SSRF-guard rejections are visible to the operator and
381
+ // testable.
382
+ const msg = err && err.message ? err.message : String(err);
383
+ const causeMsg = err && err.cause && err.cause.message ? err.cause.message : "";
384
+ const full = causeMsg ? `${msg}: ${causeMsg}` : msg;
385
+ return { error: `http recipe error: ${full}` };
386
+ } finally {
387
+ clearTimeout(timer);
388
+ }
389
+ }
390
+
391
+ const DEFAULT_SYSTEM_PROMPT = (maxChars, previousBody) =>
392
+ `You are a tick worker producing a single short line of text for a head-mounted display surface. Reply with ONLY the new value to display, no preamble, no quotes, no JSON. Maximum ${maxChars} characters. Previous value: ${JSON.stringify(previousBody || "")}.`;
393
+
394
+ function stripModelProviderPrefix(modelRef) {
395
+ if (typeof modelRef !== "string") return "";
396
+ const idx = modelRef.indexOf("/");
397
+ return idx === -1 ? modelRef : modelRef.slice(idx + 1);
398
+ }
399
+
400
+ // Both CLI-spawn backends (codex-cli, claude-cli) were removed to eliminate
401
+ // the plugin's last child_process spawn — see the backends comment in
402
+ // config/runtime-config.ts for the rationale and the deferred native-delegation
403
+ // track. Operators who want those providers point an *-api backend at the
404
+ // provider endpoint (key resolved via the host modelAuth, tool-less).
405
+
406
+ async function runAnthropicApi(params, deps) {
407
+ const fetchFn = deps && deps.fetch ? deps.fetch : fetch;
408
+ if (!params.apiKey) return { error: "anthropic-api: missing api key" };
409
+ const model = stripModelProviderPrefix(params.model);
410
+ const max_tokens = Number.isFinite(params.maxOutputTokens) ? params.maxOutputTokens : 200;
411
+ const systemPrompt = params.systemPrompt || DEFAULT_SYSTEM_PROMPT(max_tokens * 4, params.previousBody);
412
+ const timeoutMs = Number.isFinite(params.timeoutMs) ? params.timeoutMs : 30_000;
413
+ const controller = new AbortController();
414
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
415
+ try {
416
+ const response = await fetchFn("https://api.anthropic.com/v1/messages", {
417
+ method: "POST",
418
+ headers: {
419
+ "x-api-key": params.apiKey,
420
+ "anthropic-version": "2023-06-01",
421
+ "Content-Type": "application/json",
422
+ },
423
+ body: JSON.stringify({
424
+ model,
425
+ max_tokens,
426
+ system: systemPrompt,
427
+ messages: [{ role: "user", content: params.prompt }],
428
+ }),
429
+ signal: controller.signal,
430
+ });
431
+ if (response.status < 200 || response.status >= 300) {
432
+ return { error: `anthropic-api status ${response.status}` };
433
+ }
434
+ const json = await response.json();
435
+ const text = Array.isArray(json.content)
436
+ ? json.content.filter((b) => b && b.type === "text").map((b) => b.text).join("")
437
+ : "";
438
+ return { output: text };
439
+ } catch (err) {
440
+ if (err && err.name === "AbortError") return { error: `anthropic-api timeout after ${timeoutMs}ms` };
441
+ return { error: `anthropic-api error: ${err && err.message ? err.message : String(err)}` };
442
+ } finally {
443
+ clearTimeout(timer);
444
+ }
445
+ }
446
+
447
+ async function runOpenAiCompat(params, deps) {
448
+ const fetchFn = deps && deps.fetch ? deps.fetch : fetch;
449
+ if (!params.apiKey) return { error: "openai-compat: missing api key" };
450
+ if (!params.baseUrl) return { error: "openai-compat: missing baseUrl" };
451
+ const model = stripModelProviderPrefix(params.model);
452
+ const max_tokens = Number.isFinite(params.maxOutputTokens) ? params.maxOutputTokens : 200;
453
+ const systemPrompt = params.systemPrompt || DEFAULT_SYSTEM_PROMPT(max_tokens * 4, params.previousBody);
454
+ const timeoutMs = Number.isFinite(params.timeoutMs) ? params.timeoutMs : 30_000;
455
+ const controller = new AbortController();
456
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
457
+ try {
458
+ const response = await fetchFn(`${params.baseUrl}/v1/chat/completions`, {
459
+ method: "POST",
460
+ headers: {
461
+ Authorization: `Bearer ${params.apiKey}`,
462
+ "Content-Type": "application/json",
463
+ },
464
+ body: JSON.stringify({
465
+ model,
466
+ max_tokens,
467
+ messages: [
468
+ { role: "system", content: systemPrompt },
469
+ { role: "user", content: params.prompt },
470
+ ],
471
+ }),
472
+ signal: controller.signal,
473
+ });
474
+ if (response.status < 200 || response.status >= 300) {
475
+ return { error: `openai-compat status ${response.status}` };
476
+ }
477
+ const json = await response.json();
478
+ const text =
479
+ json && json.choices && json.choices[0] && json.choices[0].message
480
+ ? json.choices[0].message.content || ""
481
+ : "";
482
+ return { output: text };
483
+ } catch (err) {
484
+ if (err && err.name === "AbortError") return { error: `openai-compat timeout after ${timeoutMs}ms` };
485
+ return { error: `openai-compat error: ${err && err.message ? err.message : String(err)}` };
486
+ } finally {
487
+ clearTimeout(timer);
488
+ }
489
+ }
490
+
491
+ export async function executeLlmRecipeWithDeps(recipe, ctx, deps) {
492
+ const backend = ctx && typeof ctx.backend === "string" ? ctx.backend : "";
493
+ const baseParams = {
494
+ prompt: recipe && typeof recipe.prompt === "string" ? recipe.prompt : "",
495
+ systemPrompt: recipe && typeof recipe.systemPrompt === "string" ? recipe.systemPrompt : undefined,
496
+ model: ctx && typeof ctx.model === "string" ? ctx.model : "",
497
+ maxOutputTokens:
498
+ ctx && Number.isFinite(ctx.maxOutputTokens) ? ctx.maxOutputTokens : undefined,
499
+ apiKey: ctx && typeof ctx.apiKey === "string" ? ctx.apiKey : "",
500
+ baseUrl: ctx && typeof ctx.baseUrl === "string" ? ctx.baseUrl : "",
501
+ previousBody: ctx && typeof ctx.previousBody === "string" ? ctx.previousBody : "",
502
+ timeoutMs: ctx && Number.isFinite(ctx.timeoutMs) ? ctx.timeoutMs : undefined,
503
+ };
504
+ if (!baseParams.prompt) return { error: "llm recipe missing prompt" };
505
+ switch (backend) {
506
+ case "anthropic-api":
507
+ return runAnthropicApi(baseParams, deps || {});
508
+ case "openai-compat":
509
+ return runOpenAiCompat(baseParams, deps || {});
510
+ default:
511
+ return { error: `unknown backend: ${JSON.stringify(backend)}` };
512
+ }
513
+ }
514
+
515
+ export function executeLlmRecipe(recipe, ctx) {
516
+ return executeLlmRecipeWithDeps(recipe, ctx, {});
517
+ }
518
+
519
+ // L0' tier: host RAM/CPU via in-process node:os reads. No shell (a sandboxed
520
+ // `free -m` would report the container's view, not the host's). CPU% is a delta
521
+ // over a short window — os.loadavg() is run-queue length, not utilization. Deps
522
+ // (totalmem/freemem/loadavg/cpus/sleep) are injectable for deterministic tests.
523
+ export function computeCpuPct(t0, t1) {
524
+ let idleDelta = 0;
525
+ let totalDelta = 0;
526
+ const n = Math.min(Array.isArray(t0) ? t0.length : 0, Array.isArray(t1) ? t1.length : 0);
527
+ for (let i = 0; i < n; i += 1) {
528
+ const a = (t0[i] && t0[i].times) || {};
529
+ const b = (t1[i] && t1[i].times) || {};
530
+ const totA = (a.user || 0) + (a.nice || 0) + (a.sys || 0) + (a.idle || 0) + (a.irq || 0);
531
+ const totB = (b.user || 0) + (b.nice || 0) + (b.sys || 0) + (b.idle || 0) + (b.irq || 0);
532
+ idleDelta += (b.idle || 0) - (a.idle || 0);
533
+ totalDelta += totB - totA;
534
+ }
535
+ if (totalDelta <= 0) return 0;
536
+ return Math.max(0, Math.min(100, 100 * (1 - idleDelta / totalDelta)));
537
+ }
538
+
539
+ export async function executeSystemStatsRecipe(params, opts) {
540
+ const o = opts || {};
541
+ const totalmemFn = typeof o.totalmem === "function" ? o.totalmem : totalmem;
542
+ const freememFn = typeof o.freemem === "function" ? o.freemem : freemem;
543
+ const loadavgFn = typeof o.loadavg === "function" ? o.loadavg : loadavg;
544
+ const cpusFn = typeof o.cpus === "function" ? o.cpus : cpus;
545
+ const sleep =
546
+ typeof o.sleep === "function" ? o.sleep : (ms) => new Promise((r) => setTimeout(r, ms));
547
+ const requested =
548
+ params && Number.isFinite(params.sampleWindowMs)
549
+ ? params.sampleWindowMs
550
+ : SYSTEM_STATS_WINDOW_DEFAULT_MS;
551
+ const windowMs = Math.max(
552
+ SYSTEM_STATS_WINDOW_MIN_MS,
553
+ Math.min(SYSTEM_STATS_WINDOW_MAX_MS, Math.floor(requested)),
554
+ );
555
+ try {
556
+ const t0 = cpusFn();
557
+ await sleep(windowMs);
558
+ const t1 = cpusFn();
559
+ const cpuPct = computeCpuPct(t0, t1);
560
+ const total = totalmemFn();
561
+ const free = freememFn();
562
+ const used = total - free;
563
+ const load = loadavgFn();
564
+ const toMb = (b) => Math.round(b / (1024 * 1024));
565
+ const round1 = (x) => Math.round(x * 10) / 10;
566
+ return {
567
+ output: {
568
+ memTotalMb: toMb(total),
569
+ memUsedMb: toMb(used),
570
+ memFreeMb: toMb(free),
571
+ memUsedPct: total > 0 ? round1((used / total) * 100) : 0,
572
+ cpuPct: round1(cpuPct),
573
+ loadAvg1: Array.isArray(load) && load.length > 0 ? Math.round(load[0] * 100) / 100 : 0,
574
+ },
575
+ };
576
+ } catch (err) {
577
+ return { error: `system-stats read failed: ${err && err.message ? err.message : err}` };
578
+ }
579
+ }
580
+
581
+ export default { executeHttpRecipe, executeLlmRecipe, executeLlmRecipeWithDeps, executeSystemStatsRecipe, computeCpuPct };