ocuclaw 1.2.4 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +21 -6
  2. package/dist/config/runtime-config.js +84 -3
  3. package/dist/domain/activity-status-adapter.js +138 -605
  4. package/dist/domain/activity-status-arbiter.js +109 -0
  5. package/dist/domain/activity-status-labels.js +906 -0
  6. package/dist/domain/code-span-regions.js +103 -0
  7. package/dist/domain/conversation-state.js +14 -1
  8. package/dist/domain/debug-store.js +56 -182
  9. package/dist/domain/glasses-ui-content-summary.js +62 -0
  10. package/dist/domain/glasses-ui-system-prompt.js +28 -0
  11. package/dist/domain/message-emoji-allowlist.js +16 -0
  12. package/dist/domain/message-emoji-filter.js +33 -55
  13. package/dist/domain/neural-emoji-reactor-system-prompt.js +43 -0
  14. package/dist/domain/neural-emoji-reactor-tag-config.js +56 -0
  15. package/dist/domain/neural-pace-modulator-system-prompt.js +32 -0
  16. package/dist/domain/neural-pace-modulator-tag-config.js +51 -0
  17. package/dist/domain/tagged-span-parser.js +121 -0
  18. package/dist/domain/tagged-span-strip.js +38 -0
  19. package/dist/even-ai/even-ai-endpoint.js +91 -0
  20. package/dist/even-ai/even-ai-run-waiter.js +14 -0
  21. package/dist/even-ai/even-ai-settings-store.js +14 -0
  22. package/dist/gateway/gateway-bridge.js +14 -2
  23. package/dist/gateway/gateway-timing-ledger.js +457 -0
  24. package/dist/gateway/openclaw-client.js +462 -38
  25. package/dist/index.js +28 -1
  26. package/dist/runtime/downstream-handler.js +754 -83
  27. package/dist/runtime/ocuclaw-settings-store.js +74 -31
  28. package/dist/runtime/plugin-version-service.js +23 -0
  29. package/dist/runtime/protocol-adapter.js +9 -0
  30. package/dist/runtime/provider-usage-select.js +168 -0
  31. package/dist/runtime/relay-client-nudge-controller.js +553 -0
  32. package/dist/runtime/relay-core.js +1293 -225
  33. package/dist/runtime/relay-health-monitor.js +172 -0
  34. package/dist/runtime/relay-operation-registry.js +263 -0
  35. package/dist/runtime/relay-service.js +201 -1
  36. package/dist/runtime/relay-worker-approval-replay-cache.js +68 -0
  37. package/dist/runtime/relay-worker-entry.js +32 -0
  38. package/dist/runtime/relay-worker-health.js +272 -0
  39. package/dist/runtime/relay-worker-protocol.js +281 -0
  40. package/dist/runtime/relay-worker-queue.js +202 -0
  41. package/dist/runtime/relay-worker-supervisor.js +1004 -0
  42. package/dist/runtime/relay-worker-transport.js +1051 -0
  43. package/dist/runtime/session-context-service.js +189 -0
  44. package/dist/runtime/session-service.js +638 -27
  45. package/dist/runtime/upstream-runtime.js +1167 -60
  46. package/dist/tools/device-info-tool.js +242 -0
  47. package/dist/tools/glasses-ui-cron.js +427 -0
  48. package/dist/tools/glasses-ui-descriptors.js +261 -0
  49. package/dist/tools/glasses-ui-limits.js +21 -0
  50. package/dist/tools/glasses-ui-paint-floor.js +99 -0
  51. package/dist/tools/glasses-ui-recipes.js +581 -0
  52. package/dist/tools/glasses-ui-surfaces.js +278 -0
  53. package/dist/tools/glasses-ui-template.js +182 -0
  54. package/dist/tools/glasses-ui-tool.js +1111 -0
  55. package/dist/tools/session-title-tool.js +209 -0
  56. package/dist/version.js +2 -0
  57. package/openclaw.plugin.json +163 -15
  58. package/package.json +14 -5
  59. package/skills/glasses-ui/SKILL.md +156 -0
  60. package/dist/runtime/downstream-server.js +0 -1891
@@ -0,0 +1,553 @@
1
+ import {
2
+ APP_PROTOCOL,
3
+ DEFAULT_NUDGE_THRESHOLDS,
4
+ } from "./relay-worker-protocol.js";
5
+
6
+ const RENDER_NUDGE_FRAME = JSON.stringify({ type: "render_nudge" });
7
+
8
+ function normalizePositiveInteger(value, fallback) {
9
+ return Number.isFinite(value) ? Math.max(1, Math.floor(value)) : fallback;
10
+ }
11
+
12
+ function normalizeNonNegativeInteger(value, fallback) {
13
+ return Number.isFinite(value) ? Math.max(0, Math.floor(value)) : fallback;
14
+ }
15
+
16
+ function parseOptionalTrimmedString(value) {
17
+ if (typeof value !== "string") return null;
18
+ const trimmed = value.trim();
19
+ return trimmed ? trimmed : null;
20
+ }
21
+
22
+ function isStreamingBroadcastType(messageType) {
23
+ return (
24
+ messageType === "streaming" ||
25
+ messageType === "ocuclaw.message.stream.delta"
26
+ );
27
+ }
28
+
29
+ function isPagesBroadcastType(messageType) {
30
+ return messageType === "pages" || messageType === APP_PROTOCOL.pages;
31
+ }
32
+
33
+ function isActivityBroadcastType(messageType) {
34
+ return messageType === "activity" || messageType === "ocuclaw.activity.update";
35
+ }
36
+
37
+ function isListenCommittedBroadcastType(messageType) {
38
+ return messageType === "listen-committed";
39
+ }
40
+
41
+ function isListenEndedBroadcastType(messageType) {
42
+ return messageType === "listen-ended";
43
+ }
44
+
45
+ export function createRelayClientNudgeController(options = {}) {
46
+ const sendFrame =
47
+ typeof options.sendFrame === "function" ? options.sendFrame : () => {};
48
+ const isAppClient =
49
+ typeof options.isAppClient === "function" ? options.isAppClient : () => false;
50
+ const now =
51
+ typeof options.now === "function" ? options.now : () => Date.now();
52
+ const sourceThresholds = options.thresholds || {};
53
+ const thresholds = {
54
+ nudgeActiveIntervalMs: normalizePositiveInteger(
55
+ sourceThresholds.nudgeActiveIntervalMs,
56
+ DEFAULT_NUDGE_THRESHOLDS.nudgeActiveIntervalMs,
57
+ ),
58
+ nudgeSlowIntervalMs: normalizePositiveInteger(
59
+ sourceThresholds.nudgeSlowIntervalMs,
60
+ DEFAULT_NUDGE_THRESHOLDS.nudgeSlowIntervalMs,
61
+ ),
62
+ nudgeIdleDeactivateMs: normalizeNonNegativeInteger(
63
+ sourceThresholds.nudgeIdleDeactivateMs,
64
+ DEFAULT_NUDGE_THRESHOLDS.nudgeIdleDeactivateMs,
65
+ ),
66
+ nudgeHeartbeatIntervalMs: normalizePositiveInteger(
67
+ sourceThresholds.nudgeHeartbeatIntervalMs,
68
+ DEFAULT_NUDGE_THRESHOLDS.nudgeHeartbeatIntervalMs,
69
+ ),
70
+ nudgeHardTimeoutMs: normalizePositiveInteger(
71
+ sourceThresholds.nudgeHardTimeoutMs,
72
+ DEFAULT_NUDGE_THRESHOLDS.nudgeHardTimeoutMs,
73
+ ),
74
+ };
75
+ const nudgeStaleHeartbeatThresholdMs = thresholds.nudgeHeartbeatIntervalMs * 2;
76
+ const clientNudgeState = new Map();
77
+
78
+ function interactionStageBucket(stage) {
79
+ switch (stage) {
80
+ case "listening":
81
+ case "voice_handoff":
82
+ case "thinking":
83
+ return "active_non_stream";
84
+ case "streaming":
85
+ case "post_turn_drain":
86
+ return "active_stream";
87
+ default:
88
+ return "idle";
89
+ }
90
+ }
91
+
92
+ function createClientState() {
93
+ return {
94
+ visibilityState: null,
95
+ streamChars: null,
96
+ lastHeartbeatAtMs: null,
97
+ lastRelayStreamingActivityAtMs: null,
98
+ interactionStage: "idle",
99
+ cadenceBucket: "idle",
100
+ nudgeActive: false,
101
+ nudgeIntervalMs: null,
102
+ nudgeStartedAtMs: null,
103
+ lastNudgeAtMs: null,
104
+ stalledHeartbeatCount: 0,
105
+ nudgeTimer: null,
106
+ idleDeactivateTimer: null,
107
+ staleHeartbeatTimer: null,
108
+ hardTimeoutTimer: null,
109
+ };
110
+ }
111
+
112
+ function cloneClientState(state) {
113
+ if (!state) return null;
114
+ return {
115
+ visibilityState: state.visibilityState || null,
116
+ streamChars: Number.isFinite(state.streamChars) ? state.streamChars : null,
117
+ lastHeartbeatAtMs: Number.isFinite(state.lastHeartbeatAtMs)
118
+ ? state.lastHeartbeatAtMs
119
+ : null,
120
+ lastRelayStreamingActivityAtMs: Number.isFinite(
121
+ state.lastRelayStreamingActivityAtMs,
122
+ )
123
+ ? state.lastRelayStreamingActivityAtMs
124
+ : null,
125
+ interactionStage: state.interactionStage || "idle",
126
+ cadenceBucket: state.cadenceBucket || "idle",
127
+ nudgeActive: !!state.nudgeActive,
128
+ nudgeIntervalMs: Number.isFinite(state.nudgeIntervalMs)
129
+ ? state.nudgeIntervalMs
130
+ : null,
131
+ nudgeStartedAtMs: Number.isFinite(state.nudgeStartedAtMs)
132
+ ? state.nudgeStartedAtMs
133
+ : null,
134
+ lastNudgeAtMs: Number.isFinite(state.lastNudgeAtMs)
135
+ ? state.lastNudgeAtMs
136
+ : null,
137
+ stalledHeartbeatCount: Number.isFinite(state.stalledHeartbeatCount)
138
+ ? state.stalledHeartbeatCount
139
+ : 0,
140
+ };
141
+ }
142
+
143
+ function ensureClientState(clientId) {
144
+ let state = clientNudgeState.get(clientId);
145
+ if (!state) {
146
+ state = createClientState();
147
+ clientNudgeState.set(clientId, state);
148
+ }
149
+ return state;
150
+ }
151
+
152
+ function addClient(clientId) {
153
+ ensureClientState(clientId);
154
+ }
155
+
156
+ function clearClientNudgeTimer(state, key, clearFn = clearTimeout) {
157
+ if (!state || !state[key]) return;
158
+ clearFn(state[key]);
159
+ state[key] = null;
160
+ }
161
+
162
+ function clearClientTimers(clientId) {
163
+ const state = clientNudgeState.get(clientId);
164
+ if (!state) return;
165
+ clearClientNudgeTimer(state, "nudgeTimer", clearInterval);
166
+ clearClientNudgeTimer(state, "idleDeactivateTimer");
167
+ clearClientNudgeTimer(state, "staleHeartbeatTimer");
168
+ clearClientNudgeTimer(state, "hardTimeoutTimer");
169
+ }
170
+
171
+ function resetClientStallTracking(state) {
172
+ if (!state) return;
173
+ state.stalledHeartbeatCount = 0;
174
+ }
175
+
176
+ function hasStaleHeartbeat(state, observedAtMs = now()) {
177
+ if (!state || !Number.isFinite(state.lastHeartbeatAtMs)) {
178
+ return false;
179
+ }
180
+ return observedAtMs - state.lastHeartbeatAtMs >= nudgeStaleHeartbeatThresholdMs;
181
+ }
182
+
183
+ function isVisibilityDegraded(state) {
184
+ return (
185
+ !!state &&
186
+ (state.visibilityState === "hidden" || state.visibilityState === "blurred")
187
+ );
188
+ }
189
+
190
+ function sendRenderNudge(clientId) {
191
+ if (!isAppClient(clientId)) {
192
+ stopClientNudges(clientId, "socket_unavailable");
193
+ return;
194
+ }
195
+ const result = sendFrame(clientId, RENDER_NUDGE_FRAME);
196
+ if (result === false) {
197
+ stopClientNudges(clientId, "socket_unavailable");
198
+ return;
199
+ }
200
+ ensureClientState(clientId).lastNudgeAtMs = now();
201
+ }
202
+
203
+ function scheduleClientHardTimeout(clientId) {
204
+ const state = clientNudgeState.get(clientId);
205
+ if (!state) return;
206
+ clearClientNudgeTimer(state, "hardTimeoutTimer");
207
+ if (!state.nudgeActive) return;
208
+ state.hardTimeoutTimer = setTimeout(() => {
209
+ state.hardTimeoutTimer = null;
210
+ setInteractionStage(clientId, "idle", {
211
+ reason: "nudge_hard_timeout",
212
+ deactivateImmediately: true,
213
+ });
214
+ }, thresholds.nudgeHardTimeoutMs);
215
+ }
216
+
217
+ function startClientNudges(
218
+ clientId,
219
+ intervalMs,
220
+ reason = "nudge_start",
221
+ sendImmediately = false,
222
+ ) {
223
+ if (!isAppClient(clientId)) return;
224
+ const state = ensureClientState(clientId);
225
+ const nextIntervalMs = Math.max(1, Math.floor(intervalMs));
226
+ const wasActive = !!state.nudgeActive;
227
+ const intervalChanged = state.nudgeIntervalMs !== nextIntervalMs;
228
+ state.cadenceBucket = interactionStageBucket(state.interactionStage);
229
+ if (!wasActive) {
230
+ state.nudgeActive = true;
231
+ state.nudgeStartedAtMs = now();
232
+ }
233
+ if (wasActive && !intervalChanged) {
234
+ return;
235
+ }
236
+ clearClientNudgeTimer(state, "nudgeTimer", clearInterval);
237
+ state.nudgeActive = true;
238
+ state.nudgeIntervalMs = nextIntervalMs;
239
+ state.nudgeTimer = setInterval(() => {
240
+ sendRenderNudge(clientId);
241
+ }, nextIntervalMs);
242
+ if (!wasActive) {
243
+ scheduleClientHardTimeout(clientId);
244
+ if (sendImmediately) {
245
+ sendRenderNudge(clientId);
246
+ }
247
+ }
248
+ }
249
+
250
+ function stopClientNudges(clientId, _reason = "nudge_stop") {
251
+ const state = clientNudgeState.get(clientId);
252
+ if (!state) return;
253
+ clearClientNudgeTimer(state, "nudgeTimer", clearInterval);
254
+ clearClientNudgeTimer(state, "idleDeactivateTimer");
255
+ clearClientNudgeTimer(state, "hardTimeoutTimer");
256
+ state.nudgeActive = false;
257
+ state.nudgeIntervalMs = null;
258
+ state.nudgeStartedAtMs = null;
259
+ resetClientStallTracking(state);
260
+ scheduleClientStaleHeartbeatCheck(clientId);
261
+ }
262
+
263
+ function scheduleClientIdleDeactivation(clientId) {
264
+ const state = clientNudgeState.get(clientId);
265
+ if (!state) return;
266
+ clearClientNudgeTimer(state, "idleDeactivateTimer");
267
+ if (!state.nudgeActive || state.interactionStage !== "idle") {
268
+ return;
269
+ }
270
+ state.idleDeactivateTimer = setTimeout(() => {
271
+ state.idleDeactivateTimer = null;
272
+ const currentState = clientNudgeState.get(clientId);
273
+ if (!currentState || currentState.interactionStage !== "idle") {
274
+ return;
275
+ }
276
+ stopClientNudges(clientId, "idle_grace_elapsed");
277
+ }, thresholds.nudgeIdleDeactivateMs);
278
+ }
279
+
280
+ function scheduleClientStaleHeartbeatCheck(clientId) {
281
+ const state = clientNudgeState.get(clientId);
282
+ if (!state) return;
283
+ clearClientNudgeTimer(state, "staleHeartbeatTimer");
284
+ if (
285
+ state.nudgeActive ||
286
+ interactionStageBucket(state.interactionStage) === "idle" ||
287
+ !Number.isFinite(state.lastHeartbeatAtMs)
288
+ ) {
289
+ return;
290
+ }
291
+ const delayMs = Math.max(
292
+ 0,
293
+ (state.lastHeartbeatAtMs + nudgeStaleHeartbeatThresholdMs) - now(),
294
+ );
295
+ state.staleHeartbeatTimer = setTimeout(() => {
296
+ state.staleHeartbeatTimer = null;
297
+ const currentState = clientNudgeState.get(clientId);
298
+ if (
299
+ !currentState ||
300
+ currentState.nudgeActive ||
301
+ interactionStageBucket(currentState.interactionStage) === "idle"
302
+ ) {
303
+ return;
304
+ }
305
+ if (!hasStaleHeartbeat(currentState)) {
306
+ scheduleClientStaleHeartbeatCheck(clientId);
307
+ return;
308
+ }
309
+ startClientNudges(
310
+ clientId,
311
+ thresholds.nudgeActiveIntervalMs,
312
+ "stale_heartbeat_fallback",
313
+ true,
314
+ );
315
+ }, delayMs);
316
+ }
317
+
318
+ function maybeActivateClientNudges(clientId, reason = "nudge_eval") {
319
+ const state = clientNudgeState.get(clientId);
320
+ if (!state || !isAppClient(clientId)) return;
321
+ state.cadenceBucket = interactionStageBucket(state.interactionStage);
322
+ if (state.cadenceBucket === "idle") {
323
+ clearClientNudgeTimer(state, "staleHeartbeatTimer");
324
+ return;
325
+ }
326
+ if (state.nudgeActive) {
327
+ return;
328
+ }
329
+ if (isVisibilityDegraded(state) || hasStaleHeartbeat(state)) {
330
+ startClientNudges(clientId, thresholds.nudgeActiveIntervalMs, reason, true);
331
+ return;
332
+ }
333
+ scheduleClientStaleHeartbeatCheck(clientId);
334
+ }
335
+
336
+ function setInteractionStage(clientId, nextStage, options = {}) {
337
+ if (!isAppClient(clientId)) return;
338
+ const state = ensureClientState(clientId);
339
+ const reason = options.reason || "interaction_stage";
340
+ const deactivateImmediately = options.deactivateImmediately === true;
341
+ state.interactionStage = nextStage;
342
+ state.cadenceBucket = interactionStageBucket(nextStage);
343
+ if (state.cadenceBucket !== "active_stream") {
344
+ resetClientStallTracking(state);
345
+ }
346
+ if (state.cadenceBucket === "idle") {
347
+ clearClientNudgeTimer(state, "staleHeartbeatTimer");
348
+ if (deactivateImmediately) {
349
+ stopClientNudges(clientId, reason);
350
+ } else {
351
+ scheduleClientIdleDeactivation(clientId);
352
+ }
353
+ return;
354
+ }
355
+ clearClientNudgeTimer(state, "idleDeactivateTimer");
356
+ if (
357
+ state.cadenceBucket === "active_non_stream" &&
358
+ state.nudgeActive &&
359
+ state.nudgeIntervalMs !== thresholds.nudgeActiveIntervalMs
360
+ ) {
361
+ startClientNudges(clientId, thresholds.nudgeActiveIntervalMs, reason, false);
362
+ }
363
+ maybeActivateClientNudges(clientId, reason);
364
+ }
365
+
366
+ function observeRelayStreamingActivity(clientId, atMs) {
367
+ if (!isAppClient(clientId)) return;
368
+ const state = ensureClientState(clientId);
369
+ state.lastRelayStreamingActivityAtMs = atMs;
370
+ resetClientStallTracking(state);
371
+ if (
372
+ state.nudgeActive &&
373
+ state.nudgeIntervalMs !== thresholds.nudgeActiveIntervalMs
374
+ ) {
375
+ startClientNudges(
376
+ clientId,
377
+ thresholds.nudgeActiveIntervalMs,
378
+ "relay_stream_progress",
379
+ );
380
+ return;
381
+ }
382
+ maybeActivateClientNudges(clientId, "relay_stream_activity");
383
+ }
384
+
385
+ function updateHeartbeat(clientId, ping) {
386
+ const state = ensureClientState(clientId);
387
+ const previousHeartbeatAtMs = Number.isFinite(state.lastHeartbeatAtMs)
388
+ ? state.lastHeartbeatAtMs
389
+ : null;
390
+ const previousStreamChars = Number.isFinite(state.streamChars)
391
+ ? state.streamChars
392
+ : null;
393
+ const nextStreamChars = Number.isFinite(ping.streamChars) ? ping.streamChars : null;
394
+ const streamAdvanced =
395
+ nextStreamChars !== null &&
396
+ (previousStreamChars === null || nextStreamChars > previousStreamChars);
397
+ const relayStreamAdvanced =
398
+ previousHeartbeatAtMs !== null &&
399
+ Number.isFinite(state.lastRelayStreamingActivityAtMs) &&
400
+ state.lastRelayStreamingActivityAtMs > previousHeartbeatAtMs;
401
+ state.streamChars = nextStreamChars;
402
+ if (ping.visibilityState) {
403
+ state.visibilityState = ping.visibilityState;
404
+ }
405
+ state.lastHeartbeatAtMs = now();
406
+ state.cadenceBucket = interactionStageBucket(state.interactionStage);
407
+ if (state.visibilityState === "visible") {
408
+ stopClientNudges(clientId, "heartbeat_visible");
409
+ }
410
+ if (state.cadenceBucket === "active_stream") {
411
+ if (streamAdvanced || relayStreamAdvanced) {
412
+ resetClientStallTracking(state);
413
+ if (
414
+ state.nudgeActive &&
415
+ state.nudgeIntervalMs !== thresholds.nudgeActiveIntervalMs
416
+ ) {
417
+ startClientNudges(
418
+ clientId,
419
+ thresholds.nudgeActiveIntervalMs,
420
+ streamAdvanced
421
+ ? "heartbeat_stream_progress"
422
+ : "relay_stream_progress",
423
+ false,
424
+ );
425
+ }
426
+ } else if (state.nudgeActive) {
427
+ state.stalledHeartbeatCount += 1;
428
+ if (
429
+ state.stalledHeartbeatCount >= 3 &&
430
+ state.nudgeIntervalMs !== thresholds.nudgeSlowIntervalMs
431
+ ) {
432
+ startClientNudges(
433
+ clientId,
434
+ thresholds.nudgeSlowIntervalMs,
435
+ "stream_stalled_decelerated",
436
+ false,
437
+ );
438
+ }
439
+ }
440
+ } else {
441
+ resetClientStallTracking(state);
442
+ if (
443
+ state.nudgeActive &&
444
+ state.nudgeIntervalMs !== thresholds.nudgeActiveIntervalMs
445
+ ) {
446
+ startClientNudges(
447
+ clientId,
448
+ thresholds.nudgeActiveIntervalMs,
449
+ "non_stream_fast",
450
+ false,
451
+ );
452
+ }
453
+ }
454
+ maybeActivateClientNudges(clientId, "heartbeat_update");
455
+ }
456
+
457
+ function updateVisibilityState(clientId, visibilityState) {
458
+ const state = ensureClientState(clientId);
459
+ state.visibilityState = visibilityState;
460
+ if (visibilityState === "visible") {
461
+ stopClientNudges(clientId, "visibility_visible");
462
+ return;
463
+ }
464
+ maybeActivateClientNudges(clientId, "visibility_hidden");
465
+ }
466
+
467
+ function applyBroadcastInteractionStage(messageType, parsed, relayStreamingActivityAtMs) {
468
+ for (const [clientId] of clientNudgeState) {
469
+ if (!isAppClient(clientId)) {
470
+ continue;
471
+ }
472
+ if (relayStreamingActivityAtMs !== null) {
473
+ setInteractionStage(clientId, "streaming", {
474
+ reason: "relay_streaming",
475
+ });
476
+ observeRelayStreamingActivity(clientId, relayStreamingActivityAtMs);
477
+ continue;
478
+ }
479
+ if (isListenCommittedBroadcastType(messageType)) {
480
+ setInteractionStage(clientId, "voice_handoff", {
481
+ reason: "listen_committed",
482
+ });
483
+ continue;
484
+ }
485
+ if (isListenEndedBroadcastType(messageType)) {
486
+ setInteractionStage(clientId, "idle", {
487
+ reason: "listen_ended",
488
+ deactivateImmediately: true,
489
+ });
490
+ continue;
491
+ }
492
+ if (isActivityBroadcastType(messageType)) {
493
+ const activityState = parseOptionalTrimmedString(parsed && parsed.state);
494
+ const normalizedActivity = activityState ? activityState.toLowerCase() : null;
495
+ const currentStage = ensureClientState(clientId).interactionStage;
496
+ if (normalizedActivity === "thinking") {
497
+ setInteractionStage(clientId, "thinking", {
498
+ reason: "activity_thinking",
499
+ });
500
+ } else if (normalizedActivity === "idle") {
501
+ if (currentStage === "streaming" || currentStage === "post_turn_drain") {
502
+ setInteractionStage(clientId, "post_turn_drain", {
503
+ reason: "activity_idle_stream_drain",
504
+ });
505
+ } else {
506
+ setInteractionStage(clientId, "idle", {
507
+ reason: "activity_idle",
508
+ });
509
+ }
510
+ }
511
+ continue;
512
+ }
513
+ if (isPagesBroadcastType(messageType)) {
514
+ const currentState = ensureClientState(clientId);
515
+ if (interactionStageBucket(currentState.interactionStage) !== "idle") {
516
+ setInteractionStage(clientId, "post_turn_drain", {
517
+ reason: "pages_snapshot",
518
+ });
519
+ }
520
+ }
521
+ }
522
+ }
523
+
524
+ function getClientState(clientId) {
525
+ return cloneClientState(clientNudgeState.get(clientId) || null);
526
+ }
527
+
528
+ function deleteClient(clientId) {
529
+ clearClientTimers(clientId);
530
+ clientNudgeState.delete(clientId);
531
+ }
532
+
533
+ function clear() {
534
+ for (const [clientId] of clientNudgeState) {
535
+ clearClientTimers(clientId);
536
+ }
537
+ clientNudgeState.clear();
538
+ }
539
+
540
+ return {
541
+ createClientState,
542
+ cloneClientState,
543
+ addClient,
544
+ clearClientTimers,
545
+ updateVisibilityState,
546
+ updateHeartbeat,
547
+ setInteractionStage,
548
+ applyBroadcastInteractionStage,
549
+ getClientState,
550
+ deleteClient,
551
+ clear,
552
+ };
553
+ }