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,242 @@
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
+ import { randomUUID } from "node:crypto";
6
+
7
+ export const deviceInfoParametersSchema = {
8
+ type: "object",
9
+ properties: {},
10
+ additionalProperties: false,
11
+ };
12
+
13
+ export function validateDeviceInfoInput(input) {
14
+ if (input === undefined || input === null) return { ok: true };
15
+ if (typeof input !== "object" || Array.isArray(input)) {
16
+ return {
17
+ ok: false,
18
+ code: "invalid_input",
19
+ message: "input must be an object or undefined",
20
+ };
21
+ }
22
+ const keys = Object.keys(input);
23
+ if (keys.length > 0) {
24
+ return {
25
+ ok: false,
26
+ code: "invalid_input",
27
+ message: `unknown key(s): ${keys.join(", ")}`,
28
+ };
29
+ }
30
+ return { ok: true };
31
+ }
32
+
33
+ export function createPendingDeviceInfoMap() {
34
+ const byRequest = new Map(); // requestId -> { sessionKey, resolve }
35
+
36
+ function register(sessionKey, requestId) {
37
+ return new Promise((resolve) => {
38
+ byRequest.set(requestId, { sessionKey, resolve });
39
+ });
40
+ }
41
+
42
+ function resolve(requestId, outcome) {
43
+ const pending = byRequest.get(requestId);
44
+ if (!pending) return;
45
+ byRequest.delete(requestId);
46
+ pending.resolve(outcome);
47
+ }
48
+
49
+ function drainSession(sessionKey, outcome) {
50
+ const ids = [];
51
+ for (const [requestId, pending] of byRequest) {
52
+ if (pending.sessionKey === sessionKey) ids.push(requestId);
53
+ }
54
+ for (const requestId of ids) {
55
+ const pending = byRequest.get(requestId);
56
+ if (!pending) continue;
57
+ byRequest.delete(requestId);
58
+ pending.resolve(outcome);
59
+ }
60
+ return ids.length;
61
+ }
62
+
63
+ function drainAll(outcome) {
64
+ const ids = [...byRequest.keys()];
65
+ for (const requestId of ids) {
66
+ const pending = byRequest.get(requestId);
67
+ if (!pending) continue;
68
+ byRequest.delete(requestId);
69
+ pending.resolve(outcome);
70
+ }
71
+ return ids.length;
72
+ }
73
+
74
+ return { register, resolve, drainSession, drainAll };
75
+ }
76
+
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
+ export const DEFAULT_DEVICE_INFO_TIMEOUT_MS = 10_000;
80
+
81
+ export function createDeviceInfoToolHandler(deps) {
82
+ const pending = createPendingDeviceInfoMap();
83
+ const newRequestId =
84
+ deps && typeof deps.newRequestId === "function"
85
+ ? deps.newRequestId
86
+ : () => `dev-${randomUUID().slice(0, 8)}`;
87
+
88
+ function resolveHandlerTimeoutMs() {
89
+ if (!deps || deps.timeoutMs === undefined) return DEFAULT_DEVICE_INFO_TIMEOUT_MS;
90
+ if (typeof deps.timeoutMs === "function") {
91
+ const v = deps.timeoutMs();
92
+ return Number.isFinite(v) ? v : DEFAULT_DEVICE_INFO_TIMEOUT_MS;
93
+ }
94
+ return Number.isFinite(deps.timeoutMs) ? deps.timeoutMs : DEFAULT_DEVICE_INFO_TIMEOUT_MS;
95
+ }
96
+
97
+ deps.relay.onDeviceInfoResponse((msg) => {
98
+ if (!msg || typeof msg.requestId !== "string") return;
99
+ pending.resolve(msg.requestId, msg);
100
+ });
101
+
102
+ async function getDeviceInfo(params, ctx) {
103
+ const validation = validateDeviceInfoInput(params);
104
+ if (!validation.ok) {
105
+ const err = new Error(`${validation.code}: ${validation.message}`);
106
+ err.code = validation.code;
107
+ throw err;
108
+ }
109
+ const sessionKey =
110
+ ctx && typeof ctx.sessionKey === "string" && ctx.sessionKey.trim()
111
+ ? ctx.sessionKey.trim()
112
+ : "main";
113
+ if (typeof deps.isSessionConnected === "function" && !deps.isSessionConnected(sessionKey)) {
114
+ const err = new Error(
115
+ "glasses_not_connected: no Even Realities device client connected for this session",
116
+ );
117
+ err.code = "glasses_not_connected";
118
+ throw err;
119
+ }
120
+ const requestId = newRequestId();
121
+ const promise = pending.register(sessionKey, requestId);
122
+ try {
123
+ deps.relay.sendDeviceInfoRequest({ sessionKey, requestId });
124
+ } 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.
128
+ pending.resolve(requestId, { ok: false, code: "device_unavailable", requestId });
129
+ const outcome = await promise;
130
+ const code =
131
+ outcome && typeof outcome.code === "string" ? outcome.code : "device_unavailable";
132
+ const err = new Error(`${code}: device info request failed`);
133
+ err.code = code;
134
+ throw err;
135
+ }
136
+
137
+ const timeoutMs = resolveHandlerTimeoutMs();
138
+ const setTimeoutFn =
139
+ deps && typeof deps.setTimeout === "function" ? deps.setTimeout : setTimeout;
140
+ const clearTimeoutFn =
141
+ deps && typeof deps.clearTimeout === "function" ? deps.clearTimeout : clearTimeout;
142
+ let timeoutHandle = null;
143
+ if (Number.isFinite(timeoutMs) && timeoutMs > 0) {
144
+ timeoutHandle = setTimeoutFn(() => {
145
+ pending.resolve(requestId, { ok: false, code: "device_info_timeout", requestId });
146
+ }, timeoutMs);
147
+ }
148
+
149
+ const outcome = await promise;
150
+ if (timeoutHandle !== null) clearTimeoutFn(timeoutHandle);
151
+ if (outcome && outcome.ok === true) {
152
+ 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.
156
+ const err = new Error(
157
+ "device_unavailable: device info response was ok but carried no data",
158
+ );
159
+ err.code = "device_unavailable";
160
+ throw err;
161
+ }
162
+ return outcome.data;
163
+ }
164
+ const code =
165
+ outcome && typeof outcome.code === "string" ? outcome.code : "device_unavailable";
166
+ const err = new Error(`${code}: device info request failed`);
167
+ err.code = code;
168
+ throw err;
169
+ }
170
+
171
+ return {
172
+ getDeviceInfo,
173
+ drainSession(sessionKey, outcome) {
174
+ return pending.drainSession(sessionKey, outcome);
175
+ },
176
+ drainAll(outcome) {
177
+ return pending.drainAll(outcome);
178
+ },
179
+ };
180
+ }
181
+
182
+ const TOOL_DESCRIPTION = [
183
+ "Read the user's G2 glasses battery percentage. Returns { batteryLevel: <int 0-100> }.",
184
+ "Errors: glasses_not_connected (no Even Realities device connected),",
185
+ "device_info_timeout, device_unavailable (SDK could not read battery).",
186
+ "Call only when the user asks about glasses battery.",
187
+ ].join("\n");
188
+
189
+ export function registerDeviceInfoTool(api, service) {
190
+ if (!api || typeof api.registerTool !== "function") {
191
+ throw new Error("registerDeviceInfoTool requires api.registerTool");
192
+ }
193
+ if (!service) {
194
+ throw new Error("registerDeviceInfoTool requires the OcuClaw relay service");
195
+ }
196
+
197
+ const handler = createDeviceInfoToolHandler({
198
+ relay: {
199
+ sendDeviceInfoRequest: (msg) => service.sendDeviceInfoRequest(msg),
200
+ onDeviceInfoResponse: (cb) => service.onDeviceInfoResponse(cb),
201
+ },
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.
205
+ isSessionConnected: (_sessionKey) => {
206
+ if (typeof service.hasConnectedAppClient === "function") {
207
+ return service.hasConnectedAppClient();
208
+ }
209
+ return false;
210
+ },
211
+ });
212
+
213
+ api.registerTool({
214
+ name: "get_evenrealities_device_info",
215
+ description: TOOL_DESCRIPTION,
216
+ parameters: deviceInfoParametersSchema,
217
+ async execute(_toolCallId, params) {
218
+ const data = await handler.getDeviceInfo(params, { sessionKey: "main" });
219
+ return {
220
+ content: [{ type: "text", text: JSON.stringify(data) }],
221
+ };
222
+ },
223
+ });
224
+
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
+ if (typeof api.on === "function") {
230
+ api.on("agent_end", (_event, ctx) => {
231
+ const sessionKey = ctx && typeof ctx.sessionKey === "string" ? ctx.sessionKey : null;
232
+ if (sessionKey) {
233
+ handler.drainSession(sessionKey, { ok: false, code: "device_info_aborted" });
234
+ }
235
+ });
236
+ }
237
+
238
+ // Plugin-stop catch-all that drains everything left over across sessions.
239
+ return function dispose() {
240
+ handler.drainAll({ ok: false, code: "device_info_aborted" });
241
+ };
242
+ }
@@ -0,0 +1,427 @@
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
+ import { substituteTemplate } from "./glasses-ui-template.js";
13
+
14
+ const DEFAULT_FAILURE_BODY_PREFIX = "⚠ Update failed: ";
15
+
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
+ const DEFAULT_GLASSES_UI_LIMITS = {
20
+ bodyMax: 1000,
21
+ itemMax: 64,
22
+ detailBodyMax: 200,
23
+ maxItems: 20,
24
+ };
25
+
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
+ const BACKOFF_CAP_MS = 60_000;
32
+
33
+ export function createGlassesUiCronEngine(deps) {
34
+ const executeRecipe = deps.executeRecipe;
35
+ const sendSurfaceUpdate = deps.sendSurfaceUpdate;
36
+ const resolveLlmCtx = deps.resolveLlmCtx || (() => ({}));
37
+ const setTimeoutFn = deps.setTimeoutFn || setTimeout;
38
+ 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.
42
+ const monotonicNowMs =
43
+ typeof deps.monotonicNowMs === "function" ? deps.monotonicNowMs : () => performance.now();
44
+ const limits = deps.glassesUiLimits && typeof deps.glassesUiLimits === "object"
45
+ ? { ...DEFAULT_GLASSES_UI_LIMITS, ...deps.glassesUiLimits }
46
+ : 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.
49
+ const emitLifecycle =
50
+ typeof deps.emitLifecycle === "function" ? deps.emitLifecycle : () => {};
51
+
52
+ const active = new Map(); // surfaceId -> state
53
+
54
+ function emitSurfaceUpdate(state, patch) {
55
+ try {
56
+ sendSurfaceUpdate({ sessionKey: state.sessionKey, surfaceId: state.surfaceId, patch });
57
+ emitLifecycle("cron_tick_emit", "debug", {
58
+ surfaceId: state.surfaceId,
59
+ sessionKey: state.sessionKey,
60
+ generationToken: state.generationToken,
61
+ paused: !!state.paused,
62
+ });
63
+ } 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.
68
+ state.tickFailed += 1;
69
+ state.lastFailureAt = Date.now();
70
+ state.failureReason = `relay send failed: ${err && err.message ? err.message : err}`;
71
+ state.consecutiveFailures += 1;
72
+ }
73
+ }
74
+
75
+ function makeOutcome(state, extra) {
76
+ const ticks = {
77
+ count: state.tickCount,
78
+ succeeded: state.tickSucceeded,
79
+ failed: state.tickFailed,
80
+ lastSuccessAt: state.lastSuccessAt,
81
+ };
82
+ if (state.tickFailed > 0) ticks.lastFailureAt = state.lastFailureAt;
83
+ const outcome = { ticks, lastBody: state.lastBody, lastItems: state.lastItems };
84
+ if (state.failureReason) outcome.failureReason = state.failureReason;
85
+ return Object.assign({}, outcome, extra);
86
+ }
87
+
88
+ function resolveAndClean(state, extra) {
89
+ if (state.resolved) return;
90
+ state.resolved = true;
91
+ if (state.nextTickTimer) clearTimeoutFn(state.nextTickTimer);
92
+ if (state.maxDurationTimer) clearTimeoutFn(state.maxDurationTimer);
93
+ state.nextTickTimer = null;
94
+ state.maxDurationTimer = null;
95
+ active.delete(state.surfaceId);
96
+ try {
97
+ state.onResolve(makeOutcome(state, extra));
98
+ } catch (_) {
99
+ // swallow — caller's resolve handler is theirs to keep safe
100
+ }
101
+ }
102
+
103
+ function wrapForTemplate(value) {
104
+ if (value && typeof value === "object" && !Array.isArray(value)) {
105
+ return { ...value, output: value };
106
+ }
107
+ return { output: value };
108
+ }
109
+
110
+ function substituteOneItemTemplate(tpl, dataForTemplate, opts) {
111
+ // Object template -> {label, body}; plain-string template -> string (label-only,
112
+ // backward compatible with list_surface). Caps: label/itemMax, body/detailBodyMax.
113
+ if (tpl && typeof tpl === "object" && !Array.isArray(tpl)) {
114
+ const labelRaw =
115
+ typeof tpl.label === "string" ? substituteTemplate(tpl.label, dataForTemplate, opts) : "";
116
+ const out = { label: typeof labelRaw === "string" ? labelRaw.slice(0, limits.itemMax) : "" };
117
+ if (typeof tpl.body === "string") {
118
+ const bodyRaw = substituteTemplate(tpl.body, dataForTemplate, opts);
119
+ const cap = limits.detailBodyMax || limits.itemMax;
120
+ out.body = typeof bodyRaw === "string" ? bodyRaw.slice(0, cap) : bodyRaw;
121
+ }
122
+ return out;
123
+ }
124
+ const it = substituteTemplate(tpl, dataForTemplate, opts);
125
+ return typeof it === "string" ? it.slice(0, limits.itemMax) : it;
126
+ }
127
+
128
+ function itemsEqual(prev, next) {
129
+ if (!Array.isArray(prev) || !Array.isArray(next) || prev.length !== next.length) return false;
130
+ for (let i = 0; i < prev.length; i += 1) {
131
+ const a = prev[i];
132
+ const b = next[i];
133
+ if (typeof a === "string" || typeof b === "string") {
134
+ if (a !== b) return false;
135
+ } else if (a && b && typeof a === "object" && typeof b === "object") {
136
+ if (a.label !== b.label || a.body !== b.body) return false;
137
+ } else {
138
+ return false;
139
+ }
140
+ }
141
+ return true;
142
+ }
143
+
144
+ function substituteIntoTargets(targets, output, previousOutput) {
145
+ const opts =
146
+ previousOutput !== undefined ? { previous: wrapForTemplate(previousOutput) } : undefined;
147
+ const dataForTemplate = wrapForTemplate(output);
148
+ const result = {};
149
+ if (typeof targets.body === "string") {
150
+ const body = substituteTemplate(targets.body, dataForTemplate, opts);
151
+ // Clamp to the same caps the initial render uses, so a runaway recipe
152
+ // can't blast the relay or glasses with a multi-MB body per tick.
153
+ result.body = typeof body === "string" ? body.slice(0, limits.bodyMax) : body;
154
+ }
155
+ if (Array.isArray(targets.items)) {
156
+ // Slice BEFORE map — only the first maxItems survive, so substituting
157
+ // templates beyond that is pure waste. validateRefreshSpec already rejects
158
+ // over-long arrays; this is defense-in-depth. Each entry is a string template
159
+ // (label-only) or a {label, body} template object (list_with_details bodies).
160
+ result.items = targets.items
161
+ .slice(0, limits.maxItems)
162
+ .map((tpl) => substituteOneItemTemplate(tpl, dataForTemplate, opts));
163
+ }
164
+ return result;
165
+ }
166
+
167
+ async function runOneTick(state) {
168
+ if (state.resolved) return;
169
+ state.lastTickAt = monotonicNowMs();
170
+ const tickGeneration = state.generationToken;
171
+ state.tickCount += 1;
172
+ let result;
173
+ try {
174
+ const ctx =
175
+ state.recipe.kind === "llm" ? resolveLlmCtx(state) : null;
176
+ result = await executeRecipe(state.recipe, ctx);
177
+ } catch (err) {
178
+ result = { error: `recipe threw: ${err && err.message ? err.message : err}` };
179
+ }
180
+
181
+ if (state.resolved) return;
182
+ // In-flight tick discard: if the surface was paused/popped/re-topped
183
+ // (generation bumped) while this recipe was running, drop its result so
184
+ // it can't patch a hidden screen. Stats already counted the attempt.
185
+ if (tickGeneration !== state.generationToken) {
186
+ return;
187
+ }
188
+
189
+ if (result && typeof result.error === "string") {
190
+ state.tickFailed += 1;
191
+ state.lastFailureAt = Date.now();
192
+ state.failureReason = result.error;
193
+ state.consecutiveFailures += 1;
194
+ state.pendingRetryAfterMs = Number.isFinite(result && result.retryAfterMs)
195
+ ? result.retryAfterMs
196
+ : null;
197
+
198
+ if (state.refresh.onError === "stop") {
199
+ resolveAndClean(state, { result: "recipe_failed" });
200
+ return;
201
+ }
202
+ if (state.consecutiveFailures >= state.refresh.maxConsecutiveFailures) {
203
+ resolveAndClean(state, { result: "recipe_failed" });
204
+ return;
205
+ }
206
+ if (state.refresh.onError === "show_error") {
207
+ const errorBody = DEFAULT_FAILURE_BODY_PREFIX + result.error.slice(0, 100);
208
+ if (state.lastBody !== errorBody) {
209
+ state.lastBody = errorBody;
210
+ emitSurfaceUpdate(state, { body: errorBody });
211
+ }
212
+ }
213
+ // keep_last: do nothing — preserve previous lastBody/lastItems.
214
+ } else if (result && Object.prototype.hasOwnProperty.call(result, "output")) {
215
+ state.tickSucceeded += 1;
216
+ state.lastSuccessAt = Date.now();
217
+ state.consecutiveFailures = 0;
218
+ state.failureReason = undefined;
219
+ state.pendingRetryAfterMs = null;
220
+ const substituted = substituteIntoTargets(state.refresh.targets, result.output, state.lastRecipeOutput);
221
+ state.lastRecipeOutput = result.output;
222
+ const patch = {};
223
+ let changed = false;
224
+ if (substituted.body !== undefined && substituted.body !== state.lastBody) {
225
+ patch.body = substituted.body;
226
+ state.lastBody = substituted.body;
227
+ changed = true;
228
+ }
229
+ if (substituted.items !== undefined) {
230
+ if (!itemsEqual(state.lastItems, substituted.items)) {
231
+ patch.items = substituted.items;
232
+ state.lastItems = substituted.items;
233
+ changed = true;
234
+ }
235
+ }
236
+ if (changed) {
237
+ emitSurfaceUpdate(state, patch);
238
+ }
239
+ } else {
240
+ state.tickFailed += 1;
241
+ state.failureReason = "recipe returned no output";
242
+ state.consecutiveFailures += 1;
243
+ if (state.consecutiveFailures >= state.refresh.maxConsecutiveFailures) {
244
+ resolveAndClean(state, { result: "recipe_failed" });
245
+ return;
246
+ }
247
+ }
248
+
249
+ // Schedule next tick. A run of failures backs off exponentially (capped);
250
+ // a Retry-After from the recipe (e.g. http 429) overrides the computed
251
+ // delay; a clean tick uses the base interval.
252
+ if (!state.resolved && !state.isSmokeTest && !state.paused) {
253
+ const base = state.refresh.intervalMs;
254
+ let delay = base;
255
+ if (state.consecutiveFailures > 0) {
256
+ delay = Math.min(base * Math.pow(2, state.consecutiveFailures), BACKOFF_CAP_MS);
257
+ }
258
+ if (Number.isFinite(state.pendingRetryAfterMs) && state.pendingRetryAfterMs > 0) {
259
+ delay = state.pendingRetryAfterMs;
260
+ }
261
+ state.nextTickTimer = setTimeoutFn(() => {
262
+ state.nextTickTimer = null;
263
+ runOneTick(state);
264
+ }, delay);
265
+ }
266
+ }
267
+
268
+ async function runSmokeTest(state) {
269
+ state.isSmokeTest = true;
270
+ await runOneTick(state);
271
+ state.isSmokeTest = false;
272
+ if (state.resolved) return; // smoke failed and policy was stop/breaker
273
+ // The smoke test must succeed before we begin the regular cadence — any
274
+ // failure on tick 1 short-circuits to recipe_failed regardless of policy.
275
+ if (state.tickFailed > 0) {
276
+ resolveAndClean(state, { result: "recipe_failed" });
277
+ return;
278
+ }
279
+ // After the smoke test, kick off the regular schedule.
280
+ state.nextTickTimer = setTimeoutFn(() => {
281
+ state.nextTickTimer = null;
282
+ runOneTick(state);
283
+ }, state.refresh.intervalMs);
284
+ }
285
+
286
+ return {
287
+ start(params) {
288
+ const state = {
289
+ surfaceId: params.surfaceId,
290
+ sessionKey: params.sessionKey,
291
+ refresh: params.refresh,
292
+ recipe: params.refresh.recipe,
293
+ onResolve: params.onResolve,
294
+ startedAt: Date.now(),
295
+ tickCount: 0,
296
+ tickSucceeded: 0,
297
+ tickFailed: 0,
298
+ consecutiveFailures: 0,
299
+ lastBody: params.seedBody,
300
+ lastItems: params.seedItems,
301
+ lastRecipeOutput: undefined,
302
+ lastSuccessAt: undefined,
303
+ lastFailureAt: undefined,
304
+ failureReason: undefined,
305
+ resolved: false,
306
+ nextTickTimer: null,
307
+ maxDurationTimer: null,
308
+ isSmokeTest: false,
309
+ lastTickAt: null,
310
+ generationToken: 0,
311
+ paused: false,
312
+ pendingRetryAfterMs: null,
313
+ };
314
+ active.set(state.surfaceId, state);
315
+ // Arm the duration cap.
316
+ state.maxDurationTimer = setTimeoutFn(() => {
317
+ resolveAndClean(state, { result: "timeout" });
318
+ }, params.refresh.maxDurationMs);
319
+ // start() is sync; runSmokeTest is async. Catch any rejection so it
320
+ // doesn't become an unhandled-rejection — instead resolve the cron
321
+ // with a recipe_failed outcome carrying the rejection message.
322
+ runSmokeTest(state).catch((err) => {
323
+ resolveAndClean(state, {
324
+ result: "recipe_failed",
325
+ failureReason: `smoke test threw: ${err && err.message ? err.message : err}`,
326
+ });
327
+ });
328
+ },
329
+ stop(surfaceId, outcome) {
330
+ const state = active.get(surfaceId);
331
+ if (!state) return false;
332
+ resolveAndClean(state, outcome || { result: "preempted" });
333
+ return true;
334
+ },
335
+ stopAllForSession(sessionKey, outcome) {
336
+ const matches = [];
337
+ for (const [sid, state] of active) {
338
+ if (state.sessionKey === sessionKey) matches.push(sid);
339
+ }
340
+ for (const sid of matches) this.stop(sid, outcome);
341
+ return matches.length;
342
+ },
343
+ stopAll(outcome) {
344
+ const ids = [...active.keys()];
345
+ for (const sid of ids) this.stop(sid, outcome);
346
+ return ids.length;
347
+ },
348
+ activeCount() {
349
+ return active.size;
350
+ },
351
+ isActive(surfaceId) {
352
+ return active.has(surfaceId);
353
+ },
354
+ _debugState(surfaceId) {
355
+ return active.get(surfaceId);
356
+ },
357
+ bumpGeneration(surfaceId) {
358
+ const state = active.get(surfaceId);
359
+ if (!state) return false;
360
+ state.generationToken += 1;
361
+ return true;
362
+ },
363
+ pause(surfaceId) {
364
+ const state = active.get(surfaceId);
365
+ if (!state || state.resolved) {
366
+ emitLifecycle("cron_pause", "debug", {
367
+ surfaceId,
368
+ found: !!state,
369
+ resolved: !!(state && state.resolved),
370
+ });
371
+ return false;
372
+ }
373
+ if (state.nextTickTimer) {
374
+ clearTimeoutFn(state.nextTickTimer);
375
+ state.nextTickTimer = null;
376
+ }
377
+ state.paused = true;
378
+ // Bump so an in-flight tick (started before pause) is discarded when it
379
+ // resolves — it must not patch the now-hidden parent screen.
380
+ state.generationToken += 1;
381
+ emitLifecycle("cron_pause", "debug", { surfaceId, found: true, resolved: false });
382
+ return true;
383
+ },
384
+ resume(surfaceId) {
385
+ const state = active.get(surfaceId);
386
+ if (!state || state.resolved) {
387
+ emitLifecycle("cron_resume", "debug", {
388
+ surfaceId,
389
+ found: !!state,
390
+ resolved: !!(state && state.resolved),
391
+ branch: "noop",
392
+ });
393
+ return false;
394
+ }
395
+ state.paused = false;
396
+ if (state.nextTickTimer) {
397
+ clearTimeoutFn(state.nextTickTimer);
398
+ state.nextTickTimer = null;
399
+ }
400
+ const lastTickAt = Number.isFinite(state.lastTickAt) ? state.lastTickAt : 0;
401
+ const elapsed = monotonicNowMs() - lastTickAt;
402
+ const intervalMs = state.refresh.intervalMs;
403
+ emitLifecycle("cron_resume", "debug", {
404
+ surfaceId,
405
+ found: true,
406
+ resolved: false,
407
+ elapsedMs: Math.round(elapsed),
408
+ intervalMs,
409
+ branch: elapsed >= intervalMs ? "refire" : "schedule",
410
+ });
411
+ if (elapsed >= intervalMs) {
412
+ // Stale: refire now via the TICK path (NOT runSmokeTest — a smoke
413
+ // failure is terminal regardless of onError policy). runOneTick
414
+ // re-arms the next tick itself.
415
+ runOneTick(state);
416
+ } else {
417
+ state.nextTickTimer = setTimeoutFn(() => {
418
+ state.nextTickTimer = null;
419
+ runOneTick(state);
420
+ }, intervalMs - elapsed);
421
+ }
422
+ return true;
423
+ },
424
+ };
425
+ }
426
+
427
+ export default { createGlassesUiCronEngine };