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.
- package/README.md +21 -6
- package/dist/config/runtime-config.js +84 -3
- package/dist/domain/activity-status-adapter.js +138 -605
- package/dist/domain/activity-status-arbiter.js +109 -0
- package/dist/domain/activity-status-labels.js +906 -0
- package/dist/domain/code-span-regions.js +103 -0
- package/dist/domain/conversation-state.js +14 -1
- package/dist/domain/debug-store.js +56 -182
- package/dist/domain/glasses-ui-content-summary.js +62 -0
- package/dist/domain/glasses-ui-system-prompt.js +28 -0
- package/dist/domain/message-emoji-allowlist.js +16 -0
- package/dist/domain/message-emoji-filter.js +33 -55
- package/dist/domain/neural-emoji-reactor-system-prompt.js +43 -0
- package/dist/domain/neural-emoji-reactor-tag-config.js +56 -0
- package/dist/domain/neural-pace-modulator-system-prompt.js +32 -0
- package/dist/domain/neural-pace-modulator-tag-config.js +51 -0
- package/dist/domain/tagged-span-parser.js +121 -0
- package/dist/domain/tagged-span-strip.js +38 -0
- package/dist/even-ai/even-ai-endpoint.js +91 -0
- package/dist/even-ai/even-ai-run-waiter.js +14 -0
- package/dist/even-ai/even-ai-settings-store.js +14 -0
- package/dist/gateway/gateway-bridge.js +14 -2
- package/dist/gateway/gateway-timing-ledger.js +457 -0
- package/dist/gateway/openclaw-client.js +462 -38
- package/dist/index.js +28 -1
- package/dist/runtime/downstream-handler.js +754 -83
- package/dist/runtime/ocuclaw-settings-store.js +74 -31
- package/dist/runtime/plugin-version-service.js +23 -0
- package/dist/runtime/protocol-adapter.js +9 -0
- package/dist/runtime/provider-usage-select.js +168 -0
- package/dist/runtime/relay-client-nudge-controller.js +553 -0
- package/dist/runtime/relay-core.js +1293 -225
- package/dist/runtime/relay-health-monitor.js +172 -0
- package/dist/runtime/relay-operation-registry.js +263 -0
- package/dist/runtime/relay-service.js +201 -1
- package/dist/runtime/relay-worker-approval-replay-cache.js +68 -0
- package/dist/runtime/relay-worker-entry.js +32 -0
- package/dist/runtime/relay-worker-health.js +272 -0
- package/dist/runtime/relay-worker-protocol.js +281 -0
- package/dist/runtime/relay-worker-queue.js +202 -0
- package/dist/runtime/relay-worker-supervisor.js +1004 -0
- package/dist/runtime/relay-worker-transport.js +1051 -0
- package/dist/runtime/session-context-service.js +189 -0
- package/dist/runtime/session-service.js +638 -27
- package/dist/runtime/upstream-runtime.js +1167 -60
- package/dist/tools/device-info-tool.js +242 -0
- package/dist/tools/glasses-ui-cron.js +427 -0
- package/dist/tools/glasses-ui-descriptors.js +261 -0
- package/dist/tools/glasses-ui-limits.js +21 -0
- package/dist/tools/glasses-ui-paint-floor.js +99 -0
- package/dist/tools/glasses-ui-recipes.js +581 -0
- package/dist/tools/glasses-ui-surfaces.js +278 -0
- package/dist/tools/glasses-ui-template.js +182 -0
- package/dist/tools/glasses-ui-tool.js +1111 -0
- package/dist/tools/session-title-tool.js +209 -0
- package/dist/version.js +2 -0
- package/openclaw.plugin.json +163 -15
- package/package.json +14 -5
- package/skills/glasses-ui/SKILL.md +156 -0
- package/dist/runtime/downstream-server.js +0 -1891
|
@@ -0,0 +1,1111 @@
|
|
|
1
|
+
// Glasses UI tool: lets the agent paint an interactive surface on the user's
|
|
2
|
+
// Even G2 glasses HUD instead of replying with text. Plain JSON Schema is used
|
|
3
|
+
// for the tool parameters because the plugin build is a regex-based emitter
|
|
4
|
+
// that has no access to typebox at runtime; OpenClaw consumes JSON Schema
|
|
5
|
+
// directly anyway.
|
|
6
|
+
|
|
7
|
+
import { validateTemplate } from "./glasses-ui-template.js";
|
|
8
|
+
import { createGlassesUiCronEngine } from "./glasses-ui-cron.js";
|
|
9
|
+
import {
|
|
10
|
+
executeHttpRecipe,
|
|
11
|
+
executeLlmRecipe,
|
|
12
|
+
executeSystemStatsRecipe,
|
|
13
|
+
} from "./glasses-ui-recipes.js";
|
|
14
|
+
import { createPendingRenderMap, createSurfaceStore, isTerminalOutcome } from "./glasses-ui-surfaces.js";
|
|
15
|
+
import { createPaintFloorCoalescer, DEFAULT_PAINT_FLOOR_MS } from "./glasses-ui-paint-floor.js";
|
|
16
|
+
import { GLASSES_UI_LIMITS } from "./glasses-ui-limits.js";
|
|
17
|
+
import {
|
|
18
|
+
getKindDescriptor,
|
|
19
|
+
listKindStrings,
|
|
20
|
+
buildOneOfBranches,
|
|
21
|
+
} from "./glasses-ui-descriptors.js";
|
|
22
|
+
|
|
23
|
+
// Re-exported so existing consumers/tests that import these from this module
|
|
24
|
+
// keep working after the extractions (spec §Changes A — extraction is behavior-
|
|
25
|
+
// preserving). Canonical homes: createPendingRenderMap/createSurfaceStore ->
|
|
26
|
+
// ./glasses-ui-surfaces.js, GLASSES_UI_LIMITS -> ./glasses-ui-limits.js. Kept as
|
|
27
|
+
// ONE bare `export {}` statement because the CJS emitter (scripts/build.mjs)
|
|
28
|
+
// strips only the first such statement — a second would survive into the .cjs
|
|
29
|
+
// as invalid syntax. createPendingRenderMap is the Phase-1 alias of the single
|
|
30
|
+
// createSurfaceStore (see glasses-ui-surfaces.ts).
|
|
31
|
+
export { createPendingRenderMap, createSurfaceStore, GLASSES_UI_LIMITS };
|
|
32
|
+
|
|
33
|
+
export const GLASSES_UI_REFRESH_LIMITS = {
|
|
34
|
+
intervalMsMin: { http: 1000, "system-stats": 1000, "llm-api": 30_000 },
|
|
35
|
+
intervalMsMax: 3_600_000,
|
|
36
|
+
maxDurationMsMin: 10_000,
|
|
37
|
+
maxDurationMsMax: 7_200_000,
|
|
38
|
+
maxDurationMsDefault: 30 * 60 * 1000,
|
|
39
|
+
maxConsecutiveFailuresMin: 1,
|
|
40
|
+
maxConsecutiveFailuresMax: 100,
|
|
41
|
+
maxConsecutiveFailuresDefault: 5,
|
|
42
|
+
shellHttpTimeoutMsMin: 1000,
|
|
43
|
+
shellHttpTimeoutMsMax: 30_000,
|
|
44
|
+
shellHttpTimeoutMsDefault: 10_000,
|
|
45
|
+
llmTimeoutMsMin: 5000,
|
|
46
|
+
llmTimeoutMsMax: 60_000,
|
|
47
|
+
llmTimeoutMsDefault: 30_000,
|
|
48
|
+
outputCapBytesMin: 1024,
|
|
49
|
+
outputCapBytesMax: 1_048_576,
|
|
50
|
+
outputCapBytesDefault: 65_536,
|
|
51
|
+
maxOutputTokensMin: 16,
|
|
52
|
+
maxOutputTokensMax: 1000,
|
|
53
|
+
maxOutputTokensDefault: 200,
|
|
54
|
+
// Cap on a single template string (body or one items entry). The
|
|
55
|
+
// substituted OUTPUT is clamped to bodyMax/itemMax at runtime, but a huge
|
|
56
|
+
// template itself is wasted work — 4KB is generous for any HUD line.
|
|
57
|
+
templateMaxChars: 4096,
|
|
58
|
+
// L0' system-stats: bounds on the optional CPU-sample window (ms).
|
|
59
|
+
systemStatsWindowMsMin: 50,
|
|
60
|
+
systemStatsWindowMsMax: 1000,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const ON_ERROR_VALUES = new Set(["keep_last", "show_error", "stop"]);
|
|
64
|
+
|
|
65
|
+
// The effective per-tick interval floor is the larger of the tier minimum and
|
|
66
|
+
// the paint-floor coalescer's cadence (Spike D, 150ms) — no tick may schedule
|
|
67
|
+
// faster than the glass can paint. Today every tier min already exceeds 150ms,
|
|
68
|
+
// so this only guards the floor from ever relaxing below the coalescer cadence.
|
|
69
|
+
function effectiveIntervalFloorMs(tierMinMs) {
|
|
70
|
+
return Math.max(tierMinMs, DEFAULT_PAINT_FLOOR_MS);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function validateRefreshSpec(refresh, glassesUiLiveCfg) {
|
|
74
|
+
if (refresh === undefined || refresh === null) return { ok: true, refresh: undefined };
|
|
75
|
+
if (typeof refresh !== "object" || Array.isArray(refresh)) {
|
|
76
|
+
return { ok: false, code: "refresh_invalid_recipe", message: "refresh must be an object" };
|
|
77
|
+
}
|
|
78
|
+
const cfg = glassesUiLiveCfg && typeof glassesUiLiveCfg === "object" ? glassesUiLiveCfg : {};
|
|
79
|
+
if (cfg.enabled === false) {
|
|
80
|
+
return { ok: false, code: "refresh_disabled", message: "glassesUiLive is disabled by operator config" };
|
|
81
|
+
}
|
|
82
|
+
const recipe = refresh.recipe;
|
|
83
|
+
if (!recipe || typeof recipe !== "object") {
|
|
84
|
+
return { ok: false, code: "refresh_invalid_recipe", message: "refresh.recipe is required" };
|
|
85
|
+
}
|
|
86
|
+
const kind = recipe.kind;
|
|
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)}` };
|
|
89
|
+
}
|
|
90
|
+
// Sanitize the recipe — clamp/reject agent-supplied timeoutMs / outputCapBytes
|
|
91
|
+
// / maxOutputTokens to declared bounds, copy known fields only. The returned
|
|
92
|
+
// `refresh.recipe` is this sanitized version, never the raw input — so the
|
|
93
|
+
// executors at run-time see vetted values.
|
|
94
|
+
const sanitizedRecipe = { kind };
|
|
95
|
+
const bounded = (raw, min, max) => {
|
|
96
|
+
if (!Number.isFinite(raw)) return null;
|
|
97
|
+
if (raw < min || raw > max) return undefined; // signal out-of-range
|
|
98
|
+
return Math.floor(raw);
|
|
99
|
+
};
|
|
100
|
+
if (kind === "http") {
|
|
101
|
+
if (cfg.httpEnabled === false) return { ok: false, code: "refresh_disabled", message: "http recipes disabled" };
|
|
102
|
+
if (typeof recipe.url !== "string" || !recipe.url.trim()) {
|
|
103
|
+
return { ok: false, code: "refresh_invalid_recipe", message: "http recipe requires url (non-empty string)" };
|
|
104
|
+
}
|
|
105
|
+
sanitizedRecipe.url = recipe.url;
|
|
106
|
+
if (typeof recipe.method === "string") sanitizedRecipe.method = recipe.method;
|
|
107
|
+
if (recipe.headers && typeof recipe.headers === "object") sanitizedRecipe.headers = recipe.headers;
|
|
108
|
+
if (typeof recipe.body === "string") sanitizedRecipe.body = recipe.body;
|
|
109
|
+
if (typeof recipe.jsonPath === "string") sanitizedRecipe.jsonPath = recipe.jsonPath;
|
|
110
|
+
if (recipe.timeoutMs !== undefined) {
|
|
111
|
+
const v = bounded(recipe.timeoutMs, GLASSES_UI_REFRESH_LIMITS.shellHttpTimeoutMsMin, GLASSES_UI_REFRESH_LIMITS.shellHttpTimeoutMsMax);
|
|
112
|
+
if (v === undefined) return { ok: false, code: "refresh_invalid_recipe", message: `http.timeoutMs ${recipe.timeoutMs} out of bounds [${GLASSES_UI_REFRESH_LIMITS.shellHttpTimeoutMsMin}..${GLASSES_UI_REFRESH_LIMITS.shellHttpTimeoutMsMax}]` };
|
|
113
|
+
if (v !== null) sanitizedRecipe.timeoutMs = v;
|
|
114
|
+
}
|
|
115
|
+
if (recipe.outputCapBytes !== undefined) {
|
|
116
|
+
const v = bounded(recipe.outputCapBytes, GLASSES_UI_REFRESH_LIMITS.outputCapBytesMin, GLASSES_UI_REFRESH_LIMITS.outputCapBytesMax);
|
|
117
|
+
if (v === undefined) return { ok: false, code: "refresh_invalid_recipe", message: `http.outputCapBytes ${recipe.outputCapBytes} out of bounds` };
|
|
118
|
+
if (v !== null) sanitizedRecipe.outputCapBytes = v;
|
|
119
|
+
}
|
|
120
|
+
} else if (kind === "llm") {
|
|
121
|
+
if (cfg.llmEnabled === false) return { ok: false, code: "refresh_disabled", message: "llm recipes disabled" };
|
|
122
|
+
if (typeof recipe.prompt !== "string" || !recipe.prompt.trim()) {
|
|
123
|
+
return { ok: false, code: "refresh_invalid_recipe", message: "llm recipe requires prompt (non-empty string)" };
|
|
124
|
+
}
|
|
125
|
+
if (typeof recipe.model === "string" && recipe.model.trim() && cfg.allowAgentModelOverride !== true) {
|
|
126
|
+
return { ok: false, code: "refresh_llm_model_override_denied", message: "agent model override denied by operator config" };
|
|
127
|
+
}
|
|
128
|
+
sanitizedRecipe.prompt = recipe.prompt;
|
|
129
|
+
if (typeof recipe.systemPrompt === "string") sanitizedRecipe.systemPrompt = recipe.systemPrompt;
|
|
130
|
+
if (typeof recipe.model === "string") sanitizedRecipe.model = recipe.model;
|
|
131
|
+
if (recipe.maxOutputTokens !== undefined) {
|
|
132
|
+
const v = bounded(recipe.maxOutputTokens, GLASSES_UI_REFRESH_LIMITS.maxOutputTokensMin, GLASSES_UI_REFRESH_LIMITS.maxOutputTokensMax);
|
|
133
|
+
if (v === undefined) return { ok: false, code: "refresh_invalid_recipe", message: `llm.maxOutputTokens ${recipe.maxOutputTokens} out of bounds` };
|
|
134
|
+
if (v !== null) sanitizedRecipe.maxOutputTokens = v;
|
|
135
|
+
}
|
|
136
|
+
} else if (kind === "system-stats") {
|
|
137
|
+
// Built-in tier: host RAM/CPU via the in-process structured reader. NOT gated
|
|
138
|
+
// by httpEnabled/llmEnabled — it touches no network, no shell, no
|
|
139
|
+
// model. Only the master `enabled` switch (checked above) governs it. Do NOT
|
|
140
|
+
// add a cfg.*Enabled gate here (intentional — Phase 3 design).
|
|
141
|
+
if (recipe.sampleWindowMs !== undefined) {
|
|
142
|
+
const v = bounded(recipe.sampleWindowMs, GLASSES_UI_REFRESH_LIMITS.systemStatsWindowMsMin, GLASSES_UI_REFRESH_LIMITS.systemStatsWindowMsMax);
|
|
143
|
+
if (v === undefined) return { ok: false, code: "refresh_invalid_recipe", message: `system-stats.sampleWindowMs ${recipe.sampleWindowMs} out of bounds [${GLASSES_UI_REFRESH_LIMITS.systemStatsWindowMsMin}..${GLASSES_UI_REFRESH_LIMITS.systemStatsWindowMsMax}]` };
|
|
144
|
+
if (v !== null) sanitizedRecipe.sampleWindowMs = v;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Interval bounds.
|
|
149
|
+
const intervalMs = refresh.intervalMs;
|
|
150
|
+
if (!Number.isFinite(intervalMs)) {
|
|
151
|
+
return { ok: false, code: "refresh_invalid_recipe", message: "refresh.intervalMs is required" };
|
|
152
|
+
}
|
|
153
|
+
const minForKind =
|
|
154
|
+
kind === "llm"
|
|
155
|
+
? GLASSES_UI_REFRESH_LIMITS.intervalMsMin["llm-api"]
|
|
156
|
+
: GLASSES_UI_REFRESH_LIMITS.intervalMsMin[kind];
|
|
157
|
+
const minEffective = effectiveIntervalFloorMs(minForKind);
|
|
158
|
+
if (intervalMs < minEffective) {
|
|
159
|
+
return {
|
|
160
|
+
ok: false,
|
|
161
|
+
code: "refresh_interval_too_low",
|
|
162
|
+
message: `intervalMs ${intervalMs} below minimum ${minEffective} for ${kind}${kind === "llm" ? ` (${cfg.tickBackend})` : ""}`,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
if (intervalMs > GLASSES_UI_REFRESH_LIMITS.intervalMsMax) {
|
|
166
|
+
return { ok: false, code: "refresh_interval_too_high", message: `intervalMs ${intervalMs} above max ${GLASSES_UI_REFRESH_LIMITS.intervalMsMax}` };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Duration.
|
|
170
|
+
const maxDurationMs = Number.isFinite(refresh.maxDurationMs)
|
|
171
|
+
? refresh.maxDurationMs
|
|
172
|
+
: GLASSES_UI_REFRESH_LIMITS.maxDurationMsDefault;
|
|
173
|
+
if (maxDurationMs < GLASSES_UI_REFRESH_LIMITS.maxDurationMsMin || maxDurationMs > GLASSES_UI_REFRESH_LIMITS.maxDurationMsMax) {
|
|
174
|
+
return { ok: false, code: "refresh_duration_too_high", message: `maxDurationMs ${maxDurationMs} out of bounds` };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// onError.
|
|
178
|
+
const onError = typeof refresh.onError === "string" ? refresh.onError : "keep_last";
|
|
179
|
+
if (!ON_ERROR_VALUES.has(onError)) {
|
|
180
|
+
return { ok: false, code: "refresh_invalid_recipe", message: `onError must be keep_last/show_error/stop` };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Templates.
|
|
184
|
+
const targets = refresh.targets && typeof refresh.targets === "object" ? refresh.targets : {};
|
|
185
|
+
if (typeof targets.body === "string") {
|
|
186
|
+
if (targets.body.length > GLASSES_UI_REFRESH_LIMITS.templateMaxChars) {
|
|
187
|
+
return { ok: false, code: "refresh_template_invalid", message: `targets.body template exceeds ${GLASSES_UI_REFRESH_LIMITS.templateMaxChars} chars` };
|
|
188
|
+
}
|
|
189
|
+
const v = validateTemplate(targets.body);
|
|
190
|
+
if (!v.ok) return v;
|
|
191
|
+
}
|
|
192
|
+
if (Array.isArray(targets.items)) {
|
|
193
|
+
// Cap array length — only the first maxItems survive the runtime slice,
|
|
194
|
+
// so a 100k-entry array would burn CPU substituting templates that are
|
|
195
|
+
// immediately discarded. Reject (rather than truncate) so the agent gets
|
|
196
|
+
// clear feedback that it over-supplied.
|
|
197
|
+
if (targets.items.length > GLASSES_UI_LIMITS.maxItems) {
|
|
198
|
+
return {
|
|
199
|
+
ok: false,
|
|
200
|
+
code: "refresh_invalid_recipe",
|
|
201
|
+
message: `targets.items has ${targets.items.length} entries; max is ${GLASSES_UI_LIMITS.maxItems}`,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
for (let i = 0; i < targets.items.length; i += 1) {
|
|
205
|
+
const item = targets.items[i];
|
|
206
|
+
if (typeof item === "string") {
|
|
207
|
+
if (item.length > GLASSES_UI_REFRESH_LIMITS.templateMaxChars) {
|
|
208
|
+
return { ok: false, code: "refresh_template_invalid", message: `targets.items[${i}] template exceeds ${GLASSES_UI_REFRESH_LIMITS.templateMaxChars} chars` };
|
|
209
|
+
}
|
|
210
|
+
const v = validateTemplate(item);
|
|
211
|
+
if (!v.ok) return v;
|
|
212
|
+
} else if (item && typeof item === "object" && !Array.isArray(item)) {
|
|
213
|
+
// {label, body?} per-item templates (list_with_details detail bodies).
|
|
214
|
+
if (typeof item.label !== "string") {
|
|
215
|
+
return { ok: false, code: "refresh_template_invalid", message: `targets.items[${i}].label must be a string template` };
|
|
216
|
+
}
|
|
217
|
+
for (const field of ["label", "body"]) {
|
|
218
|
+
const tpl = item[field];
|
|
219
|
+
if (tpl === undefined) continue;
|
|
220
|
+
if (typeof tpl !== "string") {
|
|
221
|
+
return { ok: false, code: "refresh_template_invalid", message: `targets.items[${i}].${field} must be a string template` };
|
|
222
|
+
}
|
|
223
|
+
if (tpl.length > GLASSES_UI_REFRESH_LIMITS.templateMaxChars) {
|
|
224
|
+
return { ok: false, code: "refresh_template_invalid", message: `targets.items[${i}].${field} template exceeds ${GLASSES_UI_REFRESH_LIMITS.templateMaxChars} chars` };
|
|
225
|
+
}
|
|
226
|
+
const v = validateTemplate(tpl);
|
|
227
|
+
if (!v.ok) return v;
|
|
228
|
+
}
|
|
229
|
+
} else {
|
|
230
|
+
return { ok: false, code: "refresh_template_invalid", message: `targets.items[${i}] must be a string or {label, body} template` };
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
ok: true,
|
|
237
|
+
refresh: {
|
|
238
|
+
recipe: sanitizedRecipe,
|
|
239
|
+
intervalMs,
|
|
240
|
+
targets,
|
|
241
|
+
onError,
|
|
242
|
+
maxDurationMs,
|
|
243
|
+
maxConsecutiveFailures: Number.isFinite(refresh.maxConsecutiveFailures)
|
|
244
|
+
? Math.max(1, Math.min(100, Math.floor(refresh.maxConsecutiveFailures)))
|
|
245
|
+
: GLASSES_UI_REFRESH_LIMITS.maxConsecutiveFailuresDefault,
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const updateSchemaForToolParams = {
|
|
251
|
+
type: "string",
|
|
252
|
+
enum: ["patch", "replace", "push"],
|
|
253
|
+
description:
|
|
254
|
+
"How this render relates to the current surface. " +
|
|
255
|
+
"\"patch\": change some fields of the current screen (cron keeps ticking). " +
|
|
256
|
+
"\"replace\" (default): swap the whole current screen content (no back-target). " +
|
|
257
|
+
"\"push\": stack a new screen; the parent is retained and its cron pauses.",
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
const refreshSchemaForToolParams = {
|
|
261
|
+
type: "object",
|
|
262
|
+
description: "Optional periodic refresh policy; turns this surface into a live-updating one.",
|
|
263
|
+
required: ["recipe", "intervalMs"],
|
|
264
|
+
properties: {
|
|
265
|
+
intervalMs: { type: "integer", minimum: 1000, maximum: 3_600_000 },
|
|
266
|
+
maxDurationMs: { type: "integer", minimum: 10_000, maximum: 7_200_000 },
|
|
267
|
+
maxConsecutiveFailures: { type: "integer", minimum: 1, maximum: 100 },
|
|
268
|
+
onError: { type: "string", enum: ["keep_last", "show_error", "stop"] },
|
|
269
|
+
targets: {
|
|
270
|
+
type: "object",
|
|
271
|
+
properties: {
|
|
272
|
+
body: { type: "string" },
|
|
273
|
+
items: {
|
|
274
|
+
type: "array",
|
|
275
|
+
items: {
|
|
276
|
+
oneOf: [
|
|
277
|
+
{ type: "string" },
|
|
278
|
+
{ type: "object", required: ["label"], properties: { label: { type: "string" }, body: { type: "string" } } },
|
|
279
|
+
],
|
|
280
|
+
},
|
|
281
|
+
},
|
|
282
|
+
},
|
|
283
|
+
},
|
|
284
|
+
recipe: {
|
|
285
|
+
oneOf: [
|
|
286
|
+
{
|
|
287
|
+
type: "object",
|
|
288
|
+
required: ["kind", "url"],
|
|
289
|
+
properties: {
|
|
290
|
+
kind: { const: "http" },
|
|
291
|
+
url: { type: "string" },
|
|
292
|
+
method: { type: "string", enum: ["GET", "POST"] },
|
|
293
|
+
headers: { type: "object" },
|
|
294
|
+
body: { type: "string" },
|
|
295
|
+
jsonPath: { type: "string" },
|
|
296
|
+
timeoutMs: { type: "integer" },
|
|
297
|
+
outputCapBytes: { type: "integer" },
|
|
298
|
+
},
|
|
299
|
+
},
|
|
300
|
+
{
|
|
301
|
+
type: "object",
|
|
302
|
+
required: ["kind", "prompt"],
|
|
303
|
+
properties: {
|
|
304
|
+
kind: { const: "llm" },
|
|
305
|
+
prompt: { type: "string" },
|
|
306
|
+
systemPrompt: { type: "string" },
|
|
307
|
+
model: { type: "string" },
|
|
308
|
+
maxOutputTokens: { type: "integer" },
|
|
309
|
+
},
|
|
310
|
+
},
|
|
311
|
+
{
|
|
312
|
+
type: "object",
|
|
313
|
+
required: ["kind"],
|
|
314
|
+
properties: {
|
|
315
|
+
kind: { const: "system-stats" },
|
|
316
|
+
sampleWindowMs: { type: "integer", minimum: 50, maximum: 1000 },
|
|
317
|
+
},
|
|
318
|
+
},
|
|
319
|
+
],
|
|
320
|
+
},
|
|
321
|
+
},
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
// Top-level `properties` lists every field a valid spec may carry across all
|
|
325
|
+
// `kind`s. OpenClaw's Anthropic provider strips `oneOf` when building
|
|
326
|
+
// `input_schema` (it keeps only top-level `properties` + `required`), so
|
|
327
|
+
// without this flat union the model would see `properties: {}` and have to
|
|
328
|
+
// guess the shape from the tool description alone. Per-kind shape constraints
|
|
329
|
+
// are still enforced by `validateGlassesUiSpec` and the JSON Schema `oneOf`
|
|
330
|
+
// below for clients that honor it.
|
|
331
|
+
export const glassesUiParametersSchema = {
|
|
332
|
+
type: "object",
|
|
333
|
+
required: ["kind"],
|
|
334
|
+
properties: {
|
|
335
|
+
kind: {
|
|
336
|
+
type: "string",
|
|
337
|
+
// Enum derived from the descriptor registry (enum order). Adding a kind
|
|
338
|
+
// is one descriptor with no edit here (spec §Modularity).
|
|
339
|
+
enum: listKindStrings(),
|
|
340
|
+
description:
|
|
341
|
+
"Surface kind. Each kind expects a different items/body shape — see " +
|
|
342
|
+
"the tool description for examples.",
|
|
343
|
+
},
|
|
344
|
+
title: {
|
|
345
|
+
type: "string",
|
|
346
|
+
maxLength: GLASSES_UI_LIMITS.titleMax,
|
|
347
|
+
description: "Optional ≤64-char title shown at the top of the surface.",
|
|
348
|
+
},
|
|
349
|
+
body: {
|
|
350
|
+
type: "string",
|
|
351
|
+
maxLength: GLASSES_UI_LIMITS.bodyMax,
|
|
352
|
+
description:
|
|
353
|
+
"Required when kind=\"text_surface\". The ≤1000-char block of text to " +
|
|
354
|
+
"display. Ignored for the list kinds.",
|
|
355
|
+
},
|
|
356
|
+
items: {
|
|
357
|
+
type: "array",
|
|
358
|
+
maxItems: GLASSES_UI_LIMITS.maxItems,
|
|
359
|
+
description:
|
|
360
|
+
"Required when kind=\"list_surface\" or kind=\"list_with_details_surface\". " +
|
|
361
|
+
"For list_surface, an array of plain strings (≤64 chars each), e.g. " +
|
|
362
|
+
"[\"Monday\", \"Tuesday\"]. For list_with_details_surface, an array of " +
|
|
363
|
+
"{label, body?} objects (label ≤64 chars, body ≤200 chars), e.g. " +
|
|
364
|
+
"[{\"label\": \"Monday\", \"body\": \"Cloudy 14C, light rain pm\"}, " +
|
|
365
|
+
"{\"label\": \"Tuesday\", \"body\": \"Sunny 19C\"}]. Up to 20 items.",
|
|
366
|
+
},
|
|
367
|
+
// refresh must be top-level too — the Anthropic provider strips `oneOf`
|
|
368
|
+
// (see the block comment above), so a refresh entry that lives only in
|
|
369
|
+
// the oneOf branches is invisible on that path and the live-refresh
|
|
370
|
+
// feature becomes unreachable. The per-branch copies below stay for
|
|
371
|
+
// clients that honor oneOf.
|
|
372
|
+
refresh: refreshSchemaForToolParams,
|
|
373
|
+
// update is the render-vs-current-surface move (patch/replace/push,
|
|
374
|
+
// default replace). Top-level for the same Anthropic-strips-oneOf reason as
|
|
375
|
+
// refresh; mirrored into every oneOf branch below.
|
|
376
|
+
update: updateSchemaForToolParams,
|
|
377
|
+
},
|
|
378
|
+
// oneOf is assembled from the descriptor registry (one branch per kind, in
|
|
379
|
+
// enum order). Each branch's `refresh` slot — declared `undefined` in the
|
|
380
|
+
// descriptor's schemaBranch — is filled here with the shared refresh schema
|
|
381
|
+
// so the per-branch shape matches today's hand-written one (the tool owns
|
|
382
|
+
// refreshSchemaForToolParams; the descriptor only declares the slot). `update`
|
|
383
|
+
// is mirrored into every branch the same way.
|
|
384
|
+
oneOf: buildOneOfBranches().map((branch) => ({
|
|
385
|
+
...branch,
|
|
386
|
+
properties: {
|
|
387
|
+
...branch.properties,
|
|
388
|
+
refresh: refreshSchemaForToolParams,
|
|
389
|
+
update: updateSchemaForToolParams,
|
|
390
|
+
},
|
|
391
|
+
})),
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
export function validateGlassesUiSpec(input) {
|
|
395
|
+
if (!input || typeof input !== "object") {
|
|
396
|
+
return { ok: false, code: "invalid_kind", message: "spec must be an object" };
|
|
397
|
+
}
|
|
398
|
+
const obj = input;
|
|
399
|
+
// Dispatch by kind STRING through the descriptor registry. "No descriptor for
|
|
400
|
+
// kind" reproduces today's invalid_kind. Per-kind validation (incl. the
|
|
401
|
+
// shared title check, which each descriptor runs first) lives in the
|
|
402
|
+
// descriptor's validateSpec, so behavior is identical to the old switch.
|
|
403
|
+
const descriptor = getKindDescriptor(obj.kind);
|
|
404
|
+
if (!descriptor) {
|
|
405
|
+
return {
|
|
406
|
+
ok: false,
|
|
407
|
+
code: "invalid_kind",
|
|
408
|
+
message:
|
|
409
|
+
`kind must be "text_surface", "list_surface", or "list_with_details_surface"; ` +
|
|
410
|
+
`got ${JSON.stringify(obj.kind)}`,
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
return descriptor.validateSpec(obj);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
import { randomUUID } from "node:crypto";
|
|
417
|
+
|
|
418
|
+
// Mirror of even-ai-model-hook's session classifier. Inlined to avoid a
|
|
419
|
+
// cross-file CJS dependency (even-ai-model-hook is ESM-only in dist).
|
|
420
|
+
// Keep these constants in sync with even-ai-model-hook.ts.
|
|
421
|
+
const EVEN_AI_THROWAWAY_SESSION_PREFIX = "ocuclaw:even-ai:";
|
|
422
|
+
const EVEN_AI_DEFAULT_DEDICATED_SESSION_KEY = "ocuclaw:even-ai";
|
|
423
|
+
|
|
424
|
+
function normalizeEvenAiSessionKey(value) {
|
|
425
|
+
if (typeof value !== "string") return "";
|
|
426
|
+
return value.trim().toLowerCase();
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
export function isEvenAiAgentSession(sessionKey, dedicatedSessionKey) {
|
|
430
|
+
const normalized = normalizeEvenAiSessionKey(sessionKey);
|
|
431
|
+
if (!normalized) return false;
|
|
432
|
+
if (
|
|
433
|
+
normalized === EVEN_AI_DEFAULT_DEDICATED_SESSION_KEY ||
|
|
434
|
+
normalized.startsWith(EVEN_AI_THROWAWAY_SESSION_PREFIX)
|
|
435
|
+
) {
|
|
436
|
+
return true;
|
|
437
|
+
}
|
|
438
|
+
const normalizedDedicated = normalizeEvenAiSessionKey(dedicatedSessionKey);
|
|
439
|
+
return !!normalizedDedicated && normalized === normalizedDedicated;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Default timeout when no per-call or per-handler timeout is provided.
|
|
443
|
+
// Bounds the orphan-tool_use corruption window (see Family 2 in the OpenClaw
|
|
444
|
+
// task-runs zombies / orphan tool_use memory): if a render_glasses_ui call
|
|
445
|
+
// stays unresolved this long, the plugin returns { result: "timeout" } so
|
|
446
|
+
// the agent's runtime persists a matching tool_result and the next turn's
|
|
447
|
+
// session replay won't 400 on an unmatched tool_use block.
|
|
448
|
+
export const DEFAULT_RENDER_GLASSES_UI_TIMEOUT_MS = 30 * 60 * 1000;
|
|
449
|
+
|
|
450
|
+
export function createGlassesUiToolHandler(deps) {
|
|
451
|
+
// The single live surface store is constructed below (after the cron engine,
|
|
452
|
+
// which it delegates pause/resume/stop to). There is never a separate pending
|
|
453
|
+
// map alongside it (spec §Core model — one store).
|
|
454
|
+
// Short-lived per-surface capture used to read the cron's merged outcome
|
|
455
|
+
// (ticks{}, lastBody, lastItems, ...extra) when runDynamicUi stops the
|
|
456
|
+
// cron after a user dismissal (pending already resolved with a user-only
|
|
457
|
+
// outcome that lacks ticks). Cleared immediately after stop returns.
|
|
458
|
+
const capturedCronOutcome = new Map();
|
|
459
|
+
const newSurfaceId =
|
|
460
|
+
deps && typeof deps.newSurfaceId === "function"
|
|
461
|
+
? deps.newSurfaceId
|
|
462
|
+
: () => `ui-${randomUUID().slice(0, 8)}`;
|
|
463
|
+
|
|
464
|
+
// Permanent glasses.lifecycle observability (nav reconcile + cron pause/
|
|
465
|
+
// resume/tick). No-op when the dep is absent (tests) or the debug category is
|
|
466
|
+
// disabled. See docs/superpowers/findings/2026-05-30-glasses-ui-phase4-hardware.md.
|
|
467
|
+
const emitLifecycle =
|
|
468
|
+
typeof deps.emitLifecycle === "function" ? deps.emitLifecycle : () => {};
|
|
469
|
+
|
|
470
|
+
// Resolve the handler-wide default timeout. Per-call timeouts may still
|
|
471
|
+
// override this via params.timeoutMs.
|
|
472
|
+
function resolveHandlerTimeoutMs() {
|
|
473
|
+
if (!deps || deps.timeoutMs === undefined) return DEFAULT_RENDER_GLASSES_UI_TIMEOUT_MS;
|
|
474
|
+
if (typeof deps.timeoutMs === "function") {
|
|
475
|
+
const v = deps.timeoutMs();
|
|
476
|
+
return Number.isFinite(v) ? v : DEFAULT_RENDER_GLASSES_UI_TIMEOUT_MS;
|
|
477
|
+
}
|
|
478
|
+
return Number.isFinite(deps.timeoutMs) ? deps.timeoutMs : DEFAULT_RENDER_GLASSES_UI_TIMEOUT_MS;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// The single plugin->glass send chokepoint (Spike D). EVERY send — the
|
|
482
|
+
// initial render frame and every cron surface_update — routes through this
|
|
483
|
+
// trailing-edge coalescer so bursts collapse to ≤1 frame per 150ms and shed
|
|
484
|
+
// under BLE backpressure. A { __render } sentinel is a full container
|
|
485
|
+
// rebuild; a plain field patch is a surface_update.
|
|
486
|
+
const paintFloor = createPaintFloorCoalescer({
|
|
487
|
+
// Tests may inject paintFloorMs: 0 to disable coalescing (every enqueue is
|
|
488
|
+
// a leading-edge send) so synchronous send-ordering assertions hold;
|
|
489
|
+
// production uses the 150ms Spike-D cadence.
|
|
490
|
+
paintFloorMs: Number.isFinite(deps.paintFloorMs) ? deps.paintFloorMs : DEFAULT_PAINT_FLOOR_MS,
|
|
491
|
+
send: ({ surfaceId, sessionKey, patch }) => {
|
|
492
|
+
if (patch && patch.__render) {
|
|
493
|
+
deps.relay.sendGlassesUiRender({ sessionKey, surfaceId, depth: patch.__depth, spec: patch.__spec });
|
|
494
|
+
} else {
|
|
495
|
+
deps.relay.sendGlassesUiSurfaceUpdate({ sessionKey, surfaceId, patch });
|
|
496
|
+
}
|
|
497
|
+
},
|
|
498
|
+
isUnderBackpressure: typeof deps.isUnderBackpressure === "function" ? deps.isUnderBackpressure : () => false,
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
const cronEngine = createGlassesUiCronEngine({
|
|
502
|
+
emitLifecycle,
|
|
503
|
+
monotonicNowMs: () => performance.now(),
|
|
504
|
+
executeRecipe: async (recipe, ctx) => {
|
|
505
|
+
if (recipe.kind === "http") return executeHttpRecipe(recipe);
|
|
506
|
+
if (recipe.kind === "system-stats") return executeSystemStatsRecipe(recipe);
|
|
507
|
+
if (recipe.kind === "llm") return executeLlmRecipe(recipe, ctx);
|
|
508
|
+
return { error: `unknown recipe kind: ${recipe.kind}` };
|
|
509
|
+
},
|
|
510
|
+
glassesUiLimits: GLASSES_UI_LIMITS,
|
|
511
|
+
sendSurfaceUpdate: (params) => paintFloor.enqueue({ surfaceId: params.surfaceId, sessionKey: params.sessionKey, patch: params.patch }),
|
|
512
|
+
resolveLlmCtx: (state) => {
|
|
513
|
+
const cfg = deps.getGlassesUiLiveConfig ? deps.getGlassesUiLiveConfig() : {};
|
|
514
|
+
const agentModel =
|
|
515
|
+
typeof state.recipe.model === "string" && state.recipe.model.trim() && cfg.allowAgentModelOverride === true
|
|
516
|
+
? state.recipe.model.trim()
|
|
517
|
+
: null;
|
|
518
|
+
const model = agentModel || cfg.tickModel || "";
|
|
519
|
+
const maxOutputTokens = Number.isFinite(state.recipe.maxOutputTokens)
|
|
520
|
+
? Math.min(state.recipe.maxOutputTokens, cfg.tickMaxOutputTokens || 200)
|
|
521
|
+
: (cfg.tickMaxOutputTokens || 200);
|
|
522
|
+
return {
|
|
523
|
+
backend: cfg.tickBackend || "anthropic-api",
|
|
524
|
+
model,
|
|
525
|
+
baseUrl: cfg.tickApiBaseUrl || "",
|
|
526
|
+
apiKey: deps.resolveLlmApiKey ? deps.resolveLlmApiKey(model) : "",
|
|
527
|
+
maxOutputTokens,
|
|
528
|
+
previousBody: state.lastBody || "",
|
|
529
|
+
};
|
|
530
|
+
},
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
// The SINGLE live surface store (spec §Core model). Constructed after the
|
|
534
|
+
// cron engine so it can delegate pause/resume/stop; id minting reuses the
|
|
535
|
+
// handler's minter. Replaces the Phase-1 pending map — never a second store.
|
|
536
|
+
const surfaceStore = createSurfaceStore({
|
|
537
|
+
pauseCron: (id) => cronEngine.pause(id),
|
|
538
|
+
resumeCron: (id) => cronEngine.resume(id),
|
|
539
|
+
// stopCron fires on every surface teardown (replace swap, popBack child,
|
|
540
|
+
// exit, drain). Dispose the paint-floor coalescer entry too so an armed
|
|
541
|
+
// trailing flush can't paint a stale surface_update onto the now-visible
|
|
542
|
+
// parent/chat after a back/pop, and the per-surface coalescer state doesn't
|
|
543
|
+
// leak across a long push/replace session. (pauseCron on push does NOT
|
|
544
|
+
// dispose — the parent resumes.)
|
|
545
|
+
stopCron: (id) => {
|
|
546
|
+
cronEngine.stop(id, { result: "preempted" });
|
|
547
|
+
paintFloor.dispose(id);
|
|
548
|
+
},
|
|
549
|
+
mintSurfaceId: newSurfaceId,
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
deps.relay.onGlassesUiResult((msg) => {
|
|
553
|
+
if (!msg || typeof msg.surfaceId !== "string" || !msg.outcome) return;
|
|
554
|
+
const terminal = isTerminalOutcome(msg.outcome);
|
|
555
|
+
if (terminal && cronEngine.isActive(msg.surfaceId)) {
|
|
556
|
+
// Terminal with a live cron: stop the cron, merging its tick stats into
|
|
557
|
+
// the outcome via capturedCronOutcome, then settle the in-flight call (or
|
|
558
|
+
// queue the terminal so the next render discards-for-exit).
|
|
559
|
+
let merged = msg.outcome;
|
|
560
|
+
capturedCronOutcome.set(msg.surfaceId, (cronOutcome) => { merged = cronOutcome; });
|
|
561
|
+
cronEngine.stop(msg.surfaceId, msg.outcome);
|
|
562
|
+
capturedCronOutcome.delete(msg.surfaceId);
|
|
563
|
+
if (!surfaceStore.resolve(msg.surfaceId, merged)) {
|
|
564
|
+
surfaceStore.queueEvent(msg.surfaceId, merged);
|
|
565
|
+
}
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
// Nonterminal (selected/back): settle the call if one is pending; otherwise
|
|
569
|
+
// the surface is in visible_awaiting_agent — queue last-wins so the agent's
|
|
570
|
+
// next render delivers the latest event (spec §Tool-call accounting). A
|
|
571
|
+
// terminal with no live cron also lands here (queue → next render tears down).
|
|
572
|
+
if (!surfaceStore.resolve(msg.surfaceId, msg.outcome)) {
|
|
573
|
+
surfaceStore.queueEvent(msg.surfaceId, msg.outcome);
|
|
574
|
+
}
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
async function runDynamicUi(params) {
|
|
578
|
+
const validation = validateGlassesUiSpec(params.spec);
|
|
579
|
+
if (!validation.ok) {
|
|
580
|
+
emitLifecycle("render_rejected", "warn", {
|
|
581
|
+
surfaceId: params && typeof params.surfaceId === "string" ? params.surfaceId : null,
|
|
582
|
+
code: validation.code || "invalid_spec",
|
|
583
|
+
reason: validation.error || validation.message || "spec validation failed",
|
|
584
|
+
});
|
|
585
|
+
const err = new Error(`${validation.code}: ${validation.message}`);
|
|
586
|
+
err.code = validation.code;
|
|
587
|
+
throw err;
|
|
588
|
+
}
|
|
589
|
+
const sessionKey =
|
|
590
|
+
typeof params.sessionKey === "string" && params.sessionKey.trim()
|
|
591
|
+
? params.sessionKey.trim()
|
|
592
|
+
: "main";
|
|
593
|
+
if (typeof deps.isSessionConnected === "function" && !deps.isSessionConnected(sessionKey)) {
|
|
594
|
+
const err = new Error(
|
|
595
|
+
"glasses_not_connected: no Even glasses client connected for this session",
|
|
596
|
+
);
|
|
597
|
+
err.code = "glasses_not_connected";
|
|
598
|
+
throw err;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
let refreshValidated;
|
|
602
|
+
if (params.spec && params.spec.refresh !== undefined) {
|
|
603
|
+
const glassesUiLiveCfg = deps.getGlassesUiLiveConfig ? deps.getGlassesUiLiveConfig() : { enabled: true };
|
|
604
|
+
const v = validateRefreshSpec(params.spec.refresh, glassesUiLiveCfg);
|
|
605
|
+
if (!v.ok) {
|
|
606
|
+
const err = new Error(`${v.code}: ${v.message}`);
|
|
607
|
+
err.code = v.code;
|
|
608
|
+
throw err;
|
|
609
|
+
}
|
|
610
|
+
refreshValidated = v.refresh;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
const depth = Number.isFinite(params.depth) ? Math.max(1, Math.floor(params.depth)) : 1;
|
|
614
|
+
const update =
|
|
615
|
+
params.spec && (params.spec.update === "patch" || params.spec.update === "push")
|
|
616
|
+
? params.spec.update
|
|
617
|
+
: "replace";
|
|
618
|
+
// The plugin owns surfaceIds (spec §Core model). applyRender derives the
|
|
619
|
+
// target from the session's current top: patch/replace reuse the top id
|
|
620
|
+
// (re-attach in place), push mints a child + pauses the parent cron, the
|
|
621
|
+
// first render mints a root. This is the single place a surfaceId is bound.
|
|
622
|
+
const applied = surfaceStore.applyRender(sessionKey, {
|
|
623
|
+
update,
|
|
624
|
+
kind: validation.spec.kind,
|
|
625
|
+
});
|
|
626
|
+
const surfaceId = applied.surfaceId;
|
|
627
|
+
const promise = surfaceStore.register(sessionKey, surfaceId, { kind: validation.spec.kind });
|
|
628
|
+
// Re-attach flush (last-wins queue / latched exit): only for a patch/replace
|
|
629
|
+
// onto an already-attached surface (the visible_awaiting_agent window can
|
|
630
|
+
// only exist on a surface that previously resolved a call). A fresh
|
|
631
|
+
// root/push has an empty queue and stays visible_pending. onReattached
|
|
632
|
+
// delivers any queued nonterminal against the call we just established, or —
|
|
633
|
+
// if an exit was latched — returns "discarded_for_exit" so this render is
|
|
634
|
+
// dropped and the surface tears down (spec §Tool-call accounting). This is
|
|
635
|
+
// move-independent: replace (the schema default) carries the prior entry's
|
|
636
|
+
// latched exit / queued event forward (Task 8 makeEntry).
|
|
637
|
+
if (applied.mode === "patch" || applied.mode === "replace") {
|
|
638
|
+
const reattach = surfaceStore.onReattached(surfaceId);
|
|
639
|
+
if (reattach === "discarded_for_exit") {
|
|
640
|
+
// onReattached already resolved THIS render's pending call with the
|
|
641
|
+
// latched terminal outcome (so `promise` is settled). Tear down instead
|
|
642
|
+
// of painting the discarded render.
|
|
643
|
+
if (cronEngine.isActive(surfaceId)) cronEngine.stop(surfaceId, { result: "dismissed" });
|
|
644
|
+
surfaceStore.exit(sessionKey);
|
|
645
|
+
return promise;
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// Initial render uses the agent's seed (instant). Routed through the
|
|
650
|
+
// paint-floor coalescer as a leading-edge render sentinel so it shares the
|
|
651
|
+
// single send chokepoint (a render supersedes any queued field patch for
|
|
652
|
+
// this surface; see glasses-ui-paint-floor mergePatch).
|
|
653
|
+
paintFloor.enqueue({
|
|
654
|
+
surfaceId,
|
|
655
|
+
sessionKey,
|
|
656
|
+
patch: { __render: true, __depth: depth, __spec: validation.spec },
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
// Live-refresh path: kick off the cron in parallel. A `patch` onto an
|
|
660
|
+
// already-ticking surface leaves its cron alone (spec: "cron keeps
|
|
661
|
+
// ticking"); every other move (replace/push/root) starts a cron for the new
|
|
662
|
+
// content when this render carries a refresh.
|
|
663
|
+
if (refreshValidated && !(update === "patch" && cronEngine.isActive(surfaceId))) {
|
|
664
|
+
// Pre-warm the LLM API key cache so tick 1 doesn't see an empty key
|
|
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.
|
|
668
|
+
if (refreshValidated.recipe.kind === "llm" && typeof deps.prewarmLlmApiKey === "function") {
|
|
669
|
+
const cfg = deps.getGlassesUiLiveConfig ? deps.getGlassesUiLiveConfig() : {};
|
|
670
|
+
const agentModel =
|
|
671
|
+
typeof refreshValidated.recipe.model === "string" &&
|
|
672
|
+
refreshValidated.recipe.model.trim() &&
|
|
673
|
+
cfg.allowAgentModelOverride === true
|
|
674
|
+
? refreshValidated.recipe.model.trim()
|
|
675
|
+
: null;
|
|
676
|
+
const prewarmModel = agentModel || cfg.tickModel || "";
|
|
677
|
+
if (prewarmModel) {
|
|
678
|
+
try {
|
|
679
|
+
await deps.prewarmLlmApiKey(prewarmModel);
|
|
680
|
+
} catch (_) {
|
|
681
|
+
// Cache stays empty; tick 1 will fail and the cron resolves
|
|
682
|
+
// recipe_failed with a useful error from the backend.
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
cronEngine.start({
|
|
687
|
+
surfaceId,
|
|
688
|
+
sessionKey,
|
|
689
|
+
refresh: refreshValidated,
|
|
690
|
+
seedBody: validation.spec.body,
|
|
691
|
+
seedItems: validation.spec.items
|
|
692
|
+
? validation.spec.items.map((it) =>
|
|
693
|
+
typeof it === "string"
|
|
694
|
+
? it
|
|
695
|
+
: (it && typeof it.label === "string"
|
|
696
|
+
? (typeof it.body === "string" ? { label: it.label, body: it.body } : { label: it.label })
|
|
697
|
+
: ""),
|
|
698
|
+
)
|
|
699
|
+
: undefined,
|
|
700
|
+
onResolve: (cronOutcome) => {
|
|
701
|
+
// The cron-produced outcome (with ticks + lastBody) becomes the tool
|
|
702
|
+
// result. capturedCronOutcome lets the terminal-via-user path read
|
|
703
|
+
// the merged outcome. Only the cron's OWN terminal outcomes resolve
|
|
704
|
+
// the pending call here (recipe_failed / timeout / external stop);
|
|
705
|
+
// user-action results resolve via onGlassesUiResult above.
|
|
706
|
+
const capture = capturedCronOutcome.get(surfaceId);
|
|
707
|
+
if (capture) capture(cronOutcome);
|
|
708
|
+
if (isTerminalOutcome(cronOutcome)) {
|
|
709
|
+
// Settle the in-flight call; if none is pending (the surface is in
|
|
710
|
+
// visible_awaiting_agent after a nonterminal user action and the
|
|
711
|
+
// cron then hit its own terminal — recipe_failed / maxDuration
|
|
712
|
+
// timeout), QUEUE the terminal so the agent's next render's
|
|
713
|
+
// onReattached returns discarded_for_exit and tears the surface
|
|
714
|
+
// down. Without this fallback the dead-cron surface would persist.
|
|
715
|
+
// Symmetric with the onGlassesUiResult terminal path.
|
|
716
|
+
if (!surfaceStore.resolve(surfaceId, cronOutcome)) {
|
|
717
|
+
surfaceStore.queueEvent(surfaceId, cronOutcome);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
},
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// Bound the wait via the existing timeout knob — disabled if cron is
|
|
725
|
+
// active (cron has its own maxDurationMs cap and the user explicitly
|
|
726
|
+
// owns the surface lifetime through dismiss).
|
|
727
|
+
const timeoutMs = Number.isFinite(params.timeoutMs)
|
|
728
|
+
? params.timeoutMs
|
|
729
|
+
: resolveHandlerTimeoutMs();
|
|
730
|
+
if (!refreshValidated && Number.isFinite(timeoutMs) && timeoutMs > 0) {
|
|
731
|
+
const setTimeoutFn =
|
|
732
|
+
deps && typeof deps.setTimeout === "function" ? deps.setTimeout : setTimeout;
|
|
733
|
+
const clearTimeoutFn =
|
|
734
|
+
deps && typeof deps.clearTimeout === "function" ? deps.clearTimeout : clearTimeout;
|
|
735
|
+
const handle = setTimeoutFn(() => {
|
|
736
|
+
// Resolves only if the entry is still pending; surfaceStore.resolve is a
|
|
737
|
+
// no-op when the client already produced a real outcome. timeout is
|
|
738
|
+
// terminal, so it also moves the surface to `exiting`.
|
|
739
|
+
surfaceStore.resolve(surfaceId, { result: "timeout", timeout_ms: timeoutMs });
|
|
740
|
+
}, timeoutMs);
|
|
741
|
+
return promise.then((outcome) => {
|
|
742
|
+
clearTimeoutFn(handle);
|
|
743
|
+
return outcome;
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
// Decoupled lifecycle: the call resolves on the user's action (via
|
|
747
|
+
// onGlassesUiResult) or the cron's terminal outcome (via onResolve). A
|
|
748
|
+
// nonterminal selected/back resolves the call WITHOUT stopping the cron —
|
|
749
|
+
// the surface (and its cron) persists until a terminal or a drain. So the
|
|
750
|
+
// old "stop the cron after any resolved outcome" tail is gone.
|
|
751
|
+
return promise;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// Per-session last-seen depth, used only to distinguish push (depth up) from
|
|
755
|
+
// pop (depth down) on the client nav-event. The surfaceIds + pause/resume
|
|
756
|
+
// live in surfaceStore (the single source of truth) — there is NO second
|
|
757
|
+
// stack here. On push the parent cron was already paused by applyRender
|
|
758
|
+
// during the agent's push render, so push is idempotent here; on pop we drive
|
|
759
|
+
// surfaceStore.popBack, which stops the child cron and staleness-resumes the
|
|
760
|
+
// parent (Spike B: the plugin owns the resume target).
|
|
761
|
+
const navDepthBySession = new Map(); // sessionKey -> lastSeenDepth
|
|
762
|
+
|
|
763
|
+
function handleNavEvent(sessionKey, ev) {
|
|
764
|
+
const newDepth = Number.isFinite(ev.depth) ? Math.max(1, Math.floor(ev.depth)) : 1;
|
|
765
|
+
const lastDepth = navDepthBySession.get(sessionKey) || surfaceStore.stackDepth(sessionKey) || 1;
|
|
766
|
+
const storeDepthBefore = surfaceStore.stackDepth(sessionKey);
|
|
767
|
+
let popCount = 0;
|
|
768
|
+
let resumedParent = null;
|
|
769
|
+
if (newDepth < lastDepth) {
|
|
770
|
+
// Pop(s): the client popped locally. Reconcile the store to the reported
|
|
771
|
+
// depth — each popBack stops the child cron + resumes the new parent.
|
|
772
|
+
let guard = 0;
|
|
773
|
+
while (surfaceStore.stackDepth(sessionKey) > newDepth && guard < 64) {
|
|
774
|
+
resumedParent = surfaceStore.popBack(sessionKey);
|
|
775
|
+
popCount += 1;
|
|
776
|
+
guard += 1;
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
// Push (newDepth > lastDepth) is already reflected in the store by the
|
|
780
|
+
// agent's push render (applyRender), so it is intentionally a no-op here.
|
|
781
|
+
emitLifecycle("nav_reconcile", "debug", {
|
|
782
|
+
sessionKey,
|
|
783
|
+
evSurfaceId: ev.surfaceId,
|
|
784
|
+
evDepth: ev.depth,
|
|
785
|
+
newDepth,
|
|
786
|
+
lastDepth,
|
|
787
|
+
storeDepthBefore,
|
|
788
|
+
popCount,
|
|
789
|
+
resumedParent,
|
|
790
|
+
});
|
|
791
|
+
navDepthBySession.set(sessionKey, newDepth);
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
return {
|
|
795
|
+
runDynamicUi,
|
|
796
|
+
handleNavEvent,
|
|
797
|
+
drainSession(sessionKey, outcome) {
|
|
798
|
+
cronEngine.stopAllForSession(sessionKey, outcome);
|
|
799
|
+
// Resolve pending + delete entries FIRST (so pending calls settle with
|
|
800
|
+
// `outcome`), THEN clear the per-session stack. exit() deletes entries
|
|
801
|
+
// without resolving, so it must NOT run before the drain or the pending
|
|
802
|
+
// promises would hang.
|
|
803
|
+
const reaped = surfaceStore.drainSession(sessionKey, outcome);
|
|
804
|
+
surfaceStore.exit(sessionKey); // clear the stack (crons already stopped)
|
|
805
|
+
navDepthBySession.delete(sessionKey); // drop stale nav depth (stack is now 0)
|
|
806
|
+
return reaped;
|
|
807
|
+
},
|
|
808
|
+
drainAll(outcome) {
|
|
809
|
+
cronEngine.stopAll(outcome);
|
|
810
|
+
const reaped = surfaceStore.drainAll(outcome);
|
|
811
|
+
for (const sessionKey of surfaceStore.sessionKeys()) {
|
|
812
|
+
surfaceStore.exit(sessionKey);
|
|
813
|
+
}
|
|
814
|
+
navDepthBySession.clear();
|
|
815
|
+
return reaped;
|
|
816
|
+
},
|
|
817
|
+
isCronActive(surfaceId) {
|
|
818
|
+
return cronEngine.isActive(surfaceId);
|
|
819
|
+
},
|
|
820
|
+
isCronPaused(surfaceId) {
|
|
821
|
+
const st = cronEngine._debugState(surfaceId);
|
|
822
|
+
return !!(st && st.paused);
|
|
823
|
+
},
|
|
824
|
+
surfaceStackDepth(sessionKey) {
|
|
825
|
+
return surfaceStore.stackDepth(sessionKey);
|
|
826
|
+
},
|
|
827
|
+
sessionForSurface(surfaceId) {
|
|
828
|
+
return surfaceStore.sessionForSurface(surfaceId);
|
|
829
|
+
},
|
|
830
|
+
};
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
export const GLASSES_UI_TOOL_DESCRIPTION = [
|
|
834
|
+
"Render a dynamic interface on the user's Even G2 glasses HUD instead of",
|
|
835
|
+
"replying with text. Three surface kinds:",
|
|
836
|
+
" text_surface — one formatted read-only block (≤1000 chars).",
|
|
837
|
+
" list_surface — a short pickable list, label-only (≤20 × 64 chars).",
|
|
838
|
+
" list_with_details_surface — a pickable list where each item also carries a",
|
|
839
|
+
" short detail body (≤200 chars) shown as the user",
|
|
840
|
+
" scrolls; use when options need a 1-2 sentence",
|
|
841
|
+
" compare-before-choosing detail.",
|
|
842
|
+
"The call blocks until the user selects, dismisses, or backs out. result is one",
|
|
843
|
+
"of: selected, back, dismissed, timeout, recipe_failed, glasses_disconnected.",
|
|
844
|
+
"",
|
|
845
|
+
"Optional params:",
|
|
846
|
+
" refresh — make the surface self-update on a timer (e.g. live host stats via",
|
|
847
|
+
" the built-in system-stats tier). The plugin runs a recipe and",
|
|
848
|
+
" patches the surface in place until the user exits.",
|
|
849
|
+
" update — how this render relates to the current surface: \"patch\" (edit",
|
|
850
|
+
" fields; cron keeps ticking), \"replace\" (default; swap content in",
|
|
851
|
+
" place, no back-target), \"push\" (stack a child screen; the parent",
|
|
852
|
+
" is retained and its cron pauses, resuming on back).",
|
|
853
|
+
"",
|
|
854
|
+
"Before authoring any refreshing/live surface, per-item detail list, or",
|
|
855
|
+
"multi-screen flow, load the \"glasses-ui\" skill — it is the authoring source",
|
|
856
|
+
"of truth: the capability-tier ladder (system-stats host metrics, http data),",
|
|
857
|
+
"picking the lowest tier, recipe recon, the patch/replace/push moves and",
|
|
858
|
+
"exit-to-chat policy, the {{path|filter}} template + per-item {label,body}",
|
|
859
|
+
"reference, and worked examples (including a live system-stats",
|
|
860
|
+
"list_with_details surface). Keep this description lean; depth lives in the skill.",
|
|
861
|
+
].join("\n");
|
|
862
|
+
|
|
863
|
+
// Shared per-session depth counter. OpenClaw loads the plugin's register(api)
|
|
864
|
+
// in multiple isolated contexts (gateway startup, per-agent-run tool discovery)
|
|
865
|
+
// and each call to registerGlassesUiTool would otherwise close over its own
|
|
866
|
+
// Map. execute() runs in the per-run context's closure while api.on("agent_end",
|
|
867
|
+
// ...) fires from an earlier global-context closure, so reset-on-end would miss
|
|
868
|
+
// the live counter. Stashing the map on globalThis under a stable Symbol gives
|
|
869
|
+
// every load context the same Map to read and mutate.
|
|
870
|
+
const DEPTH_MAP_SYMBOL = Symbol.for("ocuclaw.glasses-ui.depthBySession");
|
|
871
|
+
function getSharedDepthMap() {
|
|
872
|
+
let m = globalThis[DEPTH_MAP_SYMBOL];
|
|
873
|
+
if (!(m instanceof Map)) {
|
|
874
|
+
m = new Map();
|
|
875
|
+
globalThis[DEPTH_MAP_SYMBOL] = m;
|
|
876
|
+
}
|
|
877
|
+
return m;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
export function registerGlassesUiTool(api, service) {
|
|
881
|
+
if (!api || typeof api.registerTool !== "function") {
|
|
882
|
+
throw new Error("registerGlassesUiTool requires api.registerTool");
|
|
883
|
+
}
|
|
884
|
+
if (!service) {
|
|
885
|
+
throw new Error("registerGlassesUiTool requires the OcuClaw relay service");
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
const depthBySession = getSharedDepthMap();
|
|
889
|
+
|
|
890
|
+
function nextDepth(sessionKey) {
|
|
891
|
+
const prev = depthBySession.get(sessionKey) || 0;
|
|
892
|
+
const next = prev + 1;
|
|
893
|
+
depthBySession.set(sessionKey, next);
|
|
894
|
+
return next;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
function resetDepth(sessionKey) {
|
|
898
|
+
if (sessionKey) {
|
|
899
|
+
depthBySession.delete(sessionKey);
|
|
900
|
+
} else {
|
|
901
|
+
depthBySession.clear();
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
async function resolveLlmApiKey(modelRef) {
|
|
906
|
+
if (!modelRef) return "";
|
|
907
|
+
try {
|
|
908
|
+
if (
|
|
909
|
+
api.runtime &&
|
|
910
|
+
api.runtime.modelAuth &&
|
|
911
|
+
typeof api.runtime.modelAuth.getApiKeyForModel === "function"
|
|
912
|
+
) {
|
|
913
|
+
const cfg = api.config;
|
|
914
|
+
const key = await api.runtime.modelAuth.getApiKeyForModel({ model: modelRef, cfg });
|
|
915
|
+
return typeof key === "string" ? key : "";
|
|
916
|
+
}
|
|
917
|
+
} catch (_) {
|
|
918
|
+
/* fall through */
|
|
919
|
+
}
|
|
920
|
+
return "";
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
// Inline resolution: the cron engine asks for the API key once per tick
|
|
924
|
+
// for the current model. We cache the last-resolved model→key pair across
|
|
925
|
+
// ticks since model rarely changes within a cron. runDynamicUi awaits
|
|
926
|
+
// prewarmLlmApiKey before starting the cron, so tick 1 sees the resolved
|
|
927
|
+
// key. resolveLlmApiKeySync is only called after the prewarm has populated
|
|
928
|
+
// the cache; if it ever runs uncached it returns "" and the backend
|
|
929
|
+
// reports a useful error (cron resolves recipe_failed via the breaker).
|
|
930
|
+
let lastModel = null;
|
|
931
|
+
let lastKey = "";
|
|
932
|
+
async function prewarmLlmApiKey(modelRef) {
|
|
933
|
+
if (!modelRef || modelRef === lastModel) return;
|
|
934
|
+
const key = await resolveLlmApiKey(modelRef);
|
|
935
|
+
lastModel = modelRef;
|
|
936
|
+
lastKey = key;
|
|
937
|
+
}
|
|
938
|
+
function resolveLlmApiKeySync(modelRef) {
|
|
939
|
+
if (modelRef === lastModel) return lastKey;
|
|
940
|
+
// Fallback: prewarm wasn't called (or model changed mid-cron). Kick
|
|
941
|
+
// off async resolution but return empty for this tick.
|
|
942
|
+
resolveLlmApiKey(modelRef).then((key) => {
|
|
943
|
+
lastModel = modelRef;
|
|
944
|
+
lastKey = key;
|
|
945
|
+
});
|
|
946
|
+
return "";
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
const handler = createGlassesUiToolHandler({
|
|
950
|
+
relay: {
|
|
951
|
+
sendGlassesUiRender: (msg) => service.sendGlassesUiRender(msg),
|
|
952
|
+
sendGlassesUiSurfaceUpdate: (msg) => service.sendGlassesUiSurfaceUpdate(msg),
|
|
953
|
+
onGlassesUiResult: (cb) => service.onGlassesUiResult(cb),
|
|
954
|
+
},
|
|
955
|
+
emitLifecycle: (event, severity, data) => {
|
|
956
|
+
try {
|
|
957
|
+
if (service && typeof service.emitGlassesUiLifecycle === "function") {
|
|
958
|
+
service.emitGlassesUiLifecycle(event, severity, data);
|
|
959
|
+
}
|
|
960
|
+
} catch (_) {
|
|
961
|
+
// observability must never break the tool path
|
|
962
|
+
}
|
|
963
|
+
},
|
|
964
|
+
getGlassesUiLiveConfig: () => {
|
|
965
|
+
try {
|
|
966
|
+
const cfg = service.getRuntimeConfig && service.getRuntimeConfig();
|
|
967
|
+
return cfg && cfg.glassesUiLive ? cfg.glassesUiLive : { enabled: false };
|
|
968
|
+
} catch (_) {
|
|
969
|
+
return { enabled: false };
|
|
970
|
+
}
|
|
971
|
+
},
|
|
972
|
+
resolveLlmApiKey: resolveLlmApiKeySync,
|
|
973
|
+
prewarmLlmApiKey,
|
|
974
|
+
timeoutMs: () => {
|
|
975
|
+
// Live-read so config hot-reloads (`openclawctl config set …`) take
|
|
976
|
+
// effect on the next render without a gateway restart. A non-finite
|
|
977
|
+
// or <=0 value disables the timeout (infinite wait — the pre-2026-05-23
|
|
978
|
+
// behaviour, kept available as an escape hatch).
|
|
979
|
+
try {
|
|
980
|
+
const cfg = service.getRuntimeConfig && service.getRuntimeConfig();
|
|
981
|
+
const v = cfg && cfg.renderGlassesUiTimeoutMs;
|
|
982
|
+
return Number.isFinite(v) ? v : DEFAULT_RENDER_GLASSES_UI_TIMEOUT_MS;
|
|
983
|
+
} catch (_) {
|
|
984
|
+
return DEFAULT_RENDER_GLASSES_UI_TIMEOUT_MS;
|
|
985
|
+
}
|
|
986
|
+
},
|
|
987
|
+
isSessionConnected: () => {
|
|
988
|
+
// Consult the shared relay singleton (works across plugin-load
|
|
989
|
+
// contexts) for any connected downstream client. Per-session
|
|
990
|
+
// connection tracking would need deeper plumbing; the global check
|
|
991
|
+
// catches the common failure mode where the tool is invoked with no
|
|
992
|
+
// glasses client at all, which is what produced the indefinite hangs
|
|
993
|
+
// before this gate existed.
|
|
994
|
+
if (typeof service.hasConnectedAppClient === "function") {
|
|
995
|
+
return service.hasConnectedAppClient();
|
|
996
|
+
}
|
|
997
|
+
return false;
|
|
998
|
+
},
|
|
999
|
+
isUnderBackpressure: () => {
|
|
1000
|
+
// The paint-floor coalescer sheds the trailing send while the relay/BLE
|
|
1001
|
+
// send buffer is over its high-water mark (Spike D — there is no
|
|
1002
|
+
// glass-side paint-ack, so transport pressure is the only signal). The
|
|
1003
|
+
// signal source is relay-health-monitor's send-buffer high-water; until
|
|
1004
|
+
// relay-service surfaces it as isGlassesSendBufferOverHighWater this
|
|
1005
|
+
// returns false (safe default — no shedding). Completing this query is
|
|
1006
|
+
// part of the BLE-backpressure hardening validated on hardware (Task 20).
|
|
1007
|
+
try {
|
|
1008
|
+
return typeof service.isGlassesSendBufferOverHighWater === "function"
|
|
1009
|
+
? service.isGlassesSendBufferOverHighWater()
|
|
1010
|
+
: false;
|
|
1011
|
+
} catch (_) {
|
|
1012
|
+
return false;
|
|
1013
|
+
}
|
|
1014
|
+
},
|
|
1015
|
+
});
|
|
1016
|
+
|
|
1017
|
+
// Wire glasses-disconnect to stop any active crons for the affected
|
|
1018
|
+
// session. drainSession is also called by agent_end below; this path is
|
|
1019
|
+
// distinct because a disconnect may happen mid-run without an agent_end.
|
|
1020
|
+
if (typeof service.onAppClientDisconnect === "function") {
|
|
1021
|
+
service.onAppClientDisconnect(({ sessionKey }) => {
|
|
1022
|
+
const target = sessionKey || null;
|
|
1023
|
+
if (target) {
|
|
1024
|
+
handler.drainSession(target, { result: "glasses_disconnected" });
|
|
1025
|
+
} else {
|
|
1026
|
+
handler.drainAll({ result: "glasses_disconnected" });
|
|
1027
|
+
}
|
|
1028
|
+
});
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
// Reconcile client nav-events with the SINGLE store stack (Task 16). On pop
|
|
1032
|
+
// the client reports the surfaceId now back on top + the post-pop depth; the
|
|
1033
|
+
// store knows it. The relay frame carries no sessionKey, so resolve it from
|
|
1034
|
+
// the surface's store entry (sessionForSurface), falling back to "main".
|
|
1035
|
+
if (typeof service.onGlassesUiNavEvent === "function") {
|
|
1036
|
+
service.onGlassesUiNavEvent((ev) => {
|
|
1037
|
+
const sessionKey = handler.sessionForSurface(ev.surfaceId) || "main";
|
|
1038
|
+
handler.handleNavEvent(sessionKey, ev);
|
|
1039
|
+
});
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
function resolveDedicatedEvenAiSessionKey() {
|
|
1043
|
+
try {
|
|
1044
|
+
return service?.getRuntimeConfig?.()?.evenAiDedicatedSessionKey || null;
|
|
1045
|
+
} catch (_) {
|
|
1046
|
+
return null;
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
api.registerTool(
|
|
1051
|
+
(ctx) => {
|
|
1052
|
+
// Hide the tool from Even AI quick-action runs (dedicated or throwaway
|
|
1053
|
+
// sessions). Those runs' responses go back to the Even Realities native
|
|
1054
|
+
// app — not the OcuClaw chat surface — so a glasses popup wouldn't be
|
|
1055
|
+
// reachable for the user. The tool stays visible to:
|
|
1056
|
+
// • the normal OcuClaw chat path (ocuclaw:<timestamp> sessions)
|
|
1057
|
+
// • the Even AI listen-mode path (which intercepts the HTTP
|
|
1058
|
+
// request and routes it through the active OcuClaw session, so
|
|
1059
|
+
// the sessionKey is an ocuclaw:<timestamp>, not ocuclaw:even-ai*).
|
|
1060
|
+
const sessionKey = ctx && typeof ctx.sessionKey === "string" ? ctx.sessionKey : "";
|
|
1061
|
+
if (isEvenAiAgentSession(sessionKey, resolveDedicatedEvenAiSessionKey())) {
|
|
1062
|
+
return null;
|
|
1063
|
+
}
|
|
1064
|
+
const factorySessionKey = sessionKey || null;
|
|
1065
|
+
return {
|
|
1066
|
+
name: "render_glasses_ui",
|
|
1067
|
+
description: GLASSES_UI_TOOL_DESCRIPTION,
|
|
1068
|
+
parameters: glassesUiParametersSchema,
|
|
1069
|
+
async execute(_toolCallId, params) {
|
|
1070
|
+
const resolvedSessionKey = factorySessionKey || "main";
|
|
1071
|
+
const depth = nextDepth(resolvedSessionKey);
|
|
1072
|
+
try {
|
|
1073
|
+
const outcome = await handler.runDynamicUi({
|
|
1074
|
+
sessionKey: resolvedSessionKey,
|
|
1075
|
+
depth,
|
|
1076
|
+
spec: params,
|
|
1077
|
+
});
|
|
1078
|
+
return {
|
|
1079
|
+
content: [{ type: "text", text: JSON.stringify(outcome) }],
|
|
1080
|
+
};
|
|
1081
|
+
} catch (err) {
|
|
1082
|
+
const prev = depthBySession.get(resolvedSessionKey) || 0;
|
|
1083
|
+
depthBySession.set(resolvedSessionKey, Math.max(0, prev - 1));
|
|
1084
|
+
throw err;
|
|
1085
|
+
}
|
|
1086
|
+
},
|
|
1087
|
+
};
|
|
1088
|
+
},
|
|
1089
|
+
{ name: "render_glasses_ui" },
|
|
1090
|
+
);
|
|
1091
|
+
|
|
1092
|
+
if (typeof api.on === "function") {
|
|
1093
|
+
api.on("agent_end", (_event, ctx) => {
|
|
1094
|
+
const sessionKey = ctx && typeof ctx.sessionKey === "string" ? ctx.sessionKey : null;
|
|
1095
|
+
// Drain any still-pending render for this session before clearing the
|
|
1096
|
+
// depth counter. Normal-completion runs will have already resolved
|
|
1097
|
+
// their tool call; this branch is the safety net for runs torn down
|
|
1098
|
+
// externally (timeout, abort) so the in-memory map doesn't leak
|
|
1099
|
+
// across runs.
|
|
1100
|
+
if (sessionKey) {
|
|
1101
|
+
handler.drainSession(sessionKey, { result: "preempted" });
|
|
1102
|
+
}
|
|
1103
|
+
resetDepth(sessionKey);
|
|
1104
|
+
});
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
return function dispose() {
|
|
1108
|
+
handler.drainAll({ result: "preempted" });
|
|
1109
|
+
depthBySession.clear();
|
|
1110
|
+
};
|
|
1111
|
+
}
|