ocuclaw 1.3.1 → 1.3.3

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 (40) hide show
  1. package/dist/config/runtime-config-session-title-model.test.js +22 -0
  2. package/dist/config/runtime-config.js +7 -1
  3. package/dist/domain/glasses-display-system-prompt.js +52 -0
  4. package/dist/domain/glasses-display-system-prompt.test.js +44 -0
  5. package/dist/domain/glasses-ui-system-prompt.js +6 -22
  6. package/dist/domain/glasses-ui-system-prompt.test.js +13 -0
  7. package/dist/domain/prompt-channel-fragments.js +32 -0
  8. package/dist/domain/prompt-channel-fragments.test.js +70 -0
  9. package/dist/gateway/gateway-timing-ledger.js +15 -3
  10. package/dist/gateway/openclaw-client.js +80 -3
  11. package/dist/index.js +22 -0
  12. package/dist/runtime/channel-two-hook.js +36 -0
  13. package/dist/runtime/container-env.js +41 -0
  14. package/dist/runtime/display-toggle-states.js +98 -0
  15. package/dist/runtime/glasses-backpressure-latch.js +115 -0
  16. package/dist/runtime/register-session-title-distiller.js +100 -0
  17. package/dist/runtime/relay-core.js +284 -33
  18. package/dist/runtime/relay-service.js +152 -13
  19. package/dist/runtime/relay-worker-entry.js +26 -0
  20. package/dist/runtime/relay-worker-supervisor.js +51 -2
  21. package/dist/runtime/relay-worker-transport.js +51 -1
  22. package/dist/runtime/session-service.js +136 -12
  23. package/dist/runtime/session-title-distiller-budget.js +36 -0
  24. package/dist/runtime/session-title-distiller-helpers.js +130 -0
  25. package/dist/runtime/session-title-distiller.js +354 -0
  26. package/dist/runtime/session-title-record.js +21 -0
  27. package/dist/runtime/stable-prompt-snapshot.js +119 -0
  28. package/dist/tools/glasses-ui-cron.js +59 -3
  29. package/dist/tools/glasses-ui-paint-floor.js +33 -4
  30. package/dist/tools/glasses-ui-surfaces.js +369 -35
  31. package/dist/tools/glasses-ui-tool-description.test.js +16 -0
  32. package/dist/tools/glasses-ui-tool.js +662 -80
  33. package/dist/tools/glasses-ui-voicemail.js +299 -0
  34. package/dist/tools/glasses-ui-wake.js +262 -0
  35. package/dist/tools/session-title-tool.js +14 -76
  36. package/dist/tools/session-title-tool.test.js +53 -0
  37. package/dist/version.js +2 -2
  38. package/openclaw.plugin.json +9 -0
  39. package/package.json +4 -3
  40. package/skills/glasses-ui/SKILL.md +26 -3
@@ -85,7 +85,7 @@ export function createGlassesUiCronEngine(deps) {
85
85
  return Object.assign({}, outcome, extra);
86
86
  }
87
87
 
88
- function resolveAndClean(state, extra) {
88
+ function resolveAndClean(state, extra, opts) {
89
89
  if (state.resolved) return;
90
90
  state.resolved = true;
91
91
  if (state.nextTickTimer) clearTimeoutFn(state.nextTickTimer);
@@ -93,6 +93,12 @@ export function createGlassesUiCronEngine(deps) {
93
93
  state.nextTickTimer = null;
94
94
  state.maxDurationTimer = null;
95
95
  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).
101
+ if (opts && opts.silent === true) return;
96
102
  try {
97
103
  state.onResolve(makeOutcome(state, extra));
98
104
  } catch (_) {
@@ -305,6 +311,12 @@ export function createGlassesUiCronEngine(deps) {
305
311
  resolved: false,
306
312
  nextTickTimer: null,
307
313
  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).
318
+ maxDurationRemainingMs: params.refresh.maxDurationMs,
319
+ maxDurationArmedAtMs: null,
308
320
  isSmokeTest: false,
309
321
  lastTickAt: null,
310
322
  generationToken: 0,
@@ -313,7 +325,14 @@ export function createGlassesUiCronEngine(deps) {
313
325
  };
314
326
  active.set(state.surfaceId, state);
315
327
  // Arm the duration cap.
328
+ state.maxDurationArmedAtMs = monotonicNowMs();
316
329
  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.
332
+ emitLifecycle("cron_max_duration_reached", "debug", {
333
+ surfaceId: state.surfaceId,
334
+ sessionKey: state.sessionKey,
335
+ });
317
336
  resolveAndClean(state, { result: "timeout" });
318
337
  }, params.refresh.maxDurationMs);
319
338
  // start() is sync; runSmokeTest is async. Catch any rejection so it
@@ -326,10 +345,10 @@ export function createGlassesUiCronEngine(deps) {
326
345
  });
327
346
  });
328
347
  },
329
- stop(surfaceId, outcome) {
348
+ stop(surfaceId, outcome, opts) {
330
349
  const state = active.get(surfaceId);
331
350
  if (!state) return false;
332
- resolveAndClean(state, outcome || { result: "preempted" });
351
+ resolveAndClean(state, outcome || { result: "preempted" }, opts);
333
352
  return true;
334
353
  },
335
354
  stopAllForSession(sessionKey, outcome) {
@@ -374,6 +393,20 @@ export function createGlassesUiCronEngine(deps) {
374
393
  clearTimeoutFn(state.nextTickTimer);
375
394
  state.nextTickTimer = null;
376
395
  }
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.
401
+ if (state.maxDurationTimer) {
402
+ clearTimeoutFn(state.maxDurationTimer);
403
+ state.maxDurationTimer = null;
404
+ state.maxDurationRemainingMs = Math.max(
405
+ 0,
406
+ state.maxDurationRemainingMs - (monotonicNowMs() - state.maxDurationArmedAtMs),
407
+ );
408
+ state.maxDurationArmedAtMs = null;
409
+ }
377
410
  state.paused = true;
378
411
  // Bump so an in-flight tick (started before pause) is discarded when it
379
412
  // resolves — it must not patch the now-hidden parent screen.
@@ -392,6 +425,29 @@ export function createGlassesUiCronEngine(deps) {
392
425
  });
393
426
  return false;
394
427
  }
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.
431
+ if (!state.maxDurationTimer) {
432
+ if (state.maxDurationRemainingMs <= 0) {
433
+ emitLifecycle("cron_resume", "debug", {
434
+ surfaceId,
435
+ found: true,
436
+ resolved: false,
437
+ branch: "max_duration_exhausted",
438
+ });
439
+ resolveAndClean(state, { result: "timeout" });
440
+ return false;
441
+ }
442
+ state.maxDurationArmedAtMs = monotonicNowMs();
443
+ state.maxDurationTimer = setTimeoutFn(() => {
444
+ emitLifecycle("cron_max_duration_reached", "debug", {
445
+ surfaceId: state.surfaceId,
446
+ sessionKey: state.sessionKey,
447
+ });
448
+ resolveAndClean(state, { result: "timeout" });
449
+ }, state.maxDurationRemainingMs);
450
+ }
395
451
  state.paused = false;
396
452
  if (state.nextTickTimer) {
397
453
  clearTimeoutFn(state.nextTickTimer);
@@ -2,15 +2,22 @@
2
2
  //
3
3
  // Governs ALL plugin->glass sends (initial RebuildPageContainer render and
4
4
  // every surface_update patch). Collapses bursts to last-write-wins per field
5
- // and emits at most one frame per paintFloorMs (default 150ms, Spike D), with
6
- // a leading-edge send + a trailing send carrying the final merged patch.
5
+ // and emits at most one frame per paintFloorMs, with a leading-edge send + a
6
+ // trailing send carrying the final merged patch.
7
7
  //
8
8
  // There is NO glass-side paint-ack: the only backpressure signal is
9
9
  // relay/BLE transport-side (see the isUnderBackpressure shed in Task 13).
10
10
  // Local fake-list textContainerUpgrade scroll-swaps are client-side and never
11
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).
12
19
 
13
- export const DEFAULT_PAINT_FLOOR_MS = 150;
20
+ export const DEFAULT_PAINT_FLOOR_MS = 250;
14
21
 
15
22
  export function createPaintFloorCoalescer(deps) {
16
23
  const paintFloorMs = Number.isFinite(deps.paintFloorMs) ? deps.paintFloorMs : DEFAULT_PAINT_FLOOR_MS;
@@ -28,6 +35,14 @@ export function createPaintFloorCoalescer(deps) {
28
35
  return !!(p && p.__render === true);
29
36
  }
30
37
 
38
+ function markerOf(p) {
39
+ if (!p) return undefined;
40
+ return p.__marker !== undefined ? p.__marker : p.marker;
41
+ }
42
+ function isMarkerOnly(p) {
43
+ return !!p && !isRenderSentinel(p) && Object.keys(p).length === 1 && p.marker !== undefined;
44
+ }
45
+
31
46
  function mergePatch(base, incoming) {
32
47
  // A render sentinel ({ __render, __depth, __spec }, Task 13) and a field
33
48
  // patch ({ body }/{ items }/{ title }) must NEVER shallow-merge — a merged
@@ -36,8 +51,22 @@ export function createPaintFloorCoalescer(deps) {
36
51
  // earlier one wholesale (a render replaces a queued field patch; a field
37
52
  // patch after a queued render replaces that render). Same-kind writes
38
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.
57
+ if (isMarkerOnly(incoming) && isRenderSentinel(base)) {
58
+ return { ...base, __marker: incoming.marker };
59
+ }
39
60
  if (isRenderSentinel(base) !== isRenderSentinel(incoming)) {
40
- return incoming && typeof incoming === "object" ? { ...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.
64
+ const next = incoming && typeof incoming === "object" ? { ...incoming } : {};
65
+ if (markerOf(next) === undefined) {
66
+ const m = markerOf(base);
67
+ if (m !== undefined) { if (isRenderSentinel(next)) next.__marker = m; else next.marker = m; }
68
+ }
69
+ return next;
41
70
  }
42
71
  const merged = base ? { ...base } : {};
43
72
  if (incoming && typeof incoming === "object") {