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.
Files changed (83) hide show
  1. package/README.md +29 -1
  2. package/dist/config/runtime-config-session-title-model.test.js +0 -3
  3. package/dist/config/runtime-config.js +22 -33
  4. package/dist/domain/activity-status-adapter.js +0 -7
  5. package/dist/domain/activity-status-arbiter.js +3 -27
  6. package/dist/domain/activity-status-labels.js +8 -38
  7. package/dist/domain/code-span-regions.js +4 -24
  8. package/dist/domain/constant-time-equal.js +9 -0
  9. package/dist/domain/constant-time-equal.test.js +28 -0
  10. package/dist/domain/conversation-state.js +27 -138
  11. package/dist/domain/debug-bundle-cache.js +52 -0
  12. package/dist/domain/debug-bundle-format.js +60 -0
  13. package/dist/domain/debug-bundle-preview.js +123 -0
  14. package/dist/domain/debug-bundle-redaction.js +182 -0
  15. package/dist/domain/debug-bundle-save.js +11 -0
  16. package/dist/domain/debug-bundle-zip.js +15 -0
  17. package/dist/domain/debug-bundle.js +97 -0
  18. package/dist/domain/debug-store.js +6 -17
  19. package/dist/domain/debug-upload-preset.js +27 -0
  20. package/dist/domain/glasses-display-system-prompt.js +0 -5
  21. package/dist/domain/glasses-display-system-prompt.test.js +1 -1
  22. package/dist/domain/glasses-ui-content-summary.js +0 -6
  23. package/dist/domain/glasses-ui-system-prompt.test.js +1 -2
  24. package/dist/domain/message-emoji-allowlist.js +0 -7
  25. package/dist/domain/message-emoji-filter.js +3 -9
  26. package/dist/domain/neural-emoji-reactor-tag-config.js +3 -3
  27. package/dist/domain/prompt-channel-fragments.js +1 -10
  28. package/dist/domain/tagged-span-parser.js +3 -26
  29. package/dist/domain/tagged-span-strip.js +0 -7
  30. package/dist/even-ai/even-ai-endpoint.js +77 -24
  31. package/dist/even-ai/even-ai-run-waiter.js +0 -1
  32. package/dist/even-ai/even-ai-settings-store.js +11 -0
  33. package/dist/gateway/gateway-bridge.js +8 -9
  34. package/dist/gateway/gateway-timing-ledger.js +8 -6
  35. package/dist/gateway/openclaw-client.js +97 -297
  36. package/dist/gateway/sanitize-connect-reason.js +10 -0
  37. package/dist/gateway/sanitize-connect-reason.test.js +34 -0
  38. package/dist/index.js +3 -3
  39. package/dist/runtime/channel-two-hook.js +1 -6
  40. package/dist/runtime/container-env.js +1 -5
  41. package/dist/runtime/debug-bundle-handler.js +159 -0
  42. package/dist/runtime/display-toggle-states.js +6 -17
  43. package/dist/runtime/downstream-handler.js +682 -508
  44. package/dist/runtime/glasses-backpressure-latch.js +2 -24
  45. package/dist/runtime/ocuclaw-settings-store.js +10 -1
  46. package/dist/runtime/openclaw-host-version.js +5 -0
  47. package/dist/runtime/plugin-version-service.js +13 -6
  48. package/dist/runtime/provider-usage-select.js +0 -6
  49. package/dist/runtime/register-session-title-distiller.js +14 -16
  50. package/dist/runtime/relay-core.js +601 -290
  51. package/dist/runtime/relay-service.js +19 -47
  52. package/dist/runtime/relay-worker-approval-replay-cache.js +1 -1
  53. package/dist/runtime/relay-worker-entry.js +1 -2
  54. package/dist/runtime/relay-worker-health.js +2 -10
  55. package/dist/runtime/relay-worker-protocol.js +6 -1
  56. package/dist/runtime/relay-worker-supervisor.js +103 -41
  57. package/dist/runtime/relay-worker-transport.js +150 -17
  58. package/dist/runtime/session-context-service.js +5 -45
  59. package/dist/runtime/session-service.js +157 -175
  60. package/dist/runtime/session-title-distiller-budget.js +1 -5
  61. package/dist/runtime/session-title-distiller-helpers.js +14 -24
  62. package/dist/runtime/session-title-distiller.js +109 -122
  63. package/dist/runtime/session-title-record.js +0 -6
  64. package/dist/runtime/stable-prompt-snapshot.js +3 -14
  65. package/dist/runtime/upstream-runtime.js +600 -103
  66. package/dist/tools/device-info-tool.js +4 -21
  67. package/dist/tools/glasses-ui-cron.js +22 -77
  68. package/dist/tools/glasses-ui-descriptors.js +4 -33
  69. package/dist/tools/glasses-ui-limits.js +0 -13
  70. package/dist/tools/glasses-ui-paint-floor.js +5 -39
  71. package/dist/tools/glasses-ui-recipes.js +92 -101
  72. package/dist/tools/glasses-ui-surfaces.js +31 -163
  73. package/dist/tools/glasses-ui-template.js +7 -22
  74. package/dist/tools/glasses-ui-tool-description.test.js +2 -2
  75. package/dist/tools/glasses-ui-tool.js +87 -451
  76. package/dist/tools/glasses-ui-voicemail.js +6 -63
  77. package/dist/tools/glasses-ui-wake.js +9 -76
  78. package/dist/tools/session-title-tool.js +2 -7
  79. package/dist/tools/session-title-tool.test.js +1 -1
  80. package/dist/version.js +3 -2
  81. package/openclaw.plugin.json +60 -13
  82. package/package.json +3 -2
  83. 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(); // requestId -> { sessionKey, resolve }
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
- // Don't leak the pending entry if the relay rejects the send (e.g.
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
- // ok=true with no data violates the wire contract; treat as
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
- // Global "any app client connected" check; the sessionKey arg the handler
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
- // Monotonic clock for staleness math (never wall-clock; a prior freeze bug
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
- // Permanent glasses.lifecycle observability (cron pause/resume/tick). No-op
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(); // surfaceId -> state
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
- // A throwing relay (e.g. "ocuclaw relay not started") must not escape
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
- // silent: tear down timers/state WITHOUT firing onResolve. Used when the
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
- // swallow — caller's resolve handler is theirs to keep safe
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
- // Object template -> {label, body}; plain-string template -> string (label-only,
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
- // Clamp to the same caps the initial render uses, so a runaway recipe
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
- // Slice BEFORE map — only the first maxItems survive, so substituting
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
- // In-flight tick discard: if the surface was paused/popped/re-topped
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
- // keep_last: do nothing — preserve previous lastBody/lastItems.
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; // smoke failed and policy was stop/breaker
279
- // The smoke test must succeed before we begin the regular cadence — any
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
- // After the smoke test, kick off the regular schedule.
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
- // Pause-aware duration accounting (roadmap 6e): the cap measures
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
- // Arm the duration cap.
284
+
328
285
  state.maxDurationArmedAtMs = monotonicNowMs();
329
286
  state.maxDurationTimer = setTimeoutFn(() => {
330
- // Cron-death observability (Wave-1 e2e leg-4 gap): the cap firing was
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
- // start() is sync; runSmokeTest is async. Catch any rejection so it
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
- // Pause-aware duration cap (roadmap 6e): stop the wall-clock burn and
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
- // Bump so an in-flight tick (started before pause) is discarded when it
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
- // Re-arm the duration cap with the banked ACTIVE remainder (roadmap
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
- // Stale: refire now via the TICK path (NOT runSmokeTest — a smoke
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, // filled by the tool when assembling the schema
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
- // Empirically (2026-05-24 hardware logs), the agent often produces the
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
- // The sibling can be a plain string body, OR (some models do this) a
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
- // No matching parallel body — degrade gracefully to label-only.
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
- // A render sentinel ({ __render, __depth, __spec }, Task 13) and a field
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
- // Different write kinds: later supersedes wholesale (a render replaces a
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
- // Shed: do NOT send while the relay/BLE send buffer is over the high
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
- // Leading edge: send immediately.
72
+
107
73
  st.lastSentAt = nowMs();
108
74
  send({ surfaceId, sessionKey, patch });
109
75
  return;
110
76
  }
111
- // Within the floor: merge into the pending patch, arm a trailing flush.
77
+
112
78
  st.pendingPatch = mergePatch(st.pendingPatch, patch);
113
79
  if (!st.timer) {
114
80
  const wait = Math.max(0, paintFloorMs - elapsed);