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.
- package/README.md +21 -6
- package/dist/config/runtime-config.js +84 -3
- package/dist/domain/activity-status-adapter.js +138 -605
- package/dist/domain/activity-status-arbiter.js +109 -0
- package/dist/domain/activity-status-labels.js +906 -0
- package/dist/domain/code-span-regions.js +103 -0
- package/dist/domain/conversation-state.js +14 -1
- package/dist/domain/debug-store.js +56 -182
- package/dist/domain/glasses-ui-content-summary.js +62 -0
- package/dist/domain/glasses-ui-system-prompt.js +28 -0
- package/dist/domain/message-emoji-allowlist.js +16 -0
- package/dist/domain/message-emoji-filter.js +33 -55
- package/dist/domain/neural-emoji-reactor-system-prompt.js +43 -0
- package/dist/domain/neural-emoji-reactor-tag-config.js +56 -0
- package/dist/domain/neural-pace-modulator-system-prompt.js +32 -0
- package/dist/domain/neural-pace-modulator-tag-config.js +51 -0
- package/dist/domain/tagged-span-parser.js +121 -0
- package/dist/domain/tagged-span-strip.js +38 -0
- package/dist/even-ai/even-ai-endpoint.js +91 -0
- package/dist/even-ai/even-ai-run-waiter.js +14 -0
- package/dist/even-ai/even-ai-settings-store.js +14 -0
- package/dist/gateway/gateway-bridge.js +14 -2
- package/dist/gateway/gateway-timing-ledger.js +457 -0
- package/dist/gateway/openclaw-client.js +462 -38
- package/dist/index.js +28 -1
- package/dist/runtime/downstream-handler.js +754 -83
- package/dist/runtime/ocuclaw-settings-store.js +74 -31
- package/dist/runtime/plugin-version-service.js +23 -0
- package/dist/runtime/protocol-adapter.js +9 -0
- package/dist/runtime/provider-usage-select.js +168 -0
- package/dist/runtime/relay-client-nudge-controller.js +553 -0
- package/dist/runtime/relay-core.js +1293 -225
- package/dist/runtime/relay-health-monitor.js +172 -0
- package/dist/runtime/relay-operation-registry.js +263 -0
- package/dist/runtime/relay-service.js +201 -1
- package/dist/runtime/relay-worker-approval-replay-cache.js +68 -0
- package/dist/runtime/relay-worker-entry.js +32 -0
- package/dist/runtime/relay-worker-health.js +272 -0
- package/dist/runtime/relay-worker-protocol.js +281 -0
- package/dist/runtime/relay-worker-queue.js +202 -0
- package/dist/runtime/relay-worker-supervisor.js +1004 -0
- package/dist/runtime/relay-worker-transport.js +1051 -0
- package/dist/runtime/session-context-service.js +189 -0
- package/dist/runtime/session-service.js +638 -27
- package/dist/runtime/upstream-runtime.js +1167 -60
- package/dist/tools/device-info-tool.js +242 -0
- package/dist/tools/glasses-ui-cron.js +427 -0
- package/dist/tools/glasses-ui-descriptors.js +261 -0
- package/dist/tools/glasses-ui-limits.js +21 -0
- package/dist/tools/glasses-ui-paint-floor.js +99 -0
- package/dist/tools/glasses-ui-recipes.js +581 -0
- package/dist/tools/glasses-ui-surfaces.js +278 -0
- package/dist/tools/glasses-ui-template.js +182 -0
- package/dist/tools/glasses-ui-tool.js +1111 -0
- package/dist/tools/session-title-tool.js +209 -0
- package/dist/version.js +2 -0
- package/openclaw.plugin.json +163 -15
- package/package.json +14 -5
- package/skills/glasses-ui/SKILL.md +156 -0
- package/dist/runtime/downstream-server.js +0 -1891
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
// Kind-descriptor registry for the glasses-UI surface kinds. Each descriptor
|
|
2
|
+
// is a self-contained unit owning everything per-kind on the PLUGIN side:
|
|
3
|
+
// - schemaBranch: the JSON-Schema oneOf entry for this kind
|
|
4
|
+
// - validateSpec: per-kind validation, returns the canonical
|
|
5
|
+
// { ok:true, spec } | { ok:false, code, message } shape
|
|
6
|
+
// - refreshTargets: which refresh.targets fields this kind binds
|
|
7
|
+
// ("body" for text, "items" for the list kinds) —
|
|
8
|
+
// informational in Phase 1, consumed in Phase 3.
|
|
9
|
+
// The core (glassesUiParametersSchema, validateGlassesUiSpec) dispatches by
|
|
10
|
+
// kind STRING through this registry and never switches on a specific kind, so
|
|
11
|
+
// adding a kind is one descriptor with zero core edits (spec §Modularity).
|
|
12
|
+
// Phase 1 ports the existing 3 kinds with NO behavior change.
|
|
13
|
+
//
|
|
14
|
+
// NOTE: this module imports GLASSES_UI_LIMITS from ./glasses-ui-limits.js (a
|
|
15
|
+
// dependency-free leaf), NOT from ./glasses-ui-tool.js — glasses-ui-tool.js
|
|
16
|
+
// imports THIS module, so importing back from it would form a require cycle
|
|
17
|
+
// that the CJS emitter cannot resolve (module.exports is written at
|
|
18
|
+
// end-of-module, so a mid-cycle require sees {}). See glasses-ui-limits.ts.
|
|
19
|
+
|
|
20
|
+
import { GLASSES_UI_LIMITS } from "./glasses-ui-limits.js";
|
|
21
|
+
|
|
22
|
+
// Shared title validation (moved from validateGlassesUiSpec — every kind ran
|
|
23
|
+
// it before its branch). Returns an error result or null.
|
|
24
|
+
function validateTitle(obj) {
|
|
25
|
+
if (typeof obj.title === "undefined") return null;
|
|
26
|
+
if (typeof obj.title !== "string") {
|
|
27
|
+
return { ok: false, code: "title_too_long", message: "title must be a string" };
|
|
28
|
+
}
|
|
29
|
+
if (obj.title.length > GLASSES_UI_LIMITS.titleMax) {
|
|
30
|
+
return {
|
|
31
|
+
ok: false,
|
|
32
|
+
code: "title_too_long",
|
|
33
|
+
message: `title is ${obj.title.length} chars; max ${GLASSES_UI_LIMITS.titleMax}`,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ---- text_surface ------------------------------------------------------
|
|
40
|
+
const textSurfaceDescriptor = {
|
|
41
|
+
kind: "text_surface",
|
|
42
|
+
refreshTargets: ["body"],
|
|
43
|
+
schemaBranch: {
|
|
44
|
+
title: "text_surface",
|
|
45
|
+
type: "object",
|
|
46
|
+
required: ["kind", "body"],
|
|
47
|
+
properties: {
|
|
48
|
+
kind: { const: "text_surface" },
|
|
49
|
+
title: { type: "string", maxLength: GLASSES_UI_LIMITS.titleMax },
|
|
50
|
+
body: { type: "string", maxLength: GLASSES_UI_LIMITS.bodyMax },
|
|
51
|
+
refresh: undefined, // filled by the tool when assembling the schema
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
validateSpec(obj) {
|
|
55
|
+
const titleErr = validateTitle(obj);
|
|
56
|
+
if (titleErr) return titleErr;
|
|
57
|
+
const body = obj.body;
|
|
58
|
+
if (typeof body !== "string") {
|
|
59
|
+
return { ok: false, code: "missing_field", message: "text_surface requires body (string)" };
|
|
60
|
+
}
|
|
61
|
+
if (body.length > GLASSES_UI_LIMITS.bodyMax) {
|
|
62
|
+
return {
|
|
63
|
+
ok: false,
|
|
64
|
+
code: "body_too_long",
|
|
65
|
+
message: `body is ${body.length} chars; max ${GLASSES_UI_LIMITS.bodyMax}`,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
const spec = { kind: "text_surface", body };
|
|
69
|
+
if (typeof obj.title === "string") spec.title = obj.title;
|
|
70
|
+
return { ok: true, spec };
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// ---- list_surface ------------------------------------------------------
|
|
75
|
+
const listSurfaceDescriptor = {
|
|
76
|
+
kind: "list_surface",
|
|
77
|
+
refreshTargets: ["items"],
|
|
78
|
+
schemaBranch: {
|
|
79
|
+
title: "list_surface",
|
|
80
|
+
type: "object",
|
|
81
|
+
required: ["kind", "items"],
|
|
82
|
+
properties: {
|
|
83
|
+
kind: { const: "list_surface" },
|
|
84
|
+
title: { type: "string", maxLength: GLASSES_UI_LIMITS.titleMax },
|
|
85
|
+
items: {
|
|
86
|
+
type: "array",
|
|
87
|
+
minItems: 1,
|
|
88
|
+
maxItems: GLASSES_UI_LIMITS.maxItems,
|
|
89
|
+
items: { type: "string", maxLength: GLASSES_UI_LIMITS.itemMax },
|
|
90
|
+
},
|
|
91
|
+
refresh: undefined,
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
validateSpec(obj) {
|
|
95
|
+
const titleErr = validateTitle(obj);
|
|
96
|
+
if (titleErr) return titleErr;
|
|
97
|
+
const items = obj.items;
|
|
98
|
+
if (!Array.isArray(items) || items.length === 0) {
|
|
99
|
+
return { ok: false, code: "missing_field", message: "list_surface requires items (non-empty array)" };
|
|
100
|
+
}
|
|
101
|
+
if (items.length > GLASSES_UI_LIMITS.maxItems) {
|
|
102
|
+
return {
|
|
103
|
+
ok: false,
|
|
104
|
+
code: "too_many_items",
|
|
105
|
+
message: `${items.length} items; max ${GLASSES_UI_LIMITS.maxItems}`,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
for (let i = 0; i < items.length; i += 1) {
|
|
109
|
+
const item = items[i];
|
|
110
|
+
if (typeof item !== "string") {
|
|
111
|
+
return { ok: false, code: "item_too_long", message: `items[${i}] must be a string` };
|
|
112
|
+
}
|
|
113
|
+
if (item.length > GLASSES_UI_LIMITS.itemMax) {
|
|
114
|
+
return {
|
|
115
|
+
ok: false,
|
|
116
|
+
code: "item_too_long",
|
|
117
|
+
message: `items[${i}] is ${item.length} chars; max ${GLASSES_UI_LIMITS.itemMax}`,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
const spec = { kind: "list_surface", items };
|
|
122
|
+
if (typeof obj.title === "string") spec.title = obj.title;
|
|
123
|
+
return { ok: true, spec };
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
// ---- list_with_details_surface ----------------------------------------
|
|
128
|
+
const listWithDetailsSurfaceDescriptor = {
|
|
129
|
+
kind: "list_with_details_surface",
|
|
130
|
+
refreshTargets: ["items"],
|
|
131
|
+
schemaBranch: {
|
|
132
|
+
title: "list_with_details_surface",
|
|
133
|
+
type: "object",
|
|
134
|
+
required: ["kind", "items"],
|
|
135
|
+
properties: {
|
|
136
|
+
kind: { const: "list_with_details_surface" },
|
|
137
|
+
title: { type: "string", maxLength: GLASSES_UI_LIMITS.titleMax },
|
|
138
|
+
items: {
|
|
139
|
+
type: "array",
|
|
140
|
+
minItems: 1,
|
|
141
|
+
maxItems: GLASSES_UI_LIMITS.maxItems,
|
|
142
|
+
items: {
|
|
143
|
+
type: "object",
|
|
144
|
+
required: ["label"],
|
|
145
|
+
properties: {
|
|
146
|
+
label: { type: "string", maxLength: GLASSES_UI_LIMITS.itemMax },
|
|
147
|
+
body: { type: "string", maxLength: GLASSES_UI_LIMITS.detailBodyMax },
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
refresh: undefined,
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
validateSpec(obj) {
|
|
155
|
+
const titleErr = validateTitle(obj);
|
|
156
|
+
if (titleErr) return titleErr;
|
|
157
|
+
// Empirically (2026-05-24 hardware logs), the agent often produces the
|
|
158
|
+
// parallel-array shape — items=[strings] plus a sibling `details` /
|
|
159
|
+
// `itemDetails` / `bodies` array — instead of the canonical [{label, body?}]
|
|
160
|
+
// objects. We accept the parallel-array shape and coerce it server-side so
|
|
161
|
+
// the wire format downstream stays canonical.
|
|
162
|
+
const rawItems = obj.items;
|
|
163
|
+
if (!Array.isArray(rawItems) || rawItems.length === 0) {
|
|
164
|
+
return {
|
|
165
|
+
ok: false,
|
|
166
|
+
code: "missing_field",
|
|
167
|
+
message: "list_with_details_surface requires items (non-empty array)",
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
if (rawItems.length > GLASSES_UI_LIMITS.maxItems) {
|
|
171
|
+
return {
|
|
172
|
+
ok: false,
|
|
173
|
+
code: "too_many_items",
|
|
174
|
+
message: `${rawItems.length} items; max ${GLASSES_UI_LIMITS.maxItems}`,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
const parallelBodies =
|
|
178
|
+
Array.isArray(obj.details) ? obj.details
|
|
179
|
+
: Array.isArray(obj.itemDetails) ? obj.itemDetails
|
|
180
|
+
: Array.isArray(obj.bodies) ? obj.bodies
|
|
181
|
+
: null;
|
|
182
|
+
const items = rawItems.map((entry, i) => {
|
|
183
|
+
if (typeof entry === "string") {
|
|
184
|
+
const sibling = parallelBodies ? parallelBodies[i] : undefined;
|
|
185
|
+
// The sibling can be a plain string body, OR (some models do this) a
|
|
186
|
+
// {label, body} object that duplicates the label — accept either.
|
|
187
|
+
if (typeof sibling === "string") {
|
|
188
|
+
return { label: entry, body: sibling };
|
|
189
|
+
}
|
|
190
|
+
if (sibling && typeof sibling === "object" && typeof sibling.body === "string") {
|
|
191
|
+
return { label: entry, body: sibling.body };
|
|
192
|
+
}
|
|
193
|
+
// No matching parallel body — degrade gracefully to label-only.
|
|
194
|
+
return { label: entry };
|
|
195
|
+
}
|
|
196
|
+
return entry;
|
|
197
|
+
});
|
|
198
|
+
let totalBodyChars = 0;
|
|
199
|
+
const normalizedItems = [];
|
|
200
|
+
for (let i = 0; i < items.length; i += 1) {
|
|
201
|
+
const it = items[i];
|
|
202
|
+
if (!it || typeof it !== "object") {
|
|
203
|
+
return { ok: false, code: "missing_field", message: `items[${i}] must be an object {label, body?}` };
|
|
204
|
+
}
|
|
205
|
+
if (typeof it.label !== "string") {
|
|
206
|
+
return { ok: false, code: "missing_field", message: `items[${i}].label is required` };
|
|
207
|
+
}
|
|
208
|
+
if (it.label.length > GLASSES_UI_LIMITS.itemMax) {
|
|
209
|
+
return {
|
|
210
|
+
ok: false,
|
|
211
|
+
code: "item_too_long",
|
|
212
|
+
message: `items[${i}].label is ${it.label.length} chars; max ${GLASSES_UI_LIMITS.itemMax}`,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
const normalized = { label: it.label };
|
|
216
|
+
if (it.body !== undefined) {
|
|
217
|
+
if (typeof it.body !== "string") {
|
|
218
|
+
return { ok: false, code: "detail_body_too_long", message: `items[${i}].body must be a string` };
|
|
219
|
+
}
|
|
220
|
+
if (it.body.length > GLASSES_UI_LIMITS.detailBodyMax) {
|
|
221
|
+
return {
|
|
222
|
+
ok: false,
|
|
223
|
+
code: "detail_body_too_long",
|
|
224
|
+
message: `items[${i}].body is ${it.body.length} chars; max ${GLASSES_UI_LIMITS.detailBodyMax}`,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
totalBodyChars += it.body.length;
|
|
228
|
+
normalized.body = it.body;
|
|
229
|
+
}
|
|
230
|
+
normalizedItems.push(normalized);
|
|
231
|
+
}
|
|
232
|
+
if (totalBodyChars > GLASSES_UI_LIMITS.totalDetailPayloadMax) {
|
|
233
|
+
return {
|
|
234
|
+
ok: false,
|
|
235
|
+
code: "total_payload_too_large",
|
|
236
|
+
message: `bodies sum to ${totalBodyChars} chars; max ${GLASSES_UI_LIMITS.totalDetailPayloadMax}`,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
const spec = { kind: "list_with_details_surface", items: normalizedItems };
|
|
240
|
+
if (typeof obj.title === "string") spec.title = obj.title;
|
|
241
|
+
return { ok: true, spec };
|
|
242
|
+
},
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
export const GLASSES_UI_KIND_DESCRIPTORS = [
|
|
246
|
+
textSurfaceDescriptor,
|
|
247
|
+
listSurfaceDescriptor,
|
|
248
|
+
listWithDetailsSurfaceDescriptor,
|
|
249
|
+
];
|
|
250
|
+
|
|
251
|
+
export function getKindDescriptor(kind) {
|
|
252
|
+
return GLASSES_UI_KIND_DESCRIPTORS.find((d) => d.kind === kind);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export function listKindStrings() {
|
|
256
|
+
return GLASSES_UI_KIND_DESCRIPTORS.map((d) => d.kind);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function buildOneOfBranches() {
|
|
260
|
+
return GLASSES_UI_KIND_DESCRIPTORS.map((d) => d.schemaBranch);
|
|
261
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Shared per-kind size caps for the glasses-UI surface tool. Extracted into its
|
|
2
|
+
// own leaf module so BOTH the tool (glasses-ui-tool.ts) and the kind-descriptor
|
|
3
|
+
// registry (glasses-ui-descriptors.ts) can import the same limits WITHOUT
|
|
4
|
+
// forming an import cycle between those two modules.
|
|
5
|
+
//
|
|
6
|
+
// Why a leaf module is required (not a cycle): the plugin's CJS emitter
|
|
7
|
+
// (scripts/build.mjs) converts `import {X} from "y"` into an in-place
|
|
8
|
+
// `const {X} = require("y.cjs")` and appends `module.exports = {...}` only at
|
|
9
|
+
// END of module execution (no live getters). Under those semantics a
|
|
10
|
+
// bidirectional descriptors<->tool require resolves to `{}` mid-cycle and
|
|
11
|
+
// crashes (GLASSES_UI_LIMITS undefined / listKindStrings not a function). Giving
|
|
12
|
+
// the limits their own dependency-free module makes the graph a DAG
|
|
13
|
+
// (tool -> {limits, descriptors}; descriptors -> limits), so no cycle exists.
|
|
14
|
+
export const GLASSES_UI_LIMITS = {
|
|
15
|
+
bodyMax: 1000,
|
|
16
|
+
itemMax: 64,
|
|
17
|
+
titleMax: 64,
|
|
18
|
+
maxItems: 20,
|
|
19
|
+
detailBodyMax: 200,
|
|
20
|
+
totalDetailPayloadMax: 6 * 1024,
|
|
21
|
+
};
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Per-surface trailing-edge paint-floor coalescer.
|
|
2
|
+
//
|
|
3
|
+
// Governs ALL plugin->glass sends (initial RebuildPageContainer render and
|
|
4
|
+
// every surface_update patch). Collapses bursts to last-write-wins per field
|
|
5
|
+
// and emits at most one frame per paintFloorMs (default 150ms, Spike D), with
|
|
6
|
+
// a leading-edge send + a trailing send carrying the final merged patch.
|
|
7
|
+
//
|
|
8
|
+
// There is NO glass-side paint-ack: the only backpressure signal is
|
|
9
|
+
// relay/BLE transport-side (see the isUnderBackpressure shed in Task 13).
|
|
10
|
+
// Local fake-list textContainerUpgrade scroll-swaps are client-side and never
|
|
11
|
+
// reach this coalescer.
|
|
12
|
+
|
|
13
|
+
export const DEFAULT_PAINT_FLOOR_MS = 150;
|
|
14
|
+
|
|
15
|
+
export function createPaintFloorCoalescer(deps) {
|
|
16
|
+
const paintFloorMs = Number.isFinite(deps.paintFloorMs) ? deps.paintFloorMs : DEFAULT_PAINT_FLOOR_MS;
|
|
17
|
+
const send = deps.send;
|
|
18
|
+
const nowMs = typeof deps.nowMs === "function" ? deps.nowMs : () => performance.now();
|
|
19
|
+
const setTimeoutFn = deps.setTimeoutFn || setTimeout;
|
|
20
|
+
const clearTimeoutFn = deps.clearTimeoutFn || clearTimeout;
|
|
21
|
+
const isUnderBackpressure =
|
|
22
|
+
typeof deps.isUnderBackpressure === "function" ? deps.isUnderBackpressure : () => false;
|
|
23
|
+
|
|
24
|
+
// surfaceId -> { sessionKey, lastSentAt, pendingPatch, timer }
|
|
25
|
+
const bySurface = new Map();
|
|
26
|
+
|
|
27
|
+
function isRenderSentinel(p) {
|
|
28
|
+
return !!(p && p.__render === true);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function mergePatch(base, incoming) {
|
|
32
|
+
// A render sentinel ({ __render, __depth, __spec }, Task 13) and a field
|
|
33
|
+
// patch ({ body }/{ items }/{ title }) must NEVER shallow-merge — a merged
|
|
34
|
+
// object would carry both __spec and a stray `body`, painting a malformed
|
|
35
|
+
// frame. They are different write kinds, so the LATER write supersedes the
|
|
36
|
+
// earlier one wholesale (a render replaces a queued field patch; a field
|
|
37
|
+
// patch after a queued render replaces that render). Same-kind writes
|
|
38
|
+
// merge last-write-wins per field as before.
|
|
39
|
+
if (isRenderSentinel(base) !== isRenderSentinel(incoming)) {
|
|
40
|
+
return incoming && typeof incoming === "object" ? { ...incoming } : {};
|
|
41
|
+
}
|
|
42
|
+
const merged = base ? { ...base } : {};
|
|
43
|
+
if (incoming && typeof incoming === "object") {
|
|
44
|
+
for (const k of Object.keys(incoming)) merged[k] = incoming[k];
|
|
45
|
+
}
|
|
46
|
+
return merged;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function flush(surfaceId) {
|
|
50
|
+
const st = bySurface.get(surfaceId);
|
|
51
|
+
if (!st || !st.pendingPatch) return;
|
|
52
|
+
st.timer = null;
|
|
53
|
+
if (isUnderBackpressure()) {
|
|
54
|
+
// Shed: do NOT send while the relay/BLE send buffer is over the high
|
|
55
|
+
// water mark. Retain pendingPatch (last-write-wins) and re-arm so the
|
|
56
|
+
// final merged value lands once pressure clears. No glass-side ack
|
|
57
|
+
// exists, so transport-side pressure is the only signal.
|
|
58
|
+
st.timer = setTimeoutFn(() => flush(surfaceId), Math.max(16, paintFloorMs));
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const patch = st.pendingPatch;
|
|
62
|
+
st.pendingPatch = null;
|
|
63
|
+
st.lastSentAt = nowMs();
|
|
64
|
+
send({ surfaceId, sessionKey: st.sessionKey, patch });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function enqueue(params) {
|
|
68
|
+
const { surfaceId, sessionKey, patch } = params;
|
|
69
|
+
let st = bySurface.get(surfaceId);
|
|
70
|
+
if (!st) {
|
|
71
|
+
st = { sessionKey, lastSentAt: -Infinity, pendingPatch: null, timer: null };
|
|
72
|
+
bySurface.set(surfaceId, st);
|
|
73
|
+
}
|
|
74
|
+
st.sessionKey = sessionKey;
|
|
75
|
+
const elapsed = nowMs() - st.lastSentAt;
|
|
76
|
+
if (elapsed >= paintFloorMs && !st.timer) {
|
|
77
|
+
// Leading edge: send immediately.
|
|
78
|
+
st.lastSentAt = nowMs();
|
|
79
|
+
send({ surfaceId, sessionKey, patch });
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
// Within the floor: merge into the pending patch, arm a trailing flush.
|
|
83
|
+
st.pendingPatch = mergePatch(st.pendingPatch, patch);
|
|
84
|
+
if (!st.timer) {
|
|
85
|
+
const wait = Math.max(0, paintFloorMs - elapsed);
|
|
86
|
+
st.timer = setTimeoutFn(() => flush(surfaceId), wait);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function dispose(surfaceId) {
|
|
91
|
+
const st = bySurface.get(surfaceId);
|
|
92
|
+
if (st && st.timer) clearTimeoutFn(st.timer);
|
|
93
|
+
bySurface.delete(surfaceId);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return { enqueue, dispose, _bySurface: bySurface };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export default { createPaintFloorCoalescer, DEFAULT_PAINT_FLOOR_MS };
|