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,45 +1,17 @@
1
- // Per-session surface store for the glasses-UI tool. Extracted from
2
- // glasses-ui-tool.ts (Phase 1) so this stateful logic can be unit-tested in
3
- // isolation and so the tool file thins toward a schema+wiring layer (spec
4
- // §Lifecycle detail — Surface/stack lifecycle store).
5
- //
6
- // Phase 2 evolves the Phase-1 createPendingRenderMap IN PLACE into
7
- // createSurfaceStore (SINGLE store — there is never a second store alongside
8
- // it). The decoupled lifecycle means a surface's lifetime is independent of
9
- // any one tool call: resolve() settles the in-flight pending call but the
10
- // surface entry PERSISTS (keyed by surfaceId), and a new register() does NOT
11
- // preempt the previous surface. The legacy createPendingRenderMap name survives
12
- // only as an alias (bottom of this file) for the Phase 1 import path.
13
-
14
- // A terminal outcome ends the surface (teardown / exit). A nonterminal outcome
15
- // (selected/back) keeps the surface alive and hands the turn to the agent.
16
1
  const TERMINAL_RESULTS = new Set(["dismissed", "timeout", "glasses_disconnected", "preempted", "recipe_failed"]);
17
2
 
18
3
  export function isTerminalOutcome(outcome) {
19
4
  return !!(outcome && typeof outcome.result === "string" && TERMINAL_RESULTS.has(outcome.result));
20
5
  }
21
6
 
22
- // Provenance enum for GlassEvents and wake envelopes (roadmap 6b, §2.6).
23
- // Only "gesture" is an enabled wake origin at launch; the others reserve the
24
- // event-to-wake categories (schedule/threshold/system) so enabling them later
25
- // is a policy change, not a schema migration.
26
7
  export const GLASS_EVENT_ORIGINS = ["gesture", "schedule", "threshold", "system"];
27
8
 
28
- // Session keys reach the store in two forms: the canonical agent ctx form
29
- // ("agent:<id>:<key>", what registerTool factories and agent_end hooks see)
30
- // and the stripped relay form ("<key>", what relay-side callbacks like the
31
- // glasses-disconnect drain carry). The suffix is the identity; the prefix is
32
- // routing. Normalizing at the store boundary makes every caller agnostic —
33
- // the same lesson as the 6f busy-tracker key fix (drift #2, `0cc639b9`).
34
9
  export function normalizeGlassesSessionKey(key) {
35
10
  return typeof key === "string" ? key.replace(/^agent:[^:]+:/, "") : key;
36
11
  }
37
12
 
38
13
  export function createSurfaceStore(deps = {}) {
39
- // Census identity: every store instance carries a storeId so lifecycle
40
- // traces can tell WHICH plugin-load context's store handled a render/reap
41
- // (drift #3, 2026-06-12: a queued wake run's collect landed in a sibling
42
- // context's empty store and minted a phantom root — invisible without this).
14
+
43
15
  const storeId =
44
16
  typeof deps.storeId === "string" && deps.storeId
45
17
  ? deps.storeId
@@ -58,21 +30,13 @@ export function createSurfaceStore(deps = {}) {
58
30
  typeof deps.mintUuid === "function"
59
31
  ? deps.mintUuid
60
32
  : () => `su-${Math.random().toString(36).slice(2, 10)}${Math.random().toString(36).slice(2, 6)}`;
61
- // surfaceId -> { sessionKey, kind, pending: (resolver|null), lastContent,
62
- // state, queuedEvent, exitLatched, uuid, events, queueMode }
33
+
63
34
  const bySurface = new Map();
64
- const stackBySession = new Map(); // sessionKey -> [surfaceId, ...] (bottom→top)
65
-
66
- // --- Parked-outcome event log + dead-letter (roadmap 6a, §2.6) ---
67
- // Nonterminal outcomes append to a per-surface GlassEvent log; "latest" is
68
- // the default read-time delivery reducer ("log" declarable per surface).
69
- // Reap paths (drain/exit/popBack) hand undelivered events to a per-session
70
- // dead-letter instead of destroying them: a ✓-acked tap must NEVER silently
71
- // disappear (the 2026-06-12 panel's fatal). Wake/voicemail legs (6f/7b)
72
- // drain the dead-letter; this wave only guarantees survival.
73
- const DEAD_LETTER_EVENT_CAP = 32; // per session, FIFO eviction
74
- const SURFACE_EVENT_LOG_CAP = 32; // per surface, FIFO eviction
75
- const deadLetterBySession = new Map(); // sessionKey -> [{surfaceUuid, surfaceId, events, reason, reapedAtMs}]
35
+ const stackBySession = new Map();
36
+
37
+ const DEAD_LETTER_EVENT_CAP = 32;
38
+ const SURFACE_EVENT_LOG_CAP = 32;
39
+ const deadLetterBySession = new Map();
76
40
  let eventSeq = 0;
77
41
 
78
42
  function deadLetterFor(sessionKey) {
@@ -82,8 +46,7 @@ export function createSurfaceStore(deps = {}) {
82
46
  }
83
47
 
84
48
  function deadLetterEntryEvents(sessionKey, surfaceId, entry, reason) {
85
- // A latched terminal means the wearer ended the surface — parked
86
- // nonterminals beneath it are no longer intent to deliver.
49
+
87
50
  if (!entry || entry.exitLatched || !entry.events || entry.events.length === 0) return;
88
51
  const eventIds = entry.events.map((e) => e.eventId);
89
52
  const list = deadLetterFor(sessionKey);
@@ -93,13 +56,11 @@ export function createSurfaceStore(deps = {}) {
93
56
  events: entry.events,
94
57
  reason,
95
58
  reapedAtMs: now(),
96
- // The surface's declared staleness window travels with the reaped
97
- // events so the 6f/7b consumers (wake/voicemail) can frame age honestly.
59
+
98
60
  staleAfterMs: Number.isFinite(entry.staleAfterMs) ? entry.staleAfterMs : null,
99
61
  });
100
62
  entry.events = [];
101
- // Reap-path observability: a ✓-acked event leaving the live queue for the
102
- // dead-letter must be traceable (the contract is "never silently lost").
63
+
103
64
  emitLifecycle("dead_letter_appended", "debug", {
104
65
  sessionKey,
105
66
  surfaceId,
@@ -128,32 +89,19 @@ export function createSurfaceStore(deps = {}) {
128
89
  return s;
129
90
  }
130
91
 
131
- // A fresh entry starts un-latched with an empty queue. A `prior` entry (an
132
- // in-place content swap of the SAME surfaceId — i.e. `replace`) carries its
133
- // latched exit / last-wins queued event forward so reattach semantics are
134
- // MOVE-INDEPENDENT: a terminal latched (or a nonterminal queued) during
135
- // `visible_awaiting_agent` is honored on the agent's NEXT render whether that
136
- // render is patch, replace (the schema DEFAULT) or push — never silently
137
- // dropped by the rebuild. (Without this carry-forward, a `replace` rebuild
138
- // would reset exitLatched=false / queuedEvent=null and onReattached would
139
- // return "reattached" instead of "discarded_for_exit", failing to tear down.)
140
92
  function makeEntry(sessionKey, kind, prior) {
141
93
  return {
142
94
  sessionKey, kind: kind || null, pending: null, lastContent: null,
143
95
  state: "visible_pending",
144
96
  queuedEvent: prior ? prior.queuedEvent : null,
145
97
  exitLatched: prior ? !!prior.exitLatched : false,
146
- // Durable identity + parked-event log carry forward through replace
147
- // (same surfaceId, same surface as far as the wearer is concerned).
98
+
148
99
  uuid: prior ? prior.uuid : mintUuid(),
149
100
  events: prior ? prior.events : [],
150
101
  queueMode: prior && prior.queueMode === "log" ? "log" : "latest",
151
- // Per-render declaration (NOT carried through replace): register() sets
152
- // it from each render's meta, so the latest render's spec governs.
102
+
153
103
  staleAfterMs: null,
154
- // 7a: title carries forward through replace (same surface, wearer continuity).
155
- // awaitingAgentResponse resets on a fresh entry — a new render opens a new
156
- // window so markerFor returns "listening" anyway.
104
+
157
105
  title: prior ? prior.title : null,
158
106
  awaitingAgentResponse: false,
159
107
  };
@@ -164,10 +112,7 @@ export function createSurfaceStore(deps = {}) {
164
112
  return new Promise((resolve) => {
165
113
  const existing = bySurface.get(surfaceId);
166
114
  if (existing) {
167
- // Re-attach to an existing surface: replace its pending resolver,
168
- // keep the entry. No preempt of a different surface. Restore the
169
- // pending state so a re-render returns to visible_pending before
170
- // onReattached flushes any queued event / latched exit.
115
+
171
116
  existing.pending = resolve;
172
117
  if (meta && meta.kind) existing.kind = meta.kind;
173
118
  if (meta && (meta.queueMode === "log" || meta.queueMode === "latest")) {
@@ -175,7 +120,7 @@ export function createSurfaceStore(deps = {}) {
175
120
  }
176
121
  existing.staleAfterMs = meta && Number.isFinite(meta.staleAfterMs) ? meta.staleAfterMs : null;
177
122
  if (meta && typeof meta.title === "string") existing.title = meta.title;
178
- existing.awaitingAgentResponse = false; // a fresh render opens a new window
123
+ existing.awaitingAgentResponse = false;
179
124
  existing.sessionKey = sessionKey;
180
125
  existing.state = "visible_pending";
181
126
  return;
@@ -191,9 +136,6 @@ export function createSurfaceStore(deps = {}) {
191
136
  });
192
137
  }
193
138
 
194
- // Every delivered outcome carries the surface's durable identity (roadmap
195
- // 6b): agents and the 6f wake/voicemail consumers key on surfaceUuid, never
196
- // on the plugin-internal surfaceId. Additive only — caller fields win.
197
139
  function decorateDelivery(entry, outcome) {
198
140
  if (!outcome || typeof outcome !== "object" || Array.isArray(outcome)) return outcome;
199
141
  if (outcome.surfaceUuid !== undefined) return outcome;
@@ -204,14 +146,13 @@ export function createSurfaceStore(deps = {}) {
204
146
  const entry = bySurface.get(surfaceId);
205
147
  if (!entry || !entry.pending) return false;
206
148
  const pending = entry.pending;
207
- entry.pending = null; // settle the call; surface entry persists
149
+ entry.pending = null;
208
150
  if (isTerminalOutcome(outcome)) {
209
151
  entry.state = "exiting";
210
152
  entry.awaitingAgentResponse = false;
211
153
  } else {
212
154
  entry.state = "visible_awaiting_agent";
213
- // A wearer gesture (selected/back) means the agent is now responding;
214
- // a window_expired resolve is the timer, not the wearer → parked.
155
+
215
156
  entry.awaitingAgentResponse = !!(outcome && outcome.result !== "window_expired");
216
157
  }
217
158
  pending(decorateDelivery(entry, outcome));
@@ -227,12 +168,6 @@ export function createSurfaceStore(deps = {}) {
227
168
  return !!(entry && entry.pending);
228
169
  }
229
170
 
230
- // Carried over from createPendingRenderMap (Phase 1). drainSession/drainAll
231
- // resolve any in-flight pending call; Task 11 extends them to also stop
232
- // crons and clear the per-session stack. They are the only resolve path that
233
- // also DELETES surfaces (terminal teardown), unlike resolve() above.
234
- // Drains are plugin/host-initiated by definition — stamp origin "system"
235
- // (never a wearer gesture) unless the caller already typed it.
236
171
  function decorateDrainOutcome(entry, outcome) {
237
172
  if (!outcome || typeof outcome !== "object" || Array.isArray(outcome)) return outcome;
238
173
  return decorateDelivery(entry, {
@@ -255,11 +190,6 @@ export function createSurfaceStore(deps = {}) {
255
190
  return n;
256
191
  }
257
192
 
258
- // Settle still-pending calls for a session WITHOUT tearing anything down:
259
- // entries, parked events, crons and the stack all persist (surfaces are
260
- // DESIGNED to outlive runs — resolve ≠ teardown). This is the run-teardown
261
- // safety net's correct scope: no leaked pending call / orphan tool_use,
262
- // and no destruction of parked wearer intent (drift #3, 2026-06-12).
263
193
  function settlePending(rawSessionKey, outcome) {
264
194
  const sessionKey = normalizeGlassesSessionKey(rawSessionKey);
265
195
  let n = 0;
@@ -267,8 +197,7 @@ export function createSurfaceStore(deps = {}) {
267
197
  if (entry.sessionKey !== sessionKey || !entry.pending) continue;
268
198
  const pending = entry.pending;
269
199
  entry.pending = null;
270
- // The call leaked past its run; the surface stays live and collectable,
271
- // exactly like the post-window_expired parked state.
200
+
272
201
  entry.state = "visible_awaiting_agent";
273
202
  pending(decorateDrainOutcome(entry, outcome));
274
203
  n += 1;
@@ -297,26 +226,21 @@ export function createSurfaceStore(deps = {}) {
297
226
  const entry = bySurface.get(surfaceId);
298
227
  if (!entry) return false;
299
228
  if (isTerminalOutcome(event)) {
300
- entry.exitLatched = true; // latched exit beats any queued nonterminal
229
+ entry.exitLatched = true;
301
230
  entry.queuedEvent = event;
302
231
  return { ok: true, eventId: ++eventSeq, surfaceUuid: entry.uuid, kind: "terminal_latch" };
303
232
  }
304
233
  if (entry.exitLatched) {
305
- // Path D: a wearer gesture on a SYSTEM-origin latch (cron maxDuration death,
306
- // stamped origin:"system" at tool.ts:945) is NOT the wearer ending the
307
- // surface — revive it. Clear the latch and fall through to the normal append
308
- // (truthy receipt → ✓-ack → wake; the next render takes onReattached's deliver
309
- // path, not discarded_for_exit). A GESTURE-origin latch keeps dropping (the
310
- // wearer really ended it). Mirrors onReattached's forgiveness (surfaces.ts:373-377).
234
+
311
235
  const latched = entry.queuedEvent;
312
236
  const latchedOrigin = latched && typeof latched.origin === "string" ? latched.origin : "gesture";
313
237
  if (latchedOrigin === "gesture") {
314
- // Wearer-ended surface — drop; never ✓-ack a dead tap.
238
+
315
239
  return false;
316
240
  }
317
241
  entry.exitLatched = false;
318
242
  entry.queuedEvent = null;
319
- // fall through to append the reviving gesture below
243
+
320
244
  }
321
245
  const record = {
322
246
  eventId: ++eventSeq,
@@ -331,7 +255,7 @@ export function createSurfaceStore(deps = {}) {
331
255
  if (entry.events.length > SURFACE_EVENT_LOG_CAP) {
332
256
  entry.events.splice(0, entry.events.length - SURFACE_EVENT_LOG_CAP);
333
257
  }
334
- entry.queuedEvent = event; // legacy newest-nonterminal mirror (carry-forward path)
258
+ entry.queuedEvent = event;
335
259
  return { ok: true, eventId: record.eventId, surfaceUuid: entry.uuid };
336
260
  }
337
261
 
@@ -340,10 +264,6 @@ export function createSurfaceStore(deps = {}) {
340
264
  return entry ? entry.title : null;
341
265
  }
342
266
 
343
- // Plugin-authoritative presence marker (roadmap 7a). Pure function of two
344
- // store facts: is a listen window open, and is anything in flight (a queued
345
- // tap OR the agent responding to a just-delivered gesture). Glyphs are
346
- // mapped client-side; the plugin only emits these enum strings.
347
267
  function markerFor(surfaceId) {
348
268
  const entry = bySurface.get(surfaceId);
349
269
  if (!entry) return null;
@@ -352,8 +272,6 @@ export function createSurfaceStore(deps = {}) {
352
272
  return "parked";
353
273
  }
354
274
 
355
- // 7a: called by the agent_end hook to flip inflight→parked for the session's
356
- // surfaces when the agent turn ends without a new render (silent end).
357
275
  function clearAwaitingResponse(rawSessionKey) {
358
276
  const sessionKey = normalizeGlassesSessionKey(rawSessionKey);
359
277
  for (const [, entry] of bySurface) {
@@ -361,8 +279,6 @@ export function createSurfaceStore(deps = {}) {
361
279
  }
362
280
  }
363
281
 
364
- // 7a: compose the breadcrumb from the session's stack of titles, separator " › ".
365
- // Returns null when the session has no stack, is empty, or has no titled surfaces.
366
282
  function breadcrumbFor(rawSessionKey) {
367
283
  const s = stackBySession.get(normalizeGlassesSessionKey(rawSessionKey));
368
284
  if (!s || s.length === 0) return null;
@@ -382,9 +298,6 @@ export function createSurfaceStore(deps = {}) {
382
298
  return entry ? [...entry.events] : [];
383
299
  }
384
300
 
385
- // Read-time delivery reducer (6a): "latest" collapses to the newest
386
- // nonterminal (the pre-6a delivery semantics); "log" exposes the ordered
387
- // sequence for consumers that drain it whole (wake/voicemail, v2 collect).
388
301
  function reduceForDelivery(surfaceId) {
389
302
  const entry = bySurface.get(surfaceId);
390
303
  if (!entry) return null;
@@ -417,17 +330,7 @@ export function createSurfaceStore(deps = {}) {
417
330
  if (!entry) return "no_surface";
418
331
  let staleLatchDropped = false;
419
332
  if (entry.exitLatched) {
420
- // Generation gate (roadmap 6d, panel amendment 5): any latch read here
421
- // was by construction queued under a PRIOR call (queueEvent only
422
- // latches while no call is pending). A SYSTEM-origin terminal — a
423
- // cron's own maxDuration timeout / recipe_failed summary — is plugin
424
- // noise relative to the agent's fresh render and must NOT consume the
425
- // fresh call's window (HW 2026-06-11: a 7-min-stale cron `timeout`
426
- // ate a fresh 10-min call in 2.2s). Drop it and proceed; the fresh
427
- // render recycles the slot, which is the teardown the latch wanted.
428
- // GESTURE terminals (and legacy un-stamped ones — honoring a teardown
429
- // is the safe default) keep the discard-for-exit contract: the wearer
430
- // ended the surface and the fresh render must not resurrect it.
333
+
431
334
  const latched = entry.queuedEvent;
432
335
  const latchedOrigin =
433
336
  latched && typeof latched.origin === "string" ? latched.origin : "gesture";
@@ -436,11 +339,7 @@ export function createSurfaceStore(deps = {}) {
436
339
  entry.queuedEvent = null;
437
340
  staleLatchDropped = true;
438
341
  } else {
439
- // A terminal latched during the agent's turn. Discard this render and
440
- // resolve the freshly-established pending call (the re-attach render's
441
- // promise) with the latched terminal outcome so the tool call settles
442
- // instead of hanging, then tear down. Parked nonterminals beneath the
443
- // latch are dropped (the wearer ended the surface), not dead-lettered.
342
+
444
343
  entry.state = "exiting";
445
344
  const terminal = entry.queuedEvent || { result: "dismissed" };
446
345
  entry.queuedEvent = null;
@@ -454,12 +353,7 @@ export function createSurfaceStore(deps = {}) {
454
353
  }
455
354
  }
456
355
  entry.state = "reattached";
457
- // "latest"-reducer delivery (unchanged since 6a): the newest nonterminal
458
- // resolves the re-attached call and the log drains. 6b: the delivery is
459
- // decorated with the log record's identity + provenance + age, and — when
460
- // the render declared staleAfterMs — an annotate-only stale flag (the tap
461
- // is still delivered; degrading a stale actuating tap to a re-confirm is
462
- // the agent's job, taught in the skill).
356
+
463
357
  const newest = entry.events.length ? entry.events[entry.events.length - 1] : null;
464
358
  let delivered = null;
465
359
  if (newest) {
@@ -485,7 +379,7 @@ export function createSurfaceStore(deps = {}) {
485
379
  const pending = entry.pending;
486
380
  entry.pending = null;
487
381
  entry.state = "visible_awaiting_agent";
488
- entry.awaitingAgentResponse = true; // 7a: a delivered queued gesture = agent now responding
382
+ entry.awaitingAgentResponse = true;
489
383
  pending(delivered);
490
384
  }
491
385
  return staleLatchDropped ? "reattached_stale_latch_dropped" : "reattached";
@@ -506,16 +400,11 @@ export function createSurfaceStore(deps = {}) {
506
400
  return entry ? entry.sessionKey : null;
507
401
  }
508
402
 
509
- // Derive the target surfaceId from the session's current top. The agent
510
- // never supplies a surfaceId (spec §Core model — the plugin owns them,
511
- // keyed by session + stack). Returns { mode, surfaceId } so the caller can
512
- // render against the bound id.
513
403
  function applyRender(rawSessionKey, params) {
514
404
  const sessionKey = normalizeGlassesSessionKey(rawSessionKey);
515
405
  const stack = stackFor(sessionKey);
516
406
  const top = stack[stack.length - 1] || null;
517
- // First render in a session (empty stack): create a root at depth 1,
518
- // regardless of the requested update (there is nothing to patch/push onto).
407
+
519
408
  if (!top) {
520
409
  const id = mintSurfaceId();
521
410
  stack.push(id);
@@ -526,9 +415,7 @@ export function createSurfaceStore(deps = {}) {
526
415
  : params && params.update === "push" ? "push"
527
416
  : "replace";
528
417
  if (update === "patch") {
529
- // Re-attach in place: reuse the top id, keep its entry + cron. The
530
- // caller sends a field patch; the body content slot is updated by the
531
- // render send, not here.
418
+
532
419
  const entry = bySurface.get(top);
533
420
  if (entry && params && params.kind) entry.kind = params.kind;
534
421
  if (entry) entry.state = "visible_pending";
@@ -541,22 +428,9 @@ export function createSurfaceStore(deps = {}) {
541
428
  bySurface.set(id, makeEntry(sessionKey, params && params.kind));
542
429
  return { mode: "push", surfaceId: id };
543
430
  }
544
- // replace: swap the current top's CONTENT in place, reusing its id (no
545
- // new back-target, depth unchanged). The entry is reset to a fresh
546
- // pending state; the cron for this slot is reset for new content. CARRY
547
- // FORWARD the prior entry's latched exit / queued event (same surfaceId)
548
- // so a terminal/nonterminal recorded during visible_awaiting_agent is
549
- // still honored by the subsequent onReattached — reattach semantics must
550
- // be move-independent (replace, the schema DEFAULT, must behave like
551
- // patch here, not silently drop a latched exit).
431
+
552
432
  const priorTop = bySurface.get(top);
553
- // SILENT stop: this is slot recycling for the incoming replace render, not
554
- // a real outcome. A non-silent stop fires the cron's onResolve with a
555
- // synthesized `preempted`, which (with no pending call at this instant)
556
- // latches a bogus exit onto the prior entry — carried into makeEntry below,
557
- // it makes the very render we are applying discard-for-exit on re-attach
558
- // ("fresh render instantly dismissed", B7 — the real B3 contamination
559
- // mechanism, found 2026-06-11).
433
+
560
434
  stopCron(top, { silent: true });
561
435
  bySurface.set(top, makeEntry(sessionKey, params && params.kind, priorTop));
562
436
  return { mode: "replace", surfaceId: top };
@@ -603,10 +477,4 @@ export function createSurfaceStore(deps = {}) {
603
477
  };
604
478
  }
605
479
 
606
- // Backwards-compatible alias. Phase 1 extracted createPendingRenderMap; Phase 2
607
- // evolved it into createSurfaceStore (single store — see the SINGLE-STORE
608
- // EVOLUTION note in the plan). The old name survives only as an alias so the
609
- // Phase 1 import path (glasses-ui-tool re-export) keeps resolving. There is
610
- // never a separate createPendingRenderMap *instance* — both names construct the
611
- // one evolved store.
612
480
  export const createPendingRenderMap = createSurfaceStore;
@@ -1,9 +1,3 @@
1
- // Template engine for glasses UI refresh recipes.
2
- // Pure functions. No I/O. Parses {{path | filter:arg | filter:arg}} expressions,
3
- // resolves paths against a data object (plus an optional previous-tick object
4
- // exposed under {{previous.*}}), and applies a small filter set sized exactly
5
- // to the spec's stated use cases.
6
-
7
1
  const KNOWN_FILTERS = new Set([
8
2
  "trim",
9
3
  "lower",
@@ -18,11 +12,10 @@ const KNOWN_FILTERS = new Set([
18
12
  "plus",
19
13
  ]);
20
14
 
21
- // Filters that require a numeric arg.
22
15
  const NUMERIC_ARG_FILTERS = new Set(["round", "truncate"]);
23
- // Filters that require a string arg (a quoted literal in the template).
16
+
24
17
  const STRING_ARG_FILTERS = new Set(["default", "prefix"]);
25
- // Filters that require a path arg (resolves against the data object).
18
+
26
19
  const PATH_ARG_FILTERS = new Set(["minus", "plus"]);
27
20
 
28
21
  function resolvePath(path, data, previous) {
@@ -51,10 +44,7 @@ function resolvePath(path, data, previous) {
51
44
  }
52
45
 
53
46
  function parseFilter(filterSrc) {
54
- // Format: `name` or `name:arg` where arg can be:
55
- // - a quoted string: `default:"x"` or `prefix:"+"`
56
- // - a number: `round:2` or `truncate:18`
57
- // - a path: `minus:previous.value`
47
+
58
48
  const colonIdx = filterSrc.indexOf(":");
59
49
  const name = (colonIdx === -1 ? filterSrc : filterSrc.slice(0, colonIdx)).trim();
60
50
  const rawArg = colonIdx === -1 ? "" : filterSrc.slice(colonIdx + 1).trim();
@@ -81,7 +71,7 @@ function parseFilter(filterSrc) {
81
71
  }
82
72
  return { ok: true, name, arg: rawArg };
83
73
  }
84
- // No-arg filters.
74
+
85
75
  return { ok: true, name, arg: null };
86
76
  }
87
77
 
@@ -113,10 +103,7 @@ function applyFilter(value, filter, data, previous) {
113
103
  case "default":
114
104
  return value === undefined || value === null || value === "" ? filter.arg : value;
115
105
  case "prefix": {
116
- // Truthy = non-zero, non-empty. Numbers >= 0 with prefix:"+" should
117
- // still prepend on positive values; here we treat "truthy" as the
118
- // value's actual truthiness — agent uses prefix when they want the
119
- // literal prepended on a non-empty value.
106
+
120
107
  if (value === undefined || value === null || value === "") return value;
121
108
  if (typeof value === "number" && value === 0) return value;
122
109
  return `${filter.arg}${value}`;
@@ -139,8 +126,6 @@ function stringify(value) {
139
126
  return String(value);
140
127
  }
141
128
 
142
- // PUBLIC ----
143
-
144
129
  export function substituteTemplate(template, data, opts) {
145
130
  if (typeof template !== "string") return "";
146
131
  const previous = opts && opts.previous ? opts.previous : null;
@@ -150,7 +135,7 @@ export function substituteTemplate(template, data, opts) {
150
135
  let value = resolvePath(path, data, previous);
151
136
  for (let i = 1; i < parts.length; i += 1) {
152
137
  const f = parseFilter(parts[i]);
153
- if (!f.ok) return stringify(value); // shouldn't happen if validateTemplate was called
138
+ if (!f.ok) return stringify(value);
154
139
  value = applyFilter(value, f, data, previous);
155
140
  }
156
141
  return stringify(value);
@@ -161,7 +146,7 @@ export function validateTemplate(template) {
161
146
  if (typeof template !== "string") {
162
147
  return { ok: false, code: "refresh_template_invalid", message: "template must be a string" };
163
148
  }
164
- // Detect unclosed braces.
149
+
165
150
  const openCount = (template.match(/\{\{/g) || []).length;
166
151
  const closeCount = (template.match(/\}\}/g) || []).length;
167
152
  if (openCount !== closeCount) {
@@ -7,10 +7,10 @@ test("description now carries the follow-up + back/selected usage rules", () =>
7
7
  assert.match(d, /text_surface/);
8
8
  assert.match(d, /list_surface/);
9
9
  assert.match(d, /list_with_details_surface/);
10
- // Channel-3 additions (moved from the old nudge):
10
+
11
11
  assert.match(d, /NEXT output|next output/);
12
12
  assert.match(d, /back/i);
13
13
  assert.match(d, /selected/i);
14
- // Skill pointer retained:
14
+
15
15
  assert.match(d, /glasses-ui/);
16
16
  });