ocuclaw 1.3.0 → 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.
- package/README.md +3 -1
- package/dist/config/runtime-config.js +17 -14
- package/dist/domain/debug-store.js +18 -0
- package/dist/runtime/plugin-version-service.js +23 -0
- package/dist/runtime/relay-core.js +98 -35
- package/dist/runtime/relay-worker-protocol.js +0 -4
- package/dist/runtime/relay-worker-supervisor.js +0 -77
- package/dist/runtime/session-service.js +23 -3
- package/dist/tools/glasses-ui-recipes.js +13 -178
- package/dist/tools/glasses-ui-tool.js +10 -46
- package/dist/version.js +2 -2
- package/package.json +4 -3
- package/skills/glasses-ui/SKILL.md +156 -0
- package/dist/runtime/downstream-server.js +0 -2057
- package/dist/runtime/plugin-update-service.js +0 -216
|
@@ -1,53 +1,17 @@
|
|
|
1
|
-
// Recipe executors for glasses_ui_refresh.
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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.
|
|
4
7
|
//
|
|
5
8
|
// output is `string` for plain text and `object` for JSON. The cron engine
|
|
6
9
|
// hands `output` to the template engine which handles both shapes.
|
|
7
10
|
|
|
8
|
-
import {
|
|
9
|
-
import { tmpdir, totalmem, freemem, loadavg, cpus } from "node:os";
|
|
11
|
+
import { totalmem, freemem, loadavg, cpus } from "node:os";
|
|
10
12
|
import * as dns from "node:dns";
|
|
11
13
|
import { Agent } from "undici";
|
|
12
14
|
|
|
13
|
-
// Env vars allowed to cross into the spawned Claude CLI subprocess.
|
|
14
|
-
// Everything else is stripped — Claude reads its own auth from ~/.claude/
|
|
15
|
-
// (reachable via HOME), so cloud-provider keys (AWS_*, GOOGLE_*,
|
|
16
|
-
// ANTHROPIC_API_KEY, OPENAI_API_KEY, *_SECRET, *_TOKEN) and database URLs
|
|
17
|
-
// must never reach the agent the CLI is running. Operators who want an
|
|
18
|
-
// API key in the LLM-tick path use the *-api backends, which read the key
|
|
19
|
-
// via the host's modelAuth resolver — not from spawn env.
|
|
20
|
-
const CLI_SPAWN_ENV_ALLOWLIST = [
|
|
21
|
-
"PATH",
|
|
22
|
-
"HOME",
|
|
23
|
-
"USER",
|
|
24
|
-
"LOGNAME",
|
|
25
|
-
"SHELL",
|
|
26
|
-
"TERM",
|
|
27
|
-
"LANG",
|
|
28
|
-
"TZ",
|
|
29
|
-
"XDG_CONFIG_HOME",
|
|
30
|
-
"XDG_CACHE_HOME",
|
|
31
|
-
"XDG_DATA_HOME",
|
|
32
|
-
"XDG_RUNTIME_DIR",
|
|
33
|
-
"NODE_OPTIONS",
|
|
34
|
-
];
|
|
35
|
-
const CLI_SPAWN_ENV_ALLOWLIST_PREFIXES = ["LC_"];
|
|
36
|
-
|
|
37
|
-
function buildScopedSpawnEnv() {
|
|
38
|
-
const sourceEnv = process.env || {};
|
|
39
|
-
const out = {};
|
|
40
|
-
for (const k of CLI_SPAWN_ENV_ALLOWLIST) {
|
|
41
|
-
if (typeof sourceEnv[k] === "string") out[k] = sourceEnv[k];
|
|
42
|
-
}
|
|
43
|
-
for (const k of Object.keys(sourceEnv)) {
|
|
44
|
-
if (CLI_SPAWN_ENV_ALLOWLIST_PREFIXES.some((p) => k.startsWith(p))) {
|
|
45
|
-
out[k] = sourceEnv[k];
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
return out;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
15
|
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
52
16
|
const DEFAULT_OUTPUT_CAP_BYTES = 64 * 1024;
|
|
53
17
|
|
|
@@ -71,77 +35,6 @@ function parseJsonIfPossible(text) {
|
|
|
71
35
|
}
|
|
72
36
|
}
|
|
73
37
|
|
|
74
|
-
export async function executeShellRecipe(params) {
|
|
75
|
-
const command = params && typeof params.command === "string" ? params.command : "";
|
|
76
|
-
if (!command) {
|
|
77
|
-
return { error: "shell recipe missing command" };
|
|
78
|
-
}
|
|
79
|
-
const timeoutMs = Number.isFinite(params && params.timeoutMs)
|
|
80
|
-
? params.timeoutMs
|
|
81
|
-
: DEFAULT_TIMEOUT_MS;
|
|
82
|
-
const outputCapBytes = Number.isFinite(params && params.outputCapBytes)
|
|
83
|
-
? params.outputCapBytes
|
|
84
|
-
: DEFAULT_OUTPUT_CAP_BYTES;
|
|
85
|
-
|
|
86
|
-
return new Promise((resolve) => {
|
|
87
|
-
const child = spawn("bash", ["-c", command], {
|
|
88
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
89
|
-
});
|
|
90
|
-
const chunks = [];
|
|
91
|
-
const errChunks = [];
|
|
92
|
-
let bytes = 0;
|
|
93
|
-
let truncated = false;
|
|
94
|
-
let done = false;
|
|
95
|
-
|
|
96
|
-
const finish = (result) => {
|
|
97
|
-
if (done) return;
|
|
98
|
-
done = true;
|
|
99
|
-
clearTimeout(timer);
|
|
100
|
-
try { child.kill("SIGKILL"); } catch (_) { /* already exited */ }
|
|
101
|
-
resolve(result);
|
|
102
|
-
};
|
|
103
|
-
|
|
104
|
-
const timer = setTimeout(() => {
|
|
105
|
-
finish({ error: `shell recipe timeout after ${timeoutMs}ms` });
|
|
106
|
-
}, timeoutMs);
|
|
107
|
-
|
|
108
|
-
child.stdout.on("data", (chunk) => {
|
|
109
|
-
if (bytes + chunk.length > outputCapBytes) {
|
|
110
|
-
const remaining = outputCapBytes - bytes;
|
|
111
|
-
if (remaining > 0) chunks.push(chunk.slice(0, remaining));
|
|
112
|
-
bytes = outputCapBytes;
|
|
113
|
-
truncated = true;
|
|
114
|
-
try { child.kill("SIGTERM"); } catch (_) {}
|
|
115
|
-
} else {
|
|
116
|
-
chunks.push(chunk);
|
|
117
|
-
bytes += chunk.length;
|
|
118
|
-
}
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
child.stderr.on("data", (chunk) => {
|
|
122
|
-
if (errChunks.length < 32) errChunks.push(chunk);
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
child.on("error", (err) => {
|
|
126
|
-
finish({ error: `shell recipe spawn error: ${err && err.message ? err.message : err}` });
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
child.on("close", (code, signal) => {
|
|
130
|
-
const stdout = Buffer.concat(chunks).toString("utf8");
|
|
131
|
-
if (code !== 0 && !truncated) {
|
|
132
|
-
const stderr = Buffer.concat(errChunks).toString("utf8").trim();
|
|
133
|
-
finish({
|
|
134
|
-
error: signal
|
|
135
|
-
? `shell recipe killed by ${signal}`
|
|
136
|
-
: `shell recipe exit code ${code}${stderr ? ": " + stderr.slice(0, 200) : ""}`,
|
|
137
|
-
});
|
|
138
|
-
return;
|
|
139
|
-
}
|
|
140
|
-
finish({ output: parseJsonIfPossible(stdout) });
|
|
141
|
-
});
|
|
142
|
-
});
|
|
143
|
-
}
|
|
144
|
-
|
|
145
38
|
function checkIpv4Tuple(a, b) {
|
|
146
39
|
if (a === 127) return "loopback IPv4 blocked";
|
|
147
40
|
if (a === 10) return "RFC1918 IPv4 blocked";
|
|
@@ -504,67 +397,11 @@ function stripModelProviderPrefix(modelRef) {
|
|
|
504
397
|
return idx === -1 ? modelRef : modelRef.slice(idx + 1);
|
|
505
398
|
}
|
|
506
399
|
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
// Lock the spawned Claude CLI to plan-mode with an empty toolset so the
|
|
513
|
-
// tick prompt can't drive file/shell/web tools to exfil. --bare disables
|
|
514
|
-
// hooks, plugin sync, CLAUDE.md auto-discovery, and keychain reads — the
|
|
515
|
-
// CLI runs as a pure text-in/text-out worker.
|
|
516
|
-
const args = [
|
|
517
|
-
"-p", promptText,
|
|
518
|
-
"--output-format", "text",
|
|
519
|
-
"--permission-mode", "plan",
|
|
520
|
-
"--tools", "",
|
|
521
|
-
"--bare",
|
|
522
|
-
];
|
|
523
|
-
if (model) args.push("--model", model);
|
|
524
|
-
const timeoutMs = Number.isFinite(params.timeoutMs) ? params.timeoutMs : 60_000;
|
|
525
|
-
|
|
526
|
-
return new Promise((resolve) => {
|
|
527
|
-
const child = spawnFn("claude", args, {
|
|
528
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
529
|
-
cwd: tmpdir(),
|
|
530
|
-
env: buildScopedSpawnEnv(),
|
|
531
|
-
});
|
|
532
|
-
const chunks = [];
|
|
533
|
-
const errChunks = [];
|
|
534
|
-
let done = false;
|
|
535
|
-
const finish = (result) => {
|
|
536
|
-
if (done) return;
|
|
537
|
-
done = true;
|
|
538
|
-
clearTimeout(timer);
|
|
539
|
-
try { child.kill && child.kill("SIGKILL"); } catch (_) {}
|
|
540
|
-
resolve(result);
|
|
541
|
-
};
|
|
542
|
-
const timer = setTimeout(
|
|
543
|
-
() => finish({ error: `claude-cli timeout after ${timeoutMs}ms` }),
|
|
544
|
-
timeoutMs,
|
|
545
|
-
);
|
|
546
|
-
child.stdout.on("data", (c) => chunks.push(c));
|
|
547
|
-
child.stderr.on("data", (c) => errChunks.push(c));
|
|
548
|
-
child.on("error", (err) => finish({ error: `claude-cli spawn error: ${err.message}` }));
|
|
549
|
-
child.on("close", (code) => {
|
|
550
|
-
if (code !== 0) {
|
|
551
|
-
const stderr = Buffer.concat(errChunks).toString("utf8").trim();
|
|
552
|
-
finish({ error: `claude-cli exit code ${code}${stderr ? ": " + stderr.slice(0, 200) : ""}` });
|
|
553
|
-
return;
|
|
554
|
-
}
|
|
555
|
-
finish({ output: Buffer.concat(chunks).toString("utf8") });
|
|
556
|
-
});
|
|
557
|
-
});
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
// codex-cli backend removed (round-5 autoreview): Codex's `read-only`
|
|
561
|
-
// sandbox blocks writes and exec but still permits filesystem reads, so
|
|
562
|
-
// an agent prompt could drive the spawned process to read ~/.aws/credentials,
|
|
563
|
-
// ~/.ssh/*, etc. and emit them through stdout → glasses body. Claude CLI
|
|
564
|
-
// is structurally safe because --tools "" disables every tool including
|
|
565
|
-
// Read; Codex has no equivalent flag. Operators who want Codex point an
|
|
566
|
-
// openai-compat backend at https://api.openai.com (or wherever Codex is
|
|
567
|
-
// served) — that path has no tool surface at all.
|
|
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).
|
|
568
405
|
|
|
569
406
|
async function runAnthropicApi(params, deps) {
|
|
570
407
|
const fetchFn = deps && deps.fetch ? deps.fetch : fetch;
|
|
@@ -666,8 +503,6 @@ export async function executeLlmRecipeWithDeps(recipe, ctx, deps) {
|
|
|
666
503
|
};
|
|
667
504
|
if (!baseParams.prompt) return { error: "llm recipe missing prompt" };
|
|
668
505
|
switch (backend) {
|
|
669
|
-
case "claude-cli":
|
|
670
|
-
return runClaudeCli(baseParams, deps || {});
|
|
671
506
|
case "anthropic-api":
|
|
672
507
|
return runAnthropicApi(baseParams, deps || {});
|
|
673
508
|
case "openai-compat":
|
|
@@ -743,4 +578,4 @@ export async function executeSystemStatsRecipe(params, opts) {
|
|
|
743
578
|
}
|
|
744
579
|
}
|
|
745
580
|
|
|
746
|
-
export default {
|
|
581
|
+
export default { executeHttpRecipe, executeLlmRecipe, executeLlmRecipeWithDeps, executeSystemStatsRecipe, computeCpuPct };
|
|
@@ -7,7 +7,6 @@
|
|
|
7
7
|
import { validateTemplate } from "./glasses-ui-template.js";
|
|
8
8
|
import { createGlassesUiCronEngine } from "./glasses-ui-cron.js";
|
|
9
9
|
import {
|
|
10
|
-
executeShellRecipe,
|
|
11
10
|
executeHttpRecipe,
|
|
12
11
|
executeLlmRecipe,
|
|
13
12
|
executeSystemStatsRecipe,
|
|
@@ -32,7 +31,7 @@ import {
|
|
|
32
31
|
export { createPendingRenderMap, createSurfaceStore, GLASSES_UI_LIMITS };
|
|
33
32
|
|
|
34
33
|
export const GLASSES_UI_REFRESH_LIMITS = {
|
|
35
|
-
intervalMsMin: {
|
|
34
|
+
intervalMsMin: { http: 1000, "system-stats": 1000, "llm-api": 30_000 },
|
|
36
35
|
intervalMsMax: 3_600_000,
|
|
37
36
|
maxDurationMsMin: 10_000,
|
|
38
37
|
maxDurationMsMax: 7_200_000,
|
|
@@ -63,13 +62,6 @@ export const GLASSES_UI_REFRESH_LIMITS = {
|
|
|
63
62
|
|
|
64
63
|
const ON_ERROR_VALUES = new Set(["keep_last", "show_error", "stop"]);
|
|
65
64
|
|
|
66
|
-
function llmIntervalMinForBackend(backend) {
|
|
67
|
-
if (backend === "claude-cli") {
|
|
68
|
-
return GLASSES_UI_REFRESH_LIMITS.intervalMsMin["llm-cli"];
|
|
69
|
-
}
|
|
70
|
-
return GLASSES_UI_REFRESH_LIMITS.intervalMsMin["llm-api"];
|
|
71
|
-
}
|
|
72
|
-
|
|
73
65
|
// The effective per-tick interval floor is the larger of the tier minimum and
|
|
74
66
|
// the paint-floor coalescer's cadence (Spike D, 150ms) — no tick may schedule
|
|
75
67
|
// faster than the glass can paint. Today every tier min already exceeds 150ms,
|
|
@@ -92,8 +84,8 @@ export function validateRefreshSpec(refresh, glassesUiLiveCfg) {
|
|
|
92
84
|
return { ok: false, code: "refresh_invalid_recipe", message: "refresh.recipe is required" };
|
|
93
85
|
}
|
|
94
86
|
const kind = recipe.kind;
|
|
95
|
-
if (kind !== "
|
|
96
|
-
return { ok: false, code: "refresh_invalid_recipe", message: `recipe.kind must be
|
|
87
|
+
if (kind !== "http" && kind !== "llm" && kind !== "system-stats") {
|
|
88
|
+
return { ok: false, code: "refresh_invalid_recipe", message: `recipe.kind must be http/llm/system-stats, got ${JSON.stringify(kind)}` };
|
|
97
89
|
}
|
|
98
90
|
// Sanitize the recipe — clamp/reject agent-supplied timeoutMs / outputCapBytes
|
|
99
91
|
// / maxOutputTokens to declared bounds, copy known fields only. The returned
|
|
@@ -105,23 +97,7 @@ export function validateRefreshSpec(refresh, glassesUiLiveCfg) {
|
|
|
105
97
|
if (raw < min || raw > max) return undefined; // signal out-of-range
|
|
106
98
|
return Math.floor(raw);
|
|
107
99
|
};
|
|
108
|
-
if (kind === "
|
|
109
|
-
if (cfg.shellEnabled === false) return { ok: false, code: "refresh_disabled", message: "shell recipes disabled" };
|
|
110
|
-
if (typeof recipe.command !== "string" || !recipe.command.trim()) {
|
|
111
|
-
return { ok: false, code: "refresh_invalid_recipe", message: "shell recipe requires command (non-empty string)" };
|
|
112
|
-
}
|
|
113
|
-
sanitizedRecipe.command = recipe.command;
|
|
114
|
-
if (recipe.timeoutMs !== undefined) {
|
|
115
|
-
const v = bounded(recipe.timeoutMs, GLASSES_UI_REFRESH_LIMITS.shellHttpTimeoutMsMin, GLASSES_UI_REFRESH_LIMITS.shellHttpTimeoutMsMax);
|
|
116
|
-
if (v === undefined) return { ok: false, code: "refresh_invalid_recipe", message: `shell.timeoutMs ${recipe.timeoutMs} out of bounds [${GLASSES_UI_REFRESH_LIMITS.shellHttpTimeoutMsMin}..${GLASSES_UI_REFRESH_LIMITS.shellHttpTimeoutMsMax}]` };
|
|
117
|
-
if (v !== null) sanitizedRecipe.timeoutMs = v;
|
|
118
|
-
}
|
|
119
|
-
if (recipe.outputCapBytes !== undefined) {
|
|
120
|
-
const v = bounded(recipe.outputCapBytes, GLASSES_UI_REFRESH_LIMITS.outputCapBytesMin, GLASSES_UI_REFRESH_LIMITS.outputCapBytesMax);
|
|
121
|
-
if (v === undefined) return { ok: false, code: "refresh_invalid_recipe", message: `shell.outputCapBytes ${recipe.outputCapBytes} out of bounds` };
|
|
122
|
-
if (v !== null) sanitizedRecipe.outputCapBytes = v;
|
|
123
|
-
}
|
|
124
|
-
} else if (kind === "http") {
|
|
100
|
+
if (kind === "http") {
|
|
125
101
|
if (cfg.httpEnabled === false) return { ok: false, code: "refresh_disabled", message: "http recipes disabled" };
|
|
126
102
|
if (typeof recipe.url !== "string" || !recipe.url.trim()) {
|
|
127
103
|
return { ok: false, code: "refresh_invalid_recipe", message: "http recipe requires url (non-empty string)" };
|
|
@@ -159,7 +135,7 @@ export function validateRefreshSpec(refresh, glassesUiLiveCfg) {
|
|
|
159
135
|
}
|
|
160
136
|
} else if (kind === "system-stats") {
|
|
161
137
|
// Built-in tier: host RAM/CPU via the in-process structured reader. NOT gated
|
|
162
|
-
// by httpEnabled/
|
|
138
|
+
// by httpEnabled/llmEnabled — it touches no network, no shell, no
|
|
163
139
|
// model. Only the master `enabled` switch (checked above) governs it. Do NOT
|
|
164
140
|
// add a cfg.*Enabled gate here (intentional — Phase 3 design).
|
|
165
141
|
if (recipe.sampleWindowMs !== undefined) {
|
|
@@ -176,7 +152,7 @@ export function validateRefreshSpec(refresh, glassesUiLiveCfg) {
|
|
|
176
152
|
}
|
|
177
153
|
const minForKind =
|
|
178
154
|
kind === "llm"
|
|
179
|
-
?
|
|
155
|
+
? GLASSES_UI_REFRESH_LIMITS.intervalMsMin["llm-api"]
|
|
180
156
|
: GLASSES_UI_REFRESH_LIMITS.intervalMsMin[kind];
|
|
181
157
|
const minEffective = effectiveIntervalFloorMs(minForKind);
|
|
182
158
|
if (intervalMs < minEffective) {
|
|
@@ -307,16 +283,6 @@ const refreshSchemaForToolParams = {
|
|
|
307
283
|
},
|
|
308
284
|
recipe: {
|
|
309
285
|
oneOf: [
|
|
310
|
-
{
|
|
311
|
-
type: "object",
|
|
312
|
-
required: ["kind", "command"],
|
|
313
|
-
properties: {
|
|
314
|
-
kind: { const: "shell" },
|
|
315
|
-
command: { type: "string" },
|
|
316
|
-
timeoutMs: { type: "integer" },
|
|
317
|
-
outputCapBytes: { type: "integer" },
|
|
318
|
-
},
|
|
319
|
-
},
|
|
320
286
|
{
|
|
321
287
|
type: "object",
|
|
322
288
|
required: ["kind", "url"],
|
|
@@ -536,7 +502,6 @@ export function createGlassesUiToolHandler(deps) {
|
|
|
536
502
|
emitLifecycle,
|
|
537
503
|
monotonicNowMs: () => performance.now(),
|
|
538
504
|
executeRecipe: async (recipe, ctx) => {
|
|
539
|
-
if (recipe.kind === "shell") return executeShellRecipe(recipe);
|
|
540
505
|
if (recipe.kind === "http") return executeHttpRecipe(recipe);
|
|
541
506
|
if (recipe.kind === "system-stats") return executeSystemStatsRecipe(recipe);
|
|
542
507
|
if (recipe.kind === "llm") return executeLlmRecipe(recipe, ctx);
|
|
@@ -555,7 +520,7 @@ export function createGlassesUiToolHandler(deps) {
|
|
|
555
520
|
? Math.min(state.recipe.maxOutputTokens, cfg.tickMaxOutputTokens || 200)
|
|
556
521
|
: (cfg.tickMaxOutputTokens || 200);
|
|
557
522
|
return {
|
|
558
|
-
backend: cfg.tickBackend || "
|
|
523
|
+
backend: cfg.tickBackend || "anthropic-api",
|
|
559
524
|
model,
|
|
560
525
|
baseUrl: cfg.tickApiBaseUrl || "",
|
|
561
526
|
apiKey: deps.resolveLlmApiKey ? deps.resolveLlmApiKey(model) : "",
|
|
@@ -697,10 +662,9 @@ export function createGlassesUiToolHandler(deps) {
|
|
|
697
662
|
// content when this render carries a refresh.
|
|
698
663
|
if (refreshValidated && !(update === "patch" && cronEngine.isActive(surfaceId))) {
|
|
699
664
|
// Pre-warm the LLM API key cache so tick 1 doesn't see an empty key
|
|
700
|
-
// and fail the smoke test. For non-LLM recipes this is a no-op.
|
|
701
|
-
//
|
|
702
|
-
//
|
|
703
|
-
// no-op there.
|
|
665
|
+
// and fail the smoke test. For non-LLM recipes this is a no-op. All llm
|
|
666
|
+
// backends are HTTP API backends that resolve a key via host modelAuth;
|
|
667
|
+
// a missing key degrades to a graceful recipe_failed on tick 1.
|
|
704
668
|
if (refreshValidated.recipe.kind === "llm" && typeof deps.prewarmLlmApiKey === "function") {
|
|
705
669
|
const cfg = deps.getGlassesUiLiveConfig ? deps.getGlassesUiLiveConfig() : {};
|
|
706
670
|
const agentModel =
|
package/dist/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export const PLUGIN_VERSION = "1.3.
|
|
2
|
-
export const REQUIRES_CLIENT_VERSION = "1.3.
|
|
1
|
+
export const PLUGIN_VERSION = "1.3.1";
|
|
2
|
+
export const REQUIRES_CLIENT_VERSION = "1.3.1";
|
package/package.json
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ocuclaw",
|
|
3
|
-
"version": "1.3.
|
|
4
|
-
"requiresClientVersion": "1.3.
|
|
3
|
+
"version": "1.3.1",
|
|
4
|
+
"requiresClientVersion": "1.3.1",
|
|
5
5
|
"description": "OcuClaw for Even Realities G2 smart glasses.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "./dist/index.js",
|
|
8
8
|
"files": [
|
|
9
9
|
"README.md",
|
|
10
10
|
"dist/",
|
|
11
|
-
"openclaw.plugin.json"
|
|
11
|
+
"openclaw.plugin.json",
|
|
12
|
+
"skills/"
|
|
12
13
|
],
|
|
13
14
|
"keywords": [
|
|
14
15
|
"even",
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: glasses-ui
|
|
3
|
+
description: Authoring guide for render_glasses_ui surfaces on Even G2 glasses. Load BEFORE building any live/refreshing surface, per-item detail list, or multi-screen flow — covers the capability-tier ladder (system-stats host metrics, http data), the patch/replace/push moves and exit-to-chat policy, recipe recon, per-item {label,body} templates, and worked examples.
|
|
4
|
+
user-invocable: false
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Authoring glasses surfaces with `render_glasses_ui`
|
|
8
|
+
|
|
9
|
+
`render_glasses_ui` paints an interactive surface on the user's Even G2 HUD instead of a text reply. The call blocks until the user selects, dismisses, or backs out. This skill is the source of truth for **authoring** surfaces; the tool description is deliberately lean.
|
|
10
|
+
|
|
11
|
+
## Surface kinds
|
|
12
|
+
|
|
13
|
+
| kind | use it for | caps |
|
|
14
|
+
|---|---|---|
|
|
15
|
+
| `text_surface` | one formatted read-only block | body ≤ 1000 chars; optional `title` ≤ 64 |
|
|
16
|
+
| `list_surface` | a short pickable list, label-only | ≤ 20 items × ≤ 64 chars |
|
|
17
|
+
| `list_with_details_surface` | a pickable list where each item carries a detail body shown as the user scrolls | label ≤ 64, body ≤ 200; total of all bodies ≤ 6144 |
|
|
18
|
+
|
|
19
|
+
`list_with_details` items are `{ label, body }` objects (`label` required, `body` optional). A bare string is treated as a label-only item. Use it when each option needs a 1–2 sentence compare-before-choosing detail — all bodies ship in one call, so the user browses with zero round-trips.
|
|
20
|
+
|
|
21
|
+
## Capability tiers — pick the lowest tier that answers the need
|
|
22
|
+
|
|
23
|
+
The refresh recipe runs at a capability tier. **Always pick the lowest tier that answers the need.**
|
|
24
|
+
|
|
25
|
+
- **L0 — `http` (data APIs).** In-process, SSRF-guarded fetch → template → surface. For pure data: scores, status APIs, quotes, weather. **Not currently enableable on this install:** `http` is gated by `glassesUiLive.httpEnabled`, and that opt-in isn't reachable yet (the plugin's config-schema omits the `glassesUiLive` block — tracked as a separate fix). Until that lands, an `http` refresh is **rejected (refresh disabled)** — do not author one expecting it to run. It is documented here so you know the tier exists and is the right pick once enabled.
|
|
26
|
+
- **L0′ — `system-stats` (host metrics).** Built-in, in-process `node:os` reader (RAM/CPU/load). **No operator gate** — governed only by the master `glassesUiLive.enabled` (on by default). **This is the only refresh tier that runs without operator config today.** Reach for it for anything host-metric.
|
|
27
|
+
- **Reasoning / judgment tier (`agent`, L1/L2) — designed, not yet available.** A future tier where a sandboxed subagent interprets a page or local files on a timer. It is design-gated (credential-isolation + containment spike) and **not built** — there is no `agent` recipe kind. For a surface that needs interpretation *right now*, render it **once from your own turn** (compose the content yourself, render a static `text_surface`/list). A *self-refreshing* reasoning surface needs an agent in the loop, which isn't wired yet; a live reasoning tier is coming.
|
|
28
|
+
|
|
29
|
+
### `system-stats` output fields (the `{{path}}` sources)
|
|
30
|
+
|
|
31
|
+
`memTotalMb`, `memUsedMb`, `memFreeMb`, `memUsedPct`, `cpuPct`, `loadAvg1`. Optional recipe param `sampleWindowMs` (50–1000, default 200) sizes the CPU-sample window.
|
|
32
|
+
|
|
33
|
+
## The four moves
|
|
34
|
+
|
|
35
|
+
`render_glasses_ui` takes an optional `update` telling it how this render relates to the current surface. **Default is `replace`.**
|
|
36
|
+
|
|
37
|
+
| move | what it does | when |
|
|
38
|
+
|---|---|---|
|
|
39
|
+
| `patch` | edit *some fields* of the current screen; the refresh cron **keeps ticking** | a partial in-place edit |
|
|
40
|
+
| `replace` *(default)* | swap the *whole content* of the current screen in place; **no back-target** | new content, no going back |
|
|
41
|
+
| `push` | stack a *new child screen*; the parent is retained and **its cron pauses** (resumes on Back) | new content, the user can go back |
|
|
42
|
+
| exit to chat | *not a param* — just end your turn with a short text reply; the chat screen takes over and the surface disappears | you're done; respond in chat |
|
|
43
|
+
|
|
44
|
+
**One-line rule:** partial edit → `patch`; new content, no going back → `replace`; new content, can go back → `push`; done → exit to chat.
|
|
45
|
+
|
|
46
|
+
**Exit-to-chat policy:** on a *deliberate* exit (the user backs past the root, or you're finished), the default is to **respond in chat and not reflexively re-surface**. You retain the capability to surface again later in the conversation — just don't bounce a new surface up reflexively. On an *in-stack* Back (depth ≥ 2), the client transparently restores the parent and its cron resumes; you don't need to re-render it.
|
|
47
|
+
|
|
48
|
+
> **Don't preempt yourself.** Omitting `update` means `replace` — it swaps your current surface in place. To drill into a detail *without losing the list*, use `push`. (Back restores the parent **surface** and re-fires its cron; it does not restore list scroll position.)
|
|
49
|
+
|
|
50
|
+
## Live refresh: recipe recon (validate-then-commit)
|
|
51
|
+
|
|
52
|
+
Add a `refresh` block to make a surface self-update: `{ recipe, intervalMs, targets, onError?, maxDurationMs?, maxConsecutiveFailures? }`. `intervalMs` ≥ 1000.
|
|
53
|
+
|
|
54
|
+
When you submit a render carrying `refresh`, the plugin runs an **initial smoke-test tick before the surface commits**. If that tick fails, the call resolves with `result: "recipe_failed"` and a `failureReason` string — **read it, fix the recipe, and retry**; the surface and cron only start on success. This is your recon loop: don't guess twice, read the failure.
|
|
55
|
+
|
|
56
|
+
`targets` maps recipe output → display:
|
|
57
|
+
- `targets.body` — a string template for `text_surface`.
|
|
58
|
+
- `targets.items` — an array of templates for list surfaces; each entry is either a string (label-only) or `{ label, body }`. The **whole array** is emitted each tick. **Labels are templated too**, so keep static labels as plain literals (no `{{…}}`) — only put `{{…}}` in the parts that should change.
|
|
59
|
+
|
|
60
|
+
### Template filters
|
|
61
|
+
|
|
62
|
+
`{{path}}` reads a value (`{{output}}` for the raw recipe output). Chain filters left-to-right:
|
|
63
|
+
|
|
64
|
+
`trim` · `upper` · `lower` · `int` · `round:N` · `percent` (×100, append %) · `truncate:N` · `default:"--"` · `prefix:"+"` · `minus:previous.value` · `plus:previous.value`. Use `previous.*` paths for deltas vs the prior tick.
|
|
65
|
+
|
|
66
|
+
## Worked examples
|
|
67
|
+
|
|
68
|
+
### 1. Live host stats — `system-stats` text_surface (runs today)
|
|
69
|
+
|
|
70
|
+
```js
|
|
71
|
+
render_glasses_ui({
|
|
72
|
+
kind: "text_surface",
|
|
73
|
+
title: "Host",
|
|
74
|
+
body: "RAM — CPU —",
|
|
75
|
+
refresh: {
|
|
76
|
+
recipe: { kind: "system-stats" },
|
|
77
|
+
intervalMs: 2000,
|
|
78
|
+
targets: { body: "RAM {{memUsedPct | round:0}}% CPU {{cpuPct | round:0}}% Load {{loadAvg1 | round:2}}" }
|
|
79
|
+
}
|
|
80
|
+
})
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### 2. Live host stats — `system-stats` list_with_details (the canonical interactive example, runs today)
|
|
84
|
+
|
|
85
|
+
```js
|
|
86
|
+
render_glasses_ui({
|
|
87
|
+
kind: "list_with_details_surface",
|
|
88
|
+
items: [
|
|
89
|
+
{ label: "Memory", body: "—" },
|
|
90
|
+
{ label: "CPU", body: "—" },
|
|
91
|
+
{ label: "Load", body: "—" }
|
|
92
|
+
],
|
|
93
|
+
refresh: {
|
|
94
|
+
recipe: { kind: "system-stats" },
|
|
95
|
+
intervalMs: 2000,
|
|
96
|
+
targets: {
|
|
97
|
+
items: [
|
|
98
|
+
{ label: "Memory", body: "{{memUsedMb}} / {{memTotalMb}} MB ({{memUsedPct | round:0}}%)" },
|
|
99
|
+
{ label: "CPU", body: "{{cpuPct | round:1}}% busy" },
|
|
100
|
+
{ label: "Load", body: "{{loadAvg1 | round:2}} (1-min avg)" }
|
|
101
|
+
]
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
})
|
|
105
|
+
// Labels are static literals (don't re-render); bodies tick every 2s.
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### 3. `push` drill-down (don't preempt the list)
|
|
109
|
+
|
|
110
|
+
```js
|
|
111
|
+
// User highlights "Memory" and asks for detail → stack a child, keep the live list underneath.
|
|
112
|
+
render_glasses_ui({
|
|
113
|
+
kind: "text_surface",
|
|
114
|
+
title: "Memory detail",
|
|
115
|
+
body: "Used … of … MB …", // compose from what you know, or give the child its own system-stats refresh
|
|
116
|
+
update: "push"
|
|
117
|
+
})
|
|
118
|
+
// The parent list's cron pauses while the child is up; on Back it staleness-resumes.
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### 4. `http` data (designed; NOT enableable on this install — shown for completeness)
|
|
122
|
+
|
|
123
|
+
```js
|
|
124
|
+
// L0 http — DATA APIs. Requires operator opt-in glassesUiLive.httpEnabled, which is
|
|
125
|
+
// NOT currently reachable (config-schema gap). This will be REJECTED today — prefer system-stats.
|
|
126
|
+
render_glasses_ui({
|
|
127
|
+
kind: "text_surface",
|
|
128
|
+
title: "AAPL",
|
|
129
|
+
body: "—",
|
|
130
|
+
refresh: {
|
|
131
|
+
recipe: { kind: "http", url: "https://api.example.com/quote/AAPL", jsonPath: "$.data" },
|
|
132
|
+
intervalMs: 30000,
|
|
133
|
+
targets: { body: "AAPL {{price}} ({{change | round:1 | prefix:\"+\"}})" }
|
|
134
|
+
}
|
|
135
|
+
})
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## Outcomes (the `result` you get back)
|
|
139
|
+
|
|
140
|
+
| result | meaning |
|
|
141
|
+
|---|---|
|
|
142
|
+
| `selected` | user picked a list item; `selected_index` + `selected_text` returned |
|
|
143
|
+
| `back` | user double-tapped above the root; they want to revise — re-render the previous step or pivot |
|
|
144
|
+
| `dismissed` | dismissed at root, or no selection made |
|
|
145
|
+
| `timeout` | no interaction within the window (non-refresh default 30 min; refresh ends at `maxDurationMs`) |
|
|
146
|
+
| `recipe_failed` | refresh only — initial smoke tick failed, the consecutive-failure breaker fired, or `onError:stop`; `failureReason` carries the last error |
|
|
147
|
+
| `glasses_disconnected` | refresh only — the glasses client dropped mid-cron |
|
|
148
|
+
|
|
149
|
+
Refresh results also carry: `ticks: { count, succeeded, failed, lastSuccessAt, lastFailureAt? }`, `lastBody`, `lastItems`, and `failureReason` (on `recipe_failed`).
|
|
150
|
+
|
|
151
|
+
## Quick reference
|
|
152
|
+
|
|
153
|
+
- Pick the **lowest tier**: host metrics → `system-stats`; pure data API → `http` (not enabled yet). Needs interpretation → render once from your turn.
|
|
154
|
+
- `update` default is `replace` (in-place). Use `push` to drill in without losing the parent; `patch` to edit fields while the cron keeps ticking.
|
|
155
|
+
- `intervalMs` ≥ 1000. Read `failureReason` on `recipe_failed` and fix the recipe.
|
|
156
|
+
- After a surface resolves, a short text reply exits to chat; another render replaces; silence lets it linger.
|