ocuclaw 1.3.3 → 1.3.4
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 +29 -1
- package/dist/config/runtime-config-session-title-model.test.js +0 -3
- package/dist/config/runtime-config.js +22 -33
- package/dist/domain/activity-status-adapter.js +0 -7
- package/dist/domain/activity-status-arbiter.js +3 -27
- package/dist/domain/activity-status-labels.js +8 -38
- package/dist/domain/code-span-regions.js +4 -24
- package/dist/domain/constant-time-equal.js +9 -0
- package/dist/domain/constant-time-equal.test.js +28 -0
- package/dist/domain/conversation-state.js +27 -138
- package/dist/domain/debug-bundle-cache.js +52 -0
- package/dist/domain/debug-bundle-format.js +60 -0
- package/dist/domain/debug-bundle-preview.js +123 -0
- package/dist/domain/debug-bundle-redaction.js +182 -0
- package/dist/domain/debug-bundle-save.js +11 -0
- package/dist/domain/debug-bundle-zip.js +15 -0
- package/dist/domain/debug-bundle.js +97 -0
- package/dist/domain/debug-store.js +6 -17
- package/dist/domain/debug-upload-preset.js +27 -0
- package/dist/domain/glasses-display-system-prompt.js +0 -5
- package/dist/domain/glasses-display-system-prompt.test.js +1 -1
- package/dist/domain/glasses-ui-content-summary.js +0 -6
- package/dist/domain/glasses-ui-system-prompt.test.js +1 -2
- package/dist/domain/message-emoji-allowlist.js +0 -7
- package/dist/domain/message-emoji-filter.js +3 -9
- package/dist/domain/neural-emoji-reactor-tag-config.js +3 -3
- package/dist/domain/prompt-channel-fragments.js +1 -10
- package/dist/domain/tagged-span-parser.js +3 -26
- package/dist/domain/tagged-span-strip.js +0 -7
- package/dist/even-ai/even-ai-endpoint.js +77 -24
- package/dist/even-ai/even-ai-run-waiter.js +0 -1
- package/dist/even-ai/even-ai-settings-store.js +11 -0
- package/dist/gateway/gateway-bridge.js +8 -9
- package/dist/gateway/gateway-timing-ledger.js +8 -6
- package/dist/gateway/openclaw-client.js +97 -297
- package/dist/gateway/sanitize-connect-reason.js +10 -0
- package/dist/gateway/sanitize-connect-reason.test.js +34 -0
- package/dist/index.js +3 -3
- package/dist/runtime/channel-two-hook.js +1 -6
- package/dist/runtime/container-env.js +1 -5
- package/dist/runtime/debug-bundle-handler.js +159 -0
- package/dist/runtime/display-toggle-states.js +6 -17
- package/dist/runtime/downstream-handler.js +682 -508
- package/dist/runtime/glasses-backpressure-latch.js +2 -24
- package/dist/runtime/ocuclaw-settings-store.js +10 -1
- package/dist/runtime/openclaw-host-version.js +5 -0
- package/dist/runtime/plugin-version-service.js +13 -6
- package/dist/runtime/provider-usage-select.js +0 -6
- package/dist/runtime/register-session-title-distiller.js +14 -16
- package/dist/runtime/relay-core.js +601 -290
- package/dist/runtime/relay-service.js +19 -47
- package/dist/runtime/relay-worker-approval-replay-cache.js +1 -1
- package/dist/runtime/relay-worker-entry.js +1 -2
- package/dist/runtime/relay-worker-health.js +2 -10
- package/dist/runtime/relay-worker-protocol.js +6 -1
- package/dist/runtime/relay-worker-supervisor.js +103 -41
- package/dist/runtime/relay-worker-transport.js +150 -17
- package/dist/runtime/session-context-service.js +5 -45
- package/dist/runtime/session-service.js +157 -175
- package/dist/runtime/session-title-distiller-budget.js +1 -5
- package/dist/runtime/session-title-distiller-helpers.js +14 -24
- package/dist/runtime/session-title-distiller.js +109 -122
- package/dist/runtime/session-title-record.js +0 -6
- package/dist/runtime/stable-prompt-snapshot.js +3 -14
- package/dist/runtime/upstream-runtime.js +600 -103
- package/dist/tools/device-info-tool.js +4 -21
- package/dist/tools/glasses-ui-cron.js +22 -77
- package/dist/tools/glasses-ui-descriptors.js +4 -33
- package/dist/tools/glasses-ui-limits.js +0 -13
- package/dist/tools/glasses-ui-paint-floor.js +5 -39
- package/dist/tools/glasses-ui-recipes.js +92 -101
- package/dist/tools/glasses-ui-surfaces.js +31 -163
- package/dist/tools/glasses-ui-template.js +7 -22
- package/dist/tools/glasses-ui-tool-description.test.js +2 -2
- package/dist/tools/glasses-ui-tool.js +87 -451
- package/dist/tools/glasses-ui-voicemail.js +6 -63
- package/dist/tools/glasses-ui-wake.js +9 -76
- package/dist/tools/session-title-tool.js +2 -7
- package/dist/tools/session-title-tool.test.js +1 -1
- package/dist/version.js +3 -2
- package/openclaw.plugin.json +60 -13
- package/package.json +3 -2
- package/dist/runtime/protocol-adapter.js +0 -387
|
@@ -1,7 +1,3 @@
|
|
|
1
|
-
// OcuClaw plugin tool: surface G2 glasses battery to the agent over a
|
|
2
|
-
// cross-process relay round-trip. Mirrors the render_glasses_ui pattern.
|
|
3
|
-
// G2 only; anything else returns a structured device_unavailable error.
|
|
4
|
-
|
|
5
1
|
import { randomUUID } from "node:crypto";
|
|
6
2
|
|
|
7
3
|
export const deviceInfoParametersSchema = {
|
|
@@ -31,7 +27,7 @@ export function validateDeviceInfoInput(input) {
|
|
|
31
27
|
}
|
|
32
28
|
|
|
33
29
|
export function createPendingDeviceInfoMap() {
|
|
34
|
-
const byRequest = new Map();
|
|
30
|
+
const byRequest = new Map();
|
|
35
31
|
|
|
36
32
|
function register(sessionKey, requestId) {
|
|
37
33
|
return new Promise((resolve) => {
|
|
@@ -74,8 +70,6 @@ export function createPendingDeviceInfoMap() {
|
|
|
74
70
|
return { register, resolve, drainSession, drainAll };
|
|
75
71
|
}
|
|
76
72
|
|
|
77
|
-
// Default round-trip timeout. WebUI → bridge.getDeviceInfo() is a local
|
|
78
|
-
// SDK call, so 10s is generous; experience may justify a config knob later.
|
|
79
73
|
export const DEFAULT_DEVICE_INFO_TIMEOUT_MS = 10_000;
|
|
80
74
|
|
|
81
75
|
export function createDeviceInfoToolHandler(deps) {
|
|
@@ -122,9 +116,7 @@ export function createDeviceInfoToolHandler(deps) {
|
|
|
122
116
|
try {
|
|
123
117
|
deps.relay.sendDeviceInfoRequest({ sessionKey, requestId });
|
|
124
118
|
} catch (sendErr) {
|
|
125
|
-
|
|
126
|
-
// relay not started). Resolve as device_unavailable so the awaiter
|
|
127
|
-
// sees a structured failure rather than hanging.
|
|
119
|
+
|
|
128
120
|
pending.resolve(requestId, { ok: false, code: "device_unavailable", requestId });
|
|
129
121
|
const outcome = await promise;
|
|
130
122
|
const code =
|
|
@@ -150,9 +142,7 @@ export function createDeviceInfoToolHandler(deps) {
|
|
|
150
142
|
if (timeoutHandle !== null) clearTimeoutFn(timeoutHandle);
|
|
151
143
|
if (outcome && outcome.ok === true) {
|
|
152
144
|
if (!outcome.data || typeof outcome.data !== "object") {
|
|
153
|
-
|
|
154
|
-
// device_unavailable rather than returning {} which the agent
|
|
155
|
-
// would describe as a successful empty snapshot.
|
|
145
|
+
|
|
156
146
|
const err = new Error(
|
|
157
147
|
"device_unavailable: device info response was ok but carried no data",
|
|
158
148
|
);
|
|
@@ -199,9 +189,7 @@ export function registerDeviceInfoTool(api, service) {
|
|
|
199
189
|
sendDeviceInfoRequest: (msg) => service.sendDeviceInfoRequest(msg),
|
|
200
190
|
onDeviceInfoResponse: (cb) => service.onDeviceInfoResponse(cb),
|
|
201
191
|
},
|
|
202
|
-
|
|
203
|
-
// passes is intentionally ignored. Per-session connectivity tracking would
|
|
204
|
-
// need deeper plumbing — same trade-off as glasses-ui-tool.ts:403-414.
|
|
192
|
+
|
|
205
193
|
isSessionConnected: (_sessionKey) => {
|
|
206
194
|
if (typeof service.hasConnectedAppClient === "function") {
|
|
207
195
|
return service.hasConnectedAppClient();
|
|
@@ -222,10 +210,6 @@ export function registerDeviceInfoTool(api, service) {
|
|
|
222
210
|
},
|
|
223
211
|
});
|
|
224
212
|
|
|
225
|
-
// Per-session drain on agent_end implements the spec's data-flow step 11:
|
|
226
|
-
// any in-flight device_info_request for an ending agent run resolves with
|
|
227
|
-
// a structured device_info_aborted rather than hanging until plugin stop.
|
|
228
|
-
// Mirrors the agent_end hook in glasses-ui-tool.ts.
|
|
229
213
|
if (typeof api.on === "function") {
|
|
230
214
|
api.on("agent_end", (_event, ctx) => {
|
|
231
215
|
const sessionKey = ctx && typeof ctx.sessionKey === "string" ? ctx.sessionKey : null;
|
|
@@ -235,7 +219,6 @@ export function registerDeviceInfoTool(api, service) {
|
|
|
235
219
|
});
|
|
236
220
|
}
|
|
237
221
|
|
|
238
|
-
// Plugin-stop catch-all that drains everything left over across sessions.
|
|
239
222
|
return function dispose() {
|
|
240
223
|
handler.drainAll({ ok: false, code: "device_info_aborted" });
|
|
241
224
|
};
|
|
@@ -1,21 +1,7 @@
|
|
|
1
|
-
// Per-surface live-refresh cron engine.
|
|
2
|
-
//
|
|
3
|
-
// Manages the smoke test + tick loop for one or more periodic glasses
|
|
4
|
-
// surfaces. Each surface is identified by surfaceId and tied to a sessionKey.
|
|
5
|
-
// The engine is single-process (in the OcuClaw plugin), holds an in-memory
|
|
6
|
-
// map of active crons, and resolves each cron with an outcome object when
|
|
7
|
-
// any exit condition fires.
|
|
8
|
-
//
|
|
9
|
-
// Tests inject `executeRecipe` and timer functions; production code wires
|
|
10
|
-
// the real executors from glasses-ui-recipes.ts and global setTimeout.
|
|
11
|
-
|
|
12
1
|
import { substituteTemplate } from "./glasses-ui-template.js";
|
|
13
2
|
|
|
14
3
|
const DEFAULT_FAILURE_BODY_PREFIX = "⚠ Update failed: ";
|
|
15
4
|
|
|
16
|
-
// Mirror GLASSES_UI_LIMITS so live patches respect the same caps as the initial
|
|
17
|
-
// render. Caller-supplied glassesUiLimits dep overrides these defaults so the
|
|
18
|
-
// tool can pass through whatever validateGlassesUiSpec uses without coupling.
|
|
19
5
|
const DEFAULT_GLASSES_UI_LIMITS = {
|
|
20
6
|
bodyMax: 1000,
|
|
21
7
|
itemMax: 64,
|
|
@@ -23,11 +9,6 @@ const DEFAULT_GLASSES_UI_LIMITS = {
|
|
|
23
9
|
maxItems: 20,
|
|
24
10
|
};
|
|
25
11
|
|
|
26
|
-
// Exponential backoff applied to the NEXT tick's delay after consecutive
|
|
27
|
-
// failures, capped so a long-down dependency can't starve the schedule
|
|
28
|
-
// forever. Base = the recipe interval; cap = 60s; doubles per consecutive
|
|
29
|
-
// failure. A successful tick resets to the base interval. Does not change the
|
|
30
|
-
// breaker count (maxConsecutiveFailures still governs terminal stop).
|
|
31
12
|
const BACKOFF_CAP_MS = 60_000;
|
|
32
13
|
|
|
33
14
|
export function createGlassesUiCronEngine(deps) {
|
|
@@ -36,20 +17,17 @@ export function createGlassesUiCronEngine(deps) {
|
|
|
36
17
|
const resolveLlmCtx = deps.resolveLlmCtx || (() => ({}));
|
|
37
18
|
const setTimeoutFn = deps.setTimeoutFn || setTimeout;
|
|
38
19
|
const clearTimeoutFn = deps.clearTimeoutFn || clearTimeout;
|
|
39
|
-
|
|
40
|
-
// came from mixing Date.now() with performance.now()). Defaults to
|
|
41
|
-
// performance.now() so production wiring is correct even if a caller forgets.
|
|
20
|
+
|
|
42
21
|
const monotonicNowMs =
|
|
43
22
|
typeof deps.monotonicNowMs === "function" ? deps.monotonicNowMs : () => performance.now();
|
|
44
23
|
const limits = deps.glassesUiLimits && typeof deps.glassesUiLimits === "object"
|
|
45
24
|
? { ...DEFAULT_GLASSES_UI_LIMITS, ...deps.glassesUiLimits }
|
|
46
25
|
: DEFAULT_GLASSES_UI_LIMITS;
|
|
47
|
-
|
|
48
|
-
// when the dep is absent (tests) or the debug category is disabled.
|
|
26
|
+
|
|
49
27
|
const emitLifecycle =
|
|
50
28
|
typeof deps.emitLifecycle === "function" ? deps.emitLifecycle : () => {};
|
|
51
29
|
|
|
52
|
-
const active = new Map();
|
|
30
|
+
const active = new Map();
|
|
53
31
|
|
|
54
32
|
function emitSurfaceUpdate(state, patch) {
|
|
55
33
|
try {
|
|
@@ -61,10 +39,7 @@ export function createGlassesUiCronEngine(deps) {
|
|
|
61
39
|
paused: !!state.paused,
|
|
62
40
|
});
|
|
63
41
|
} catch (err) {
|
|
64
|
-
|
|
65
|
-
// as an unhandled rejection — it would lock the surface until
|
|
66
|
-
// maxDurationMs (30min default). Record as a tick failure so the
|
|
67
|
-
// breaker / onError policy can decide what to do on the next tick.
|
|
42
|
+
|
|
68
43
|
state.tickFailed += 1;
|
|
69
44
|
state.lastFailureAt = Date.now();
|
|
70
45
|
state.failureReason = `relay send failed: ${err && err.message ? err.message : err}`;
|
|
@@ -93,16 +68,12 @@ export function createGlassesUiCronEngine(deps) {
|
|
|
93
68
|
state.nextTickTimer = null;
|
|
94
69
|
state.maxDurationTimer = null;
|
|
95
70
|
active.delete(state.surfaceId);
|
|
96
|
-
|
|
97
|
-
// cron slot is being RECYCLED (a replace render swapping the surface's
|
|
98
|
-
// content in place) — a synthesized outcome here would reach
|
|
99
|
-
// surfaceStore.resolve with no pending call and LATCH a bogus exit that
|
|
100
|
-
// discards the very render doing the replacing (B7, found 2026-06-11).
|
|
71
|
+
|
|
101
72
|
if (opts && opts.silent === true) return;
|
|
102
73
|
try {
|
|
103
74
|
state.onResolve(makeOutcome(state, extra));
|
|
104
75
|
} catch (_) {
|
|
105
|
-
|
|
76
|
+
|
|
106
77
|
}
|
|
107
78
|
}
|
|
108
79
|
|
|
@@ -114,8 +85,7 @@ export function createGlassesUiCronEngine(deps) {
|
|
|
114
85
|
}
|
|
115
86
|
|
|
116
87
|
function substituteOneItemTemplate(tpl, dataForTemplate, opts) {
|
|
117
|
-
|
|
118
|
-
// backward compatible with list_surface). Caps: label/itemMax, body/detailBodyMax.
|
|
88
|
+
|
|
119
89
|
if (tpl && typeof tpl === "object" && !Array.isArray(tpl)) {
|
|
120
90
|
const labelRaw =
|
|
121
91
|
typeof tpl.label === "string" ? substituteTemplate(tpl.label, dataForTemplate, opts) : "";
|
|
@@ -154,15 +124,11 @@ export function createGlassesUiCronEngine(deps) {
|
|
|
154
124
|
const result = {};
|
|
155
125
|
if (typeof targets.body === "string") {
|
|
156
126
|
const body = substituteTemplate(targets.body, dataForTemplate, opts);
|
|
157
|
-
|
|
158
|
-
// can't blast the relay or glasses with a multi-MB body per tick.
|
|
127
|
+
|
|
159
128
|
result.body = typeof body === "string" ? body.slice(0, limits.bodyMax) : body;
|
|
160
129
|
}
|
|
161
130
|
if (Array.isArray(targets.items)) {
|
|
162
|
-
|
|
163
|
-
// templates beyond that is pure waste. validateRefreshSpec already rejects
|
|
164
|
-
// over-long arrays; this is defense-in-depth. Each entry is a string template
|
|
165
|
-
// (label-only) or a {label, body} template object (list_with_details bodies).
|
|
131
|
+
|
|
166
132
|
result.items = targets.items
|
|
167
133
|
.slice(0, limits.maxItems)
|
|
168
134
|
.map((tpl) => substituteOneItemTemplate(tpl, dataForTemplate, opts));
|
|
@@ -185,9 +151,7 @@ export function createGlassesUiCronEngine(deps) {
|
|
|
185
151
|
}
|
|
186
152
|
|
|
187
153
|
if (state.resolved) return;
|
|
188
|
-
|
|
189
|
-
// (generation bumped) while this recipe was running, drop its result so
|
|
190
|
-
// it can't patch a hidden screen. Stats already counted the attempt.
|
|
154
|
+
|
|
191
155
|
if (tickGeneration !== state.generationToken) {
|
|
192
156
|
return;
|
|
193
157
|
}
|
|
@@ -216,7 +180,7 @@ export function createGlassesUiCronEngine(deps) {
|
|
|
216
180
|
emitSurfaceUpdate(state, { body: errorBody });
|
|
217
181
|
}
|
|
218
182
|
}
|
|
219
|
-
|
|
183
|
+
|
|
220
184
|
} else if (result && Object.prototype.hasOwnProperty.call(result, "output")) {
|
|
221
185
|
state.tickSucceeded += 1;
|
|
222
186
|
state.lastSuccessAt = Date.now();
|
|
@@ -252,9 +216,6 @@ export function createGlassesUiCronEngine(deps) {
|
|
|
252
216
|
}
|
|
253
217
|
}
|
|
254
218
|
|
|
255
|
-
// Schedule next tick. A run of failures backs off exponentially (capped);
|
|
256
|
-
// a Retry-After from the recipe (e.g. http 429) overrides the computed
|
|
257
|
-
// delay; a clean tick uses the base interval.
|
|
258
219
|
if (!state.resolved && !state.isSmokeTest && !state.paused) {
|
|
259
220
|
const base = state.refresh.intervalMs;
|
|
260
221
|
let delay = base;
|
|
@@ -275,14 +236,13 @@ export function createGlassesUiCronEngine(deps) {
|
|
|
275
236
|
state.isSmokeTest = true;
|
|
276
237
|
await runOneTick(state);
|
|
277
238
|
state.isSmokeTest = false;
|
|
278
|
-
if (state.resolved) return;
|
|
279
|
-
|
|
280
|
-
// failure on tick 1 short-circuits to recipe_failed regardless of policy.
|
|
239
|
+
if (state.resolved) return;
|
|
240
|
+
|
|
281
241
|
if (state.tickFailed > 0) {
|
|
282
242
|
resolveAndClean(state, { result: "recipe_failed" });
|
|
283
243
|
return;
|
|
284
244
|
}
|
|
285
|
-
|
|
245
|
+
|
|
286
246
|
state.nextTickTimer = setTimeoutFn(() => {
|
|
287
247
|
state.nextTickTimer = null;
|
|
288
248
|
runOneTick(state);
|
|
@@ -311,10 +271,7 @@ export function createGlassesUiCronEngine(deps) {
|
|
|
311
271
|
resolved: false,
|
|
312
272
|
nextTickTimer: null,
|
|
313
273
|
maxDurationTimer: null,
|
|
314
|
-
|
|
315
|
-
// ACTIVE time only. remainingMs is banked on pause and re-armed on
|
|
316
|
-
// resume; armedAtMs is monotonic (never wall-clock — the 2026-06-01
|
|
317
|
-
// freeze came from mixing clocks).
|
|
274
|
+
|
|
318
275
|
maxDurationRemainingMs: params.refresh.maxDurationMs,
|
|
319
276
|
maxDurationArmedAtMs: null,
|
|
320
277
|
isSmokeTest: false,
|
|
@@ -324,20 +281,17 @@ export function createGlassesUiCronEngine(deps) {
|
|
|
324
281
|
pendingRetryAfterMs: null,
|
|
325
282
|
};
|
|
326
283
|
active.set(state.surfaceId, state);
|
|
327
|
-
|
|
284
|
+
|
|
328
285
|
state.maxDurationArmedAtMs = monotonicNowMs();
|
|
329
286
|
state.maxDurationTimer = setTimeoutFn(() => {
|
|
330
|
-
|
|
331
|
-
// invisible — bounded tick-silence watches were the only evidence.
|
|
287
|
+
|
|
332
288
|
emitLifecycle("cron_max_duration_reached", "debug", {
|
|
333
289
|
surfaceId: state.surfaceId,
|
|
334
290
|
sessionKey: state.sessionKey,
|
|
335
291
|
});
|
|
336
292
|
resolveAndClean(state, { result: "timeout" });
|
|
337
293
|
}, params.refresh.maxDurationMs);
|
|
338
|
-
|
|
339
|
-
// doesn't become an unhandled-rejection — instead resolve the cron
|
|
340
|
-
// with a recipe_failed outcome carrying the rejection message.
|
|
294
|
+
|
|
341
295
|
runSmokeTest(state).catch((err) => {
|
|
342
296
|
resolveAndClean(state, {
|
|
343
297
|
result: "recipe_failed",
|
|
@@ -393,11 +347,7 @@ export function createGlassesUiCronEngine(deps) {
|
|
|
393
347
|
clearTimeoutFn(state.nextTickTimer);
|
|
394
348
|
state.nextTickTimer = null;
|
|
395
349
|
}
|
|
396
|
-
|
|
397
|
-
// bank the remaining ACTIVE budget. Pre-6e the cap kept running while
|
|
398
|
-
// the parent hid under a pushed child — hardware-observed 2026-06-11
|
|
399
|
-
// as cron_resume {found:false} → Back landed on a frozen list. Guarded
|
|
400
|
-
// on the timer handle so a double-pause cannot double-deduct.
|
|
350
|
+
|
|
401
351
|
if (state.maxDurationTimer) {
|
|
402
352
|
clearTimeoutFn(state.maxDurationTimer);
|
|
403
353
|
state.maxDurationTimer = null;
|
|
@@ -408,8 +358,7 @@ export function createGlassesUiCronEngine(deps) {
|
|
|
408
358
|
state.maxDurationArmedAtMs = null;
|
|
409
359
|
}
|
|
410
360
|
state.paused = true;
|
|
411
|
-
|
|
412
|
-
// resolves — it must not patch the now-hidden parent screen.
|
|
361
|
+
|
|
413
362
|
state.generationToken += 1;
|
|
414
363
|
emitLifecycle("cron_pause", "debug", { surfaceId, found: true, resolved: false });
|
|
415
364
|
return true;
|
|
@@ -425,9 +374,7 @@ export function createGlassesUiCronEngine(deps) {
|
|
|
425
374
|
});
|
|
426
375
|
return false;
|
|
427
376
|
}
|
|
428
|
-
|
|
429
|
-
// 6e). An exhausted budget resolves terminal timeout here instead of
|
|
430
|
-
// re-arming a dead cron — honest death beats a zombie surface.
|
|
377
|
+
|
|
431
378
|
if (!state.maxDurationTimer) {
|
|
432
379
|
if (state.maxDurationRemainingMs <= 0) {
|
|
433
380
|
emitLifecycle("cron_resume", "debug", {
|
|
@@ -465,9 +412,7 @@ export function createGlassesUiCronEngine(deps) {
|
|
|
465
412
|
branch: elapsed >= intervalMs ? "refire" : "schedule",
|
|
466
413
|
});
|
|
467
414
|
if (elapsed >= intervalMs) {
|
|
468
|
-
|
|
469
|
-
// failure is terminal regardless of onError policy). runOneTick
|
|
470
|
-
// re-arms the next tick itself.
|
|
415
|
+
|
|
471
416
|
runOneTick(state);
|
|
472
417
|
} else {
|
|
473
418
|
state.nextTickTimer = setTimeoutFn(() => {
|
|
@@ -1,26 +1,5 @@
|
|
|
1
|
-
// Kind-descriptor registry for the glasses-UI surface kinds. Each descriptor
|
|
2
|
-
// is a self-contained unit owning everything per-kind on the PLUGIN side:
|
|
3
|
-
// - schemaBranch: the JSON-Schema oneOf entry for this kind
|
|
4
|
-
// - validateSpec: per-kind validation, returns the canonical
|
|
5
|
-
// { ok:true, spec } | { ok:false, code, message } shape
|
|
6
|
-
// - refreshTargets: which refresh.targets fields this kind binds
|
|
7
|
-
// ("body" for text, "items" for the list kinds) —
|
|
8
|
-
// informational in Phase 1, consumed in Phase 3.
|
|
9
|
-
// The core (glassesUiParametersSchema, validateGlassesUiSpec) dispatches by
|
|
10
|
-
// kind STRING through this registry and never switches on a specific kind, so
|
|
11
|
-
// adding a kind is one descriptor with zero core edits (spec §Modularity).
|
|
12
|
-
// Phase 1 ports the existing 3 kinds with NO behavior change.
|
|
13
|
-
//
|
|
14
|
-
// NOTE: this module imports GLASSES_UI_LIMITS from ./glasses-ui-limits.js (a
|
|
15
|
-
// dependency-free leaf), NOT from ./glasses-ui-tool.js — glasses-ui-tool.js
|
|
16
|
-
// imports THIS module, so importing back from it would form a require cycle
|
|
17
|
-
// that the CJS emitter cannot resolve (module.exports is written at
|
|
18
|
-
// end-of-module, so a mid-cycle require sees {}). See glasses-ui-limits.ts.
|
|
19
|
-
|
|
20
1
|
import { GLASSES_UI_LIMITS } from "./glasses-ui-limits.js";
|
|
21
2
|
|
|
22
|
-
// Shared title validation (moved from validateGlassesUiSpec — every kind ran
|
|
23
|
-
// it before its branch). Returns an error result or null.
|
|
24
3
|
function validateTitle(obj) {
|
|
25
4
|
if (typeof obj.title === "undefined") return null;
|
|
26
5
|
if (typeof obj.title !== "string") {
|
|
@@ -36,7 +15,6 @@ function validateTitle(obj) {
|
|
|
36
15
|
return null;
|
|
37
16
|
}
|
|
38
17
|
|
|
39
|
-
// ---- text_surface ------------------------------------------------------
|
|
40
18
|
const textSurfaceDescriptor = {
|
|
41
19
|
kind: "text_surface",
|
|
42
20
|
refreshTargets: ["body"],
|
|
@@ -48,7 +26,7 @@ const textSurfaceDescriptor = {
|
|
|
48
26
|
kind: { const: "text_surface" },
|
|
49
27
|
title: { type: "string", maxLength: GLASSES_UI_LIMITS.titleMax },
|
|
50
28
|
body: { type: "string", maxLength: GLASSES_UI_LIMITS.bodyMax },
|
|
51
|
-
refresh: undefined,
|
|
29
|
+
refresh: undefined,
|
|
52
30
|
},
|
|
53
31
|
},
|
|
54
32
|
validateSpec(obj) {
|
|
@@ -71,7 +49,6 @@ const textSurfaceDescriptor = {
|
|
|
71
49
|
},
|
|
72
50
|
};
|
|
73
51
|
|
|
74
|
-
// ---- list_surface ------------------------------------------------------
|
|
75
52
|
const listSurfaceDescriptor = {
|
|
76
53
|
kind: "list_surface",
|
|
77
54
|
refreshTargets: ["items"],
|
|
@@ -124,7 +101,6 @@ const listSurfaceDescriptor = {
|
|
|
124
101
|
},
|
|
125
102
|
};
|
|
126
103
|
|
|
127
|
-
// ---- list_with_details_surface ----------------------------------------
|
|
128
104
|
const listWithDetailsSurfaceDescriptor = {
|
|
129
105
|
kind: "list_with_details_surface",
|
|
130
106
|
refreshTargets: ["items"],
|
|
@@ -154,11 +130,7 @@ const listWithDetailsSurfaceDescriptor = {
|
|
|
154
130
|
validateSpec(obj) {
|
|
155
131
|
const titleErr = validateTitle(obj);
|
|
156
132
|
if (titleErr) return titleErr;
|
|
157
|
-
|
|
158
|
-
// parallel-array shape — items=[strings] plus a sibling `details` /
|
|
159
|
-
// `itemDetails` / `bodies` array — instead of the canonical [{label, body?}]
|
|
160
|
-
// objects. We accept the parallel-array shape and coerce it server-side so
|
|
161
|
-
// the wire format downstream stays canonical.
|
|
133
|
+
|
|
162
134
|
const rawItems = obj.items;
|
|
163
135
|
if (!Array.isArray(rawItems) || rawItems.length === 0) {
|
|
164
136
|
return {
|
|
@@ -182,15 +154,14 @@ const listWithDetailsSurfaceDescriptor = {
|
|
|
182
154
|
const items = rawItems.map((entry, i) => {
|
|
183
155
|
if (typeof entry === "string") {
|
|
184
156
|
const sibling = parallelBodies ? parallelBodies[i] : undefined;
|
|
185
|
-
|
|
186
|
-
// {label, body} object that duplicates the label — accept either.
|
|
157
|
+
|
|
187
158
|
if (typeof sibling === "string") {
|
|
188
159
|
return { label: entry, body: sibling };
|
|
189
160
|
}
|
|
190
161
|
if (sibling && typeof sibling === "object" && typeof sibling.body === "string") {
|
|
191
162
|
return { label: entry, body: sibling.body };
|
|
192
163
|
}
|
|
193
|
-
|
|
164
|
+
|
|
194
165
|
return { label: entry };
|
|
195
166
|
}
|
|
196
167
|
return entry;
|
|
@@ -1,16 +1,3 @@
|
|
|
1
|
-
// Shared per-kind size caps for the glasses-UI surface tool. Extracted into its
|
|
2
|
-
// own leaf module so BOTH the tool (glasses-ui-tool.ts) and the kind-descriptor
|
|
3
|
-
// registry (glasses-ui-descriptors.ts) can import the same limits WITHOUT
|
|
4
|
-
// forming an import cycle between those two modules.
|
|
5
|
-
//
|
|
6
|
-
// Why a leaf module is required (not a cycle): the plugin's CJS emitter
|
|
7
|
-
// (scripts/build.mjs) converts `import {X} from "y"` into an in-place
|
|
8
|
-
// `const {X} = require("y.cjs")` and appends `module.exports = {...}` only at
|
|
9
|
-
// END of module execution (no live getters). Under those semantics a
|
|
10
|
-
// bidirectional descriptors<->tool require resolves to `{}` mid-cycle and
|
|
11
|
-
// crashes (GLASSES_UI_LIMITS undefined / listKindStrings not a function). Giving
|
|
12
|
-
// the limits their own dependency-free module makes the graph a DAG
|
|
13
|
-
// (tool -> {limits, descriptors}; descriptors -> limits), so no cycle exists.
|
|
14
1
|
export const GLASSES_UI_LIMITS = {
|
|
15
2
|
bodyMax: 1000,
|
|
16
3
|
itemMax: 64,
|
|
@@ -1,22 +1,3 @@
|
|
|
1
|
-
// Per-surface trailing-edge paint-floor coalescer.
|
|
2
|
-
//
|
|
3
|
-
// Governs ALL plugin->glass sends (initial RebuildPageContainer render and
|
|
4
|
-
// every surface_update patch). Collapses bursts to last-write-wins per field
|
|
5
|
-
// and emits at most one frame per paintFloorMs, with a leading-edge send + a
|
|
6
|
-
// trailing send carrying the final merged patch.
|
|
7
|
-
//
|
|
8
|
-
// There is NO glass-side paint-ack: the only backpressure signal is
|
|
9
|
-
// relay/BLE transport-side (see the isUnderBackpressure shed in Task 13).
|
|
10
|
-
// Local fake-list textContainerUpgrade scroll-swaps are client-side and never
|
|
11
|
-
// reach this coalescer.
|
|
12
|
-
//
|
|
13
|
-
// 250 is the unconditionally hardware-proven floor. Spike D approved lowering
|
|
14
|
-
// to 150 ONLY once the backpressure shed has a live signal; the shed's
|
|
15
|
-
// isGlassesSendBufferOverHighWater query is implemented nowhere yet, so the
|
|
16
|
-
// shed is inert and 150 would run without its safety condition. Restore 150
|
|
17
|
-
// when the relay-service bridge lands and is validated on hardware
|
|
18
|
-
// (roadmap step 4, docs/superpowers/plans/2026-06-10-glasses-ui-state-reset-and-roadmap.md).
|
|
19
|
-
|
|
20
1
|
export const DEFAULT_PAINT_FLOOR_MS = 250;
|
|
21
2
|
|
|
22
3
|
export function createPaintFloorCoalescer(deps) {
|
|
@@ -28,7 +9,6 @@ export function createPaintFloorCoalescer(deps) {
|
|
|
28
9
|
const isUnderBackpressure =
|
|
29
10
|
typeof deps.isUnderBackpressure === "function" ? deps.isUnderBackpressure : () => false;
|
|
30
11
|
|
|
31
|
-
// surfaceId -> { sessionKey, lastSentAt, pendingPatch, timer }
|
|
32
12
|
const bySurface = new Map();
|
|
33
13
|
|
|
34
14
|
function isRenderSentinel(p) {
|
|
@@ -44,23 +24,12 @@ export function createPaintFloorCoalescer(deps) {
|
|
|
44
24
|
}
|
|
45
25
|
|
|
46
26
|
function mergePatch(base, incoming) {
|
|
47
|
-
|
|
48
|
-
// patch ({ body }/{ items }/{ title }) must NEVER shallow-merge — a merged
|
|
49
|
-
// object would carry both __spec and a stray `body`, painting a malformed
|
|
50
|
-
// frame. They are different write kinds, so the LATER write supersedes the
|
|
51
|
-
// earlier one wholesale (a render replaces a queued field patch; a field
|
|
52
|
-
// patch after a queued render replaces that render). Same-kind writes
|
|
53
|
-
// merge last-write-wins per field as before.
|
|
54
|
-
//
|
|
55
|
-
// Marker-only field patch must NEVER drop a queued render (7a): merge the
|
|
56
|
-
// marker onto the render sentinel instead of superseding it wholesale.
|
|
27
|
+
|
|
57
28
|
if (isMarkerOnly(incoming) && isRenderSentinel(base)) {
|
|
58
29
|
return { ...base, __marker: incoming.marker };
|
|
59
30
|
}
|
|
60
31
|
if (isRenderSentinel(base) !== isRenderSentinel(incoming)) {
|
|
61
|
-
|
|
62
|
-
// queued field patch; a content field patch replaces a queued render).
|
|
63
|
-
// Marker is sticky — carry it forward if the survivor didn't set one.
|
|
32
|
+
|
|
64
33
|
const next = incoming && typeof incoming === "object" ? { ...incoming } : {};
|
|
65
34
|
if (markerOf(next) === undefined) {
|
|
66
35
|
const m = markerOf(base);
|
|
@@ -80,10 +49,7 @@ export function createPaintFloorCoalescer(deps) {
|
|
|
80
49
|
if (!st || !st.pendingPatch) return;
|
|
81
50
|
st.timer = null;
|
|
82
51
|
if (isUnderBackpressure()) {
|
|
83
|
-
|
|
84
|
-
// water mark. Retain pendingPatch (last-write-wins) and re-arm so the
|
|
85
|
-
// final merged value lands once pressure clears. No glass-side ack
|
|
86
|
-
// exists, so transport-side pressure is the only signal.
|
|
52
|
+
|
|
87
53
|
st.timer = setTimeoutFn(() => flush(surfaceId), Math.max(16, paintFloorMs));
|
|
88
54
|
return;
|
|
89
55
|
}
|
|
@@ -103,12 +69,12 @@ export function createPaintFloorCoalescer(deps) {
|
|
|
103
69
|
st.sessionKey = sessionKey;
|
|
104
70
|
const elapsed = nowMs() - st.lastSentAt;
|
|
105
71
|
if (elapsed >= paintFloorMs && !st.timer) {
|
|
106
|
-
|
|
72
|
+
|
|
107
73
|
st.lastSentAt = nowMs();
|
|
108
74
|
send({ surfaceId, sessionKey, patch });
|
|
109
75
|
return;
|
|
110
76
|
}
|
|
111
|
-
|
|
77
|
+
|
|
112
78
|
st.pendingPatch = mergePatch(st.pendingPatch, patch);
|
|
113
79
|
if (!st.timer) {
|
|
114
80
|
const wait = Math.max(0, paintFloorMs - elapsed);
|