ocuclaw 1.3.2 → 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.
- package/README.md +29 -1
- package/dist/config/runtime-config-session-title-model.test.js +0 -3
- package/dist/config/runtime-config.js +22 -33
- package/dist/domain/activity-status-adapter.js +0 -7
- package/dist/domain/activity-status-arbiter.js +3 -27
- package/dist/domain/activity-status-labels.js +8 -38
- package/dist/domain/code-span-regions.js +4 -24
- package/dist/domain/constant-time-equal.js +9 -0
- package/dist/domain/constant-time-equal.test.js +28 -0
- package/dist/domain/conversation-state.js +27 -138
- package/dist/domain/debug-bundle-cache.js +52 -0
- package/dist/domain/debug-bundle-format.js +60 -0
- package/dist/domain/debug-bundle-preview.js +123 -0
- package/dist/domain/debug-bundle-redaction.js +182 -0
- package/dist/domain/debug-bundle-save.js +11 -0
- package/dist/domain/debug-bundle-zip.js +15 -0
- package/dist/domain/debug-bundle.js +97 -0
- package/dist/domain/debug-store.js +6 -17
- package/dist/domain/debug-upload-preset.js +27 -0
- package/dist/domain/glasses-display-system-prompt.js +0 -5
- package/dist/domain/glasses-display-system-prompt.test.js +1 -1
- package/dist/domain/glasses-ui-content-summary.js +0 -6
- package/dist/domain/glasses-ui-system-prompt.test.js +1 -2
- package/dist/domain/message-emoji-allowlist.js +0 -7
- package/dist/domain/message-emoji-filter.js +3 -9
- package/dist/domain/neural-emoji-reactor-tag-config.js +3 -3
- package/dist/domain/prompt-channel-fragments.js +1 -10
- package/dist/domain/tagged-span-parser.js +3 -26
- package/dist/domain/tagged-span-strip.js +0 -7
- package/dist/even-ai/even-ai-endpoint.js +77 -24
- package/dist/even-ai/even-ai-run-waiter.js +0 -1
- package/dist/even-ai/even-ai-settings-store.js +11 -0
- package/dist/gateway/gateway-bridge.js +8 -9
- package/dist/gateway/gateway-timing-ledger.js +8 -6
- package/dist/gateway/openclaw-client.js +97 -297
- package/dist/gateway/sanitize-connect-reason.js +10 -0
- package/dist/gateway/sanitize-connect-reason.test.js +34 -0
- package/dist/index.js +3 -3
- package/dist/runtime/channel-two-hook.js +1 -6
- package/dist/runtime/container-env.js +1 -5
- package/dist/runtime/debug-bundle-handler.js +159 -0
- package/dist/runtime/display-toggle-states.js +6 -17
- package/dist/runtime/downstream-handler.js +682 -508
- package/dist/runtime/glasses-backpressure-latch.js +93 -0
- package/dist/runtime/ocuclaw-settings-store.js +10 -1
- package/dist/runtime/openclaw-host-version.js +5 -0
- package/dist/runtime/plugin-version-service.js +13 -6
- package/dist/runtime/provider-usage-select.js +0 -6
- package/dist/runtime/register-session-title-distiller.js +14 -16
- package/dist/runtime/relay-core.js +657 -271
- package/dist/runtime/relay-service.js +40 -36
- package/dist/runtime/relay-worker-approval-replay-cache.js +1 -1
- package/dist/runtime/relay-worker-entry.js +1 -2
- package/dist/runtime/relay-worker-health.js +2 -10
- package/dist/runtime/relay-worker-protocol.js +6 -1
- package/dist/runtime/relay-worker-supervisor.js +109 -39
- package/dist/runtime/relay-worker-transport.js +157 -15
- package/dist/runtime/session-context-service.js +5 -45
- package/dist/runtime/session-service.js +157 -175
- package/dist/runtime/session-title-distiller-budget.js +1 -5
- package/dist/runtime/session-title-distiller-helpers.js +14 -24
- package/dist/runtime/session-title-distiller.js +109 -122
- package/dist/runtime/session-title-record.js +0 -6
- package/dist/runtime/stable-prompt-snapshot.js +3 -14
- package/dist/runtime/upstream-runtime.js +600 -103
- package/dist/tools/device-info-tool.js +4 -21
- package/dist/tools/glasses-ui-cron.js +58 -63
- package/dist/tools/glasses-ui-descriptors.js +4 -33
- package/dist/tools/glasses-ui-limits.js +0 -13
- package/dist/tools/glasses-ui-paint-floor.js +22 -34
- package/dist/tools/glasses-ui-recipes.js +92 -101
- package/dist/tools/glasses-ui-surfaces.js +295 -100
- package/dist/tools/glasses-ui-template.js +7 -22
- package/dist/tools/glasses-ui-tool-description.test.js +2 -2
- package/dist/tools/glasses-ui-tool.js +475 -331
- package/dist/tools/glasses-ui-voicemail.js +242 -0
- package/dist/tools/glasses-ui-wake.js +195 -0
- package/dist/tools/session-title-tool.js +2 -7
- package/dist/tools/session-title-tool.test.js +1 -1
- package/dist/version.js +3 -2
- package/openclaw.plugin.json +60 -13
- package/package.json +3 -2
- package/skills/glasses-ui/SKILL.md +19 -3
- package/dist/runtime/protocol-adapter.js +0 -387
|
@@ -1,36 +1,87 @@
|
|
|
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
|
|
|
7
|
+
export const GLASS_EVENT_ORIGINS = ["gesture", "schedule", "threshold", "system"];
|
|
8
|
+
|
|
9
|
+
export function normalizeGlassesSessionKey(key) {
|
|
10
|
+
return typeof key === "string" ? key.replace(/^agent:[^:]+:/, "") : key;
|
|
11
|
+
}
|
|
12
|
+
|
|
22
13
|
export function createSurfaceStore(deps = {}) {
|
|
14
|
+
|
|
15
|
+
const storeId =
|
|
16
|
+
typeof deps.storeId === "string" && deps.storeId
|
|
17
|
+
? deps.storeId
|
|
18
|
+
: `st-${Math.random().toString(36).slice(2, 8)}`;
|
|
19
|
+
const emitLifecycle =
|
|
20
|
+
typeof deps.emitLifecycle === "function" ? deps.emitLifecycle : () => {};
|
|
23
21
|
const pauseCron = typeof deps.pauseCron === "function" ? deps.pauseCron : () => {};
|
|
24
22
|
const resumeCron = typeof deps.resumeCron === "function" ? deps.resumeCron : () => {};
|
|
25
23
|
const stopCron = typeof deps.stopCron === "function" ? deps.stopCron : () => {};
|
|
24
|
+
const now = typeof deps.now === "function" ? deps.now : Date.now;
|
|
26
25
|
const mintSurfaceId =
|
|
27
26
|
typeof deps.mintSurfaceId === "function"
|
|
28
27
|
? deps.mintSurfaceId
|
|
29
28
|
: () => `ui-${Math.random().toString(36).slice(2, 10)}`;
|
|
30
|
-
|
|
31
|
-
|
|
29
|
+
const mintUuid =
|
|
30
|
+
typeof deps.mintUuid === "function"
|
|
31
|
+
? deps.mintUuid
|
|
32
|
+
: () => `su-${Math.random().toString(36).slice(2, 10)}${Math.random().toString(36).slice(2, 6)}`;
|
|
33
|
+
|
|
32
34
|
const bySurface = new Map();
|
|
33
|
-
const stackBySession = new Map();
|
|
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();
|
|
40
|
+
let eventSeq = 0;
|
|
41
|
+
|
|
42
|
+
function deadLetterFor(sessionKey) {
|
|
43
|
+
let list = deadLetterBySession.get(sessionKey);
|
|
44
|
+
if (!list) { list = []; deadLetterBySession.set(sessionKey, list); }
|
|
45
|
+
return list;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function deadLetterEntryEvents(sessionKey, surfaceId, entry, reason) {
|
|
49
|
+
|
|
50
|
+
if (!entry || entry.exitLatched || !entry.events || entry.events.length === 0) return;
|
|
51
|
+
const eventIds = entry.events.map((e) => e.eventId);
|
|
52
|
+
const list = deadLetterFor(sessionKey);
|
|
53
|
+
list.push({
|
|
54
|
+
surfaceUuid: entry.uuid,
|
|
55
|
+
surfaceId,
|
|
56
|
+
events: entry.events,
|
|
57
|
+
reason,
|
|
58
|
+
reapedAtMs: now(),
|
|
59
|
+
|
|
60
|
+
staleAfterMs: Number.isFinite(entry.staleAfterMs) ? entry.staleAfterMs : null,
|
|
61
|
+
});
|
|
62
|
+
entry.events = [];
|
|
63
|
+
|
|
64
|
+
emitLifecycle("dead_letter_appended", "debug", {
|
|
65
|
+
sessionKey,
|
|
66
|
+
surfaceId,
|
|
67
|
+
surfaceUuid: entry.uuid,
|
|
68
|
+
reason,
|
|
69
|
+
eventIds,
|
|
70
|
+
count: eventIds.length,
|
|
71
|
+
});
|
|
72
|
+
let total = list.reduce((n, r) => n + r.events.length, 0);
|
|
73
|
+
while (total > DEAD_LETTER_EVENT_CAP && list.length) {
|
|
74
|
+
const oldest = list[0];
|
|
75
|
+
const overflow = total - DEAD_LETTER_EVENT_CAP;
|
|
76
|
+
if (oldest.events.length <= overflow) {
|
|
77
|
+
total -= oldest.events.length;
|
|
78
|
+
list.shift();
|
|
79
|
+
} else {
|
|
80
|
+
oldest.events.splice(0, overflow);
|
|
81
|
+
total -= overflow;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
34
85
|
|
|
35
86
|
function stackFor(sessionKey) {
|
|
36
87
|
let s = stackBySession.get(sessionKey);
|
|
@@ -38,55 +89,73 @@ export function createSurfaceStore(deps = {}) {
|
|
|
38
89
|
return s;
|
|
39
90
|
}
|
|
40
91
|
|
|
41
|
-
// A fresh entry starts un-latched with an empty queue. A `prior` entry (an
|
|
42
|
-
// in-place content swap of the SAME surfaceId — i.e. `replace`) carries its
|
|
43
|
-
// latched exit / last-wins queued event forward so reattach semantics are
|
|
44
|
-
// MOVE-INDEPENDENT: a terminal latched (or a nonterminal queued) during
|
|
45
|
-
// `visible_awaiting_agent` is honored on the agent's NEXT render whether that
|
|
46
|
-
// render is patch, replace (the schema DEFAULT) or push — never silently
|
|
47
|
-
// dropped by the rebuild. (Without this carry-forward, a `replace` rebuild
|
|
48
|
-
// would reset exitLatched=false / queuedEvent=null and onReattached would
|
|
49
|
-
// return "reattached" instead of "discarded_for_exit", failing to tear down.)
|
|
50
92
|
function makeEntry(sessionKey, kind, prior) {
|
|
51
93
|
return {
|
|
52
94
|
sessionKey, kind: kind || null, pending: null, lastContent: null,
|
|
53
95
|
state: "visible_pending",
|
|
54
96
|
queuedEvent: prior ? prior.queuedEvent : null,
|
|
55
97
|
exitLatched: prior ? !!prior.exitLatched : false,
|
|
98
|
+
|
|
99
|
+
uuid: prior ? prior.uuid : mintUuid(),
|
|
100
|
+
events: prior ? prior.events : [],
|
|
101
|
+
queueMode: prior && prior.queueMode === "log" ? "log" : "latest",
|
|
102
|
+
|
|
103
|
+
staleAfterMs: null,
|
|
104
|
+
|
|
105
|
+
title: prior ? prior.title : null,
|
|
106
|
+
awaitingAgentResponse: false,
|
|
56
107
|
};
|
|
57
108
|
}
|
|
58
109
|
|
|
59
|
-
function register(
|
|
110
|
+
function register(rawSessionKey, surfaceId, meta) {
|
|
111
|
+
const sessionKey = normalizeGlassesSessionKey(rawSessionKey);
|
|
60
112
|
return new Promise((resolve) => {
|
|
61
113
|
const existing = bySurface.get(surfaceId);
|
|
62
114
|
if (existing) {
|
|
63
|
-
|
|
64
|
-
// keep the entry. No preempt of a different surface. Restore the
|
|
65
|
-
// pending state so a re-render returns to visible_pending before
|
|
66
|
-
// onReattached flushes any queued event / latched exit.
|
|
115
|
+
|
|
67
116
|
existing.pending = resolve;
|
|
68
117
|
if (meta && meta.kind) existing.kind = meta.kind;
|
|
118
|
+
if (meta && (meta.queueMode === "log" || meta.queueMode === "latest")) {
|
|
119
|
+
existing.queueMode = meta.queueMode;
|
|
120
|
+
}
|
|
121
|
+
existing.staleAfterMs = meta && Number.isFinite(meta.staleAfterMs) ? meta.staleAfterMs : null;
|
|
122
|
+
if (meta && typeof meta.title === "string") existing.title = meta.title;
|
|
123
|
+
existing.awaitingAgentResponse = false;
|
|
69
124
|
existing.sessionKey = sessionKey;
|
|
70
125
|
existing.state = "visible_pending";
|
|
71
126
|
return;
|
|
72
127
|
}
|
|
73
128
|
const entry = makeEntry(sessionKey, meta && meta.kind ? meta.kind : null);
|
|
129
|
+
if (meta && (meta.queueMode === "log" || meta.queueMode === "latest")) {
|
|
130
|
+
entry.queueMode = meta.queueMode;
|
|
131
|
+
}
|
|
132
|
+
entry.staleAfterMs = meta && Number.isFinite(meta.staleAfterMs) ? meta.staleAfterMs : null;
|
|
133
|
+
if (meta && typeof meta.title === "string") entry.title = meta.title;
|
|
74
134
|
entry.pending = resolve;
|
|
75
135
|
bySurface.set(surfaceId, entry);
|
|
76
136
|
});
|
|
77
137
|
}
|
|
78
138
|
|
|
139
|
+
function decorateDelivery(entry, outcome) {
|
|
140
|
+
if (!outcome || typeof outcome !== "object" || Array.isArray(outcome)) return outcome;
|
|
141
|
+
if (outcome.surfaceUuid !== undefined) return outcome;
|
|
142
|
+
return { ...outcome, surfaceUuid: entry.uuid };
|
|
143
|
+
}
|
|
144
|
+
|
|
79
145
|
function resolve(surfaceId, outcome) {
|
|
80
146
|
const entry = bySurface.get(surfaceId);
|
|
81
147
|
if (!entry || !entry.pending) return false;
|
|
82
148
|
const pending = entry.pending;
|
|
83
|
-
entry.pending = null;
|
|
149
|
+
entry.pending = null;
|
|
84
150
|
if (isTerminalOutcome(outcome)) {
|
|
85
151
|
entry.state = "exiting";
|
|
152
|
+
entry.awaitingAgentResponse = false;
|
|
86
153
|
} else {
|
|
87
154
|
entry.state = "visible_awaiting_agent";
|
|
155
|
+
|
|
156
|
+
entry.awaitingAgentResponse = !!(outcome && outcome.result !== "window_expired");
|
|
88
157
|
}
|
|
89
|
-
pending(outcome);
|
|
158
|
+
pending(decorateDelivery(entry, outcome));
|
|
90
159
|
return true;
|
|
91
160
|
}
|
|
92
161
|
|
|
@@ -99,18 +168,39 @@ export function createSurfaceStore(deps = {}) {
|
|
|
99
168
|
return !!(entry && entry.pending);
|
|
100
169
|
}
|
|
101
170
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
171
|
+
function decorateDrainOutcome(entry, outcome) {
|
|
172
|
+
if (!outcome || typeof outcome !== "object" || Array.isArray(outcome)) return outcome;
|
|
173
|
+
return decorateDelivery(entry, {
|
|
174
|
+
...outcome,
|
|
175
|
+
origin: typeof outcome.origin === "string" ? outcome.origin : "system",
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function drainSession(rawSessionKey, outcome) {
|
|
180
|
+
const sessionKey = normalizeGlassesSessionKey(rawSessionKey);
|
|
107
181
|
let n = 0;
|
|
108
182
|
for (const [surfaceId, entry] of [...bySurface]) {
|
|
109
183
|
if (entry.sessionKey !== sessionKey) continue;
|
|
110
184
|
const pending = entry.pending;
|
|
111
185
|
entry.pending = null;
|
|
186
|
+
deadLetterEntryEvents(sessionKey, surfaceId, entry, "drain_session");
|
|
112
187
|
bySurface.delete(surfaceId);
|
|
113
|
-
if (pending) { pending(outcome); n += 1; }
|
|
188
|
+
if (pending) { pending(decorateDrainOutcome(entry, outcome)); n += 1; }
|
|
189
|
+
}
|
|
190
|
+
return n;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function settlePending(rawSessionKey, outcome) {
|
|
194
|
+
const sessionKey = normalizeGlassesSessionKey(rawSessionKey);
|
|
195
|
+
let n = 0;
|
|
196
|
+
for (const [, entry] of bySurface) {
|
|
197
|
+
if (entry.sessionKey !== sessionKey || !entry.pending) continue;
|
|
198
|
+
const pending = entry.pending;
|
|
199
|
+
entry.pending = null;
|
|
200
|
+
|
|
201
|
+
entry.state = "visible_awaiting_agent";
|
|
202
|
+
pending(decorateDrainOutcome(entry, outcome));
|
|
203
|
+
n += 1;
|
|
114
204
|
}
|
|
115
205
|
return n;
|
|
116
206
|
}
|
|
@@ -120,8 +210,9 @@ export function createSurfaceStore(deps = {}) {
|
|
|
120
210
|
for (const [surfaceId, entry] of [...bySurface]) {
|
|
121
211
|
const pending = entry.pending;
|
|
122
212
|
entry.pending = null;
|
|
213
|
+
deadLetterEntryEvents(entry.sessionKey, surfaceId, entry, "drain_all");
|
|
123
214
|
bySurface.delete(surfaceId);
|
|
124
|
-
if (pending) { pending(outcome); n += 1; }
|
|
215
|
+
if (pending) { pending(decorateDrainOutcome(entry, outcome)); n += 1; }
|
|
125
216
|
}
|
|
126
217
|
return n;
|
|
127
218
|
}
|
|
@@ -131,16 +222,102 @@ export function createSurfaceStore(deps = {}) {
|
|
|
131
222
|
return entry ? entry.state : null;
|
|
132
223
|
}
|
|
133
224
|
|
|
134
|
-
function queueEvent(surfaceId, event) {
|
|
225
|
+
function queueEvent(surfaceId, event, opts) {
|
|
135
226
|
const entry = bySurface.get(surfaceId);
|
|
136
227
|
if (!entry) return false;
|
|
137
228
|
if (isTerminalOutcome(event)) {
|
|
138
|
-
entry.exitLatched = true;
|
|
229
|
+
entry.exitLatched = true;
|
|
139
230
|
entry.queuedEvent = event;
|
|
140
|
-
|
|
141
|
-
entry.queuedEvent = event; // last-wins among nonterminals
|
|
231
|
+
return { ok: true, eventId: ++eventSeq, surfaceUuid: entry.uuid, kind: "terminal_latch" };
|
|
142
232
|
}
|
|
143
|
-
|
|
233
|
+
if (entry.exitLatched) {
|
|
234
|
+
|
|
235
|
+
const latched = entry.queuedEvent;
|
|
236
|
+
const latchedOrigin = latched && typeof latched.origin === "string" ? latched.origin : "gesture";
|
|
237
|
+
if (latchedOrigin === "gesture") {
|
|
238
|
+
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
entry.exitLatched = false;
|
|
242
|
+
entry.queuedEvent = null;
|
|
243
|
+
|
|
244
|
+
}
|
|
245
|
+
const record = {
|
|
246
|
+
eventId: ++eventSeq,
|
|
247
|
+
surfaceUuid: entry.uuid,
|
|
248
|
+
origin: opts && typeof opts.origin === "string" ? opts.origin : "gesture",
|
|
249
|
+
actor: opts && typeof opts.actor === "string" ? opts.actor : "wearer",
|
|
250
|
+
queuedAtMs: now(),
|
|
251
|
+
deliveredVia: null,
|
|
252
|
+
outcome: event,
|
|
253
|
+
};
|
|
254
|
+
entry.events.push(record);
|
|
255
|
+
if (entry.events.length > SURFACE_EVENT_LOG_CAP) {
|
|
256
|
+
entry.events.splice(0, entry.events.length - SURFACE_EVENT_LOG_CAP);
|
|
257
|
+
}
|
|
258
|
+
entry.queuedEvent = event;
|
|
259
|
+
return { ok: true, eventId: record.eventId, surfaceUuid: entry.uuid };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function titleOf(surfaceId) {
|
|
263
|
+
const entry = bySurface.get(surfaceId);
|
|
264
|
+
return entry ? entry.title : null;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function markerFor(surfaceId) {
|
|
268
|
+
const entry = bySurface.get(surfaceId);
|
|
269
|
+
if (!entry) return null;
|
|
270
|
+
if (entry.pending) return "listening";
|
|
271
|
+
if ((entry.events && entry.events.length > 0) || entry.awaitingAgentResponse) return "inflight";
|
|
272
|
+
return "parked";
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function clearAwaitingResponse(rawSessionKey) {
|
|
276
|
+
const sessionKey = normalizeGlassesSessionKey(rawSessionKey);
|
|
277
|
+
for (const [, entry] of bySurface) {
|
|
278
|
+
if (entry.sessionKey === sessionKey) entry.awaitingAgentResponse = false;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function breadcrumbFor(rawSessionKey) {
|
|
283
|
+
const s = stackBySession.get(normalizeGlassesSessionKey(rawSessionKey));
|
|
284
|
+
if (!s || s.length === 0) return null;
|
|
285
|
+
const titles = s
|
|
286
|
+
.map((id) => { const e = bySurface.get(id); return e && typeof e.title === "string" ? e.title : null; })
|
|
287
|
+
.filter((t) => typeof t === "string" && t.length > 0);
|
|
288
|
+
return titles.length ? titles.join(" › ") : null;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function uuidOf(surfaceId) {
|
|
292
|
+
const entry = bySurface.get(surfaceId);
|
|
293
|
+
return entry ? entry.uuid : null;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function peekEvents(surfaceId) {
|
|
297
|
+
const entry = bySurface.get(surfaceId);
|
|
298
|
+
return entry ? [...entry.events] : [];
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function reduceForDelivery(surfaceId) {
|
|
302
|
+
const entry = bySurface.get(surfaceId);
|
|
303
|
+
if (!entry) return null;
|
|
304
|
+
if (entry.queueMode === "log") {
|
|
305
|
+
return { mode: "log", events: [...entry.events] };
|
|
306
|
+
}
|
|
307
|
+
const newest = entry.events.length ? entry.events[entry.events.length - 1] : null;
|
|
308
|
+
return { mode: "latest", outcome: newest ? newest.outcome : null };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function peekDeadLetter(sessionKey) {
|
|
312
|
+
const list = deadLetterBySession.get(normalizeGlassesSessionKey(sessionKey));
|
|
313
|
+
return list ? list.map((r) => ({ ...r, events: [...r.events] })) : [];
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function drainDeadLetter(rawSessionKey) {
|
|
317
|
+
const sessionKey = normalizeGlassesSessionKey(rawSessionKey);
|
|
318
|
+
const list = deadLetterBySession.get(sessionKey) || [];
|
|
319
|
+
deadLetterBySession.set(sessionKey, []);
|
|
320
|
+
return list;
|
|
144
321
|
}
|
|
145
322
|
|
|
146
323
|
function isExitLatched(surfaceId) {
|
|
@@ -151,40 +328,70 @@ export function createSurfaceStore(deps = {}) {
|
|
|
151
328
|
function onReattached(surfaceId) {
|
|
152
329
|
const entry = bySurface.get(surfaceId);
|
|
153
330
|
if (!entry) return "no_surface";
|
|
331
|
+
let staleLatchDropped = false;
|
|
154
332
|
if (entry.exitLatched) {
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
333
|
+
|
|
334
|
+
const latched = entry.queuedEvent;
|
|
335
|
+
const latchedOrigin =
|
|
336
|
+
latched && typeof latched.origin === "string" ? latched.origin : "gesture";
|
|
337
|
+
if (latchedOrigin !== "gesture") {
|
|
338
|
+
entry.exitLatched = false;
|
|
339
|
+
entry.queuedEvent = null;
|
|
340
|
+
staleLatchDropped = true;
|
|
341
|
+
} else {
|
|
342
|
+
|
|
343
|
+
entry.state = "exiting";
|
|
344
|
+
const terminal = entry.queuedEvent || { result: "dismissed" };
|
|
345
|
+
entry.queuedEvent = null;
|
|
346
|
+
entry.events = [];
|
|
347
|
+
if (entry.pending) {
|
|
348
|
+
const pending = entry.pending;
|
|
349
|
+
entry.pending = null;
|
|
350
|
+
pending(decorateDelivery(entry, terminal));
|
|
351
|
+
}
|
|
352
|
+
return "discarded_for_exit";
|
|
166
353
|
}
|
|
167
|
-
return "discarded_for_exit";
|
|
168
354
|
}
|
|
169
355
|
entry.state = "reattached";
|
|
170
|
-
|
|
356
|
+
|
|
357
|
+
const newest = entry.events.length ? entry.events[entry.events.length - 1] : null;
|
|
358
|
+
let delivered = null;
|
|
359
|
+
if (newest) {
|
|
360
|
+
const parkedForMs = Math.max(0, now() - newest.queuedAtMs);
|
|
361
|
+
delivered = {
|
|
362
|
+
...newest.outcome,
|
|
363
|
+
surfaceUuid: entry.uuid,
|
|
364
|
+
eventId: newest.eventId,
|
|
365
|
+
origin: newest.origin,
|
|
366
|
+
actor: newest.actor || "wearer",
|
|
367
|
+
queuedAtMs: newest.queuedAtMs,
|
|
368
|
+
parkedForMs,
|
|
369
|
+
};
|
|
370
|
+
if (Number.isFinite(entry.staleAfterMs) && parkedForMs > entry.staleAfterMs) {
|
|
371
|
+
delivered.stale = true;
|
|
372
|
+
}
|
|
373
|
+
} else if (entry.queuedEvent) {
|
|
374
|
+
delivered = decorateDelivery(entry, entry.queuedEvent);
|
|
375
|
+
}
|
|
171
376
|
entry.queuedEvent = null;
|
|
172
|
-
|
|
377
|
+
entry.events = [];
|
|
378
|
+
if (delivered && entry.pending) {
|
|
173
379
|
const pending = entry.pending;
|
|
174
380
|
entry.pending = null;
|
|
175
381
|
entry.state = "visible_awaiting_agent";
|
|
176
|
-
|
|
382
|
+
entry.awaitingAgentResponse = true;
|
|
383
|
+
pending(delivered);
|
|
177
384
|
}
|
|
178
|
-
return "reattached";
|
|
385
|
+
return staleLatchDropped ? "reattached_stale_latch_dropped" : "reattached";
|
|
179
386
|
}
|
|
180
387
|
|
|
181
388
|
function topSurfaceId(sessionKey) {
|
|
182
|
-
const s = stackBySession.get(sessionKey);
|
|
389
|
+
const s = stackBySession.get(normalizeGlassesSessionKey(sessionKey));
|
|
183
390
|
return s && s.length ? s[s.length - 1] : null;
|
|
184
391
|
}
|
|
185
392
|
|
|
186
393
|
function stackDepth(sessionKey) {
|
|
187
|
-
const s = stackBySession.get(sessionKey);
|
|
394
|
+
const s = stackBySession.get(normalizeGlassesSessionKey(sessionKey));
|
|
188
395
|
return s ? s.length : 0;
|
|
189
396
|
}
|
|
190
397
|
|
|
@@ -193,15 +400,11 @@ export function createSurfaceStore(deps = {}) {
|
|
|
193
400
|
return entry ? entry.sessionKey : null;
|
|
194
401
|
}
|
|
195
402
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
// keyed by session + stack). Returns { mode, surfaceId } so the caller can
|
|
199
|
-
// render against the bound id.
|
|
200
|
-
function applyRender(sessionKey, params) {
|
|
403
|
+
function applyRender(rawSessionKey, params) {
|
|
404
|
+
const sessionKey = normalizeGlassesSessionKey(rawSessionKey);
|
|
201
405
|
const stack = stackFor(sessionKey);
|
|
202
406
|
const top = stack[stack.length - 1] || null;
|
|
203
|
-
|
|
204
|
-
// regardless of the requested update (there is nothing to patch/push onto).
|
|
407
|
+
|
|
205
408
|
if (!top) {
|
|
206
409
|
const id = mintSurfaceId();
|
|
207
410
|
stack.push(id);
|
|
@@ -212,9 +415,7 @@ export function createSurfaceStore(deps = {}) {
|
|
|
212
415
|
: params && params.update === "push" ? "push"
|
|
213
416
|
: "replace";
|
|
214
417
|
if (update === "patch") {
|
|
215
|
-
|
|
216
|
-
// caller sends a field patch; the body content slot is updated by the
|
|
217
|
-
// render send, not here.
|
|
418
|
+
|
|
218
419
|
const entry = bySurface.get(top);
|
|
219
420
|
if (entry && params && params.kind) entry.kind = params.kind;
|
|
220
421
|
if (entry) entry.state = "visible_pending";
|
|
@@ -227,39 +428,36 @@ export function createSurfaceStore(deps = {}) {
|
|
|
227
428
|
bySurface.set(id, makeEntry(sessionKey, params && params.kind));
|
|
228
429
|
return { mode: "push", surfaceId: id };
|
|
229
430
|
}
|
|
230
|
-
|
|
231
|
-
// new back-target, depth unchanged). The entry is reset to a fresh
|
|
232
|
-
// pending state; the cron for this slot is reset for new content. CARRY
|
|
233
|
-
// FORWARD the prior entry's latched exit / queued event (same surfaceId)
|
|
234
|
-
// so a terminal/nonterminal recorded during visible_awaiting_agent is
|
|
235
|
-
// still honored by the subsequent onReattached — reattach semantics must
|
|
236
|
-
// be move-independent (replace, the schema DEFAULT, must behave like
|
|
237
|
-
// patch here, not silently drop a latched exit).
|
|
431
|
+
|
|
238
432
|
const priorTop = bySurface.get(top);
|
|
239
|
-
|
|
240
|
-
// a real outcome. A non-silent stop fires the cron's onResolve with a
|
|
241
|
-
// synthesized `preempted`, which (with no pending call at this instant)
|
|
242
|
-
// latches a bogus exit onto the prior entry — carried into makeEntry below,
|
|
243
|
-
// it makes the very render we are applying discard-for-exit on re-attach
|
|
244
|
-
// ("fresh render instantly dismissed", B7 — the real B3 contamination
|
|
245
|
-
// mechanism, found 2026-06-11).
|
|
433
|
+
|
|
246
434
|
stopCron(top, { silent: true });
|
|
247
435
|
bySurface.set(top, makeEntry(sessionKey, params && params.kind, priorTop));
|
|
248
436
|
return { mode: "replace", surfaceId: top };
|
|
249
437
|
}
|
|
250
438
|
|
|
251
|
-
function popBack(
|
|
439
|
+
function popBack(rawSessionKey) {
|
|
440
|
+
const sessionKey = normalizeGlassesSessionKey(rawSessionKey);
|
|
252
441
|
const stack = stackFor(sessionKey);
|
|
253
442
|
const child = stack.pop();
|
|
254
|
-
if (child) {
|
|
443
|
+
if (child) {
|
|
444
|
+
stopCron(child);
|
|
445
|
+
deadLetterEntryEvents(sessionKey, child, bySurface.get(child), "pop_back");
|
|
446
|
+
bySurface.delete(child);
|
|
447
|
+
}
|
|
255
448
|
const parent = stack[stack.length - 1] || null;
|
|
256
449
|
if (parent) resumeCron(parent);
|
|
257
450
|
return parent;
|
|
258
451
|
}
|
|
259
452
|
|
|
260
|
-
function exit(
|
|
453
|
+
function exit(rawSessionKey) {
|
|
454
|
+
const sessionKey = normalizeGlassesSessionKey(rawSessionKey);
|
|
261
455
|
const stack = stackFor(sessionKey);
|
|
262
|
-
for (const id of stack) {
|
|
456
|
+
for (const id of stack) {
|
|
457
|
+
stopCron(id);
|
|
458
|
+
deadLetterEntryEvents(sessionKey, id, bySurface.get(id), "exit");
|
|
459
|
+
bySurface.delete(id);
|
|
460
|
+
}
|
|
263
461
|
stackBySession.set(sessionKey, []);
|
|
264
462
|
return true;
|
|
265
463
|
}
|
|
@@ -269,17 +467,14 @@ export function createSurfaceStore(deps = {}) {
|
|
|
269
467
|
}
|
|
270
468
|
|
|
271
469
|
return {
|
|
272
|
-
|
|
470
|
+
storeId,
|
|
471
|
+
register, resolve, hasSurface, isPending, drainSession, drainAll, settlePending,
|
|
273
472
|
stateOf, queueEvent, isExitLatched, onReattached,
|
|
274
473
|
applyRender, popBack, exit, topSurfaceId, stackDepth, sessionKeys, sessionForSurface,
|
|
474
|
+
uuidOf, titleOf, markerFor, clearAwaitingResponse, breadcrumbFor,
|
|
475
|
+
peekEvents, reduceForDelivery, peekDeadLetter, drainDeadLetter,
|
|
275
476
|
_bySurface: bySurface,
|
|
276
477
|
};
|
|
277
478
|
}
|
|
278
479
|
|
|
279
|
-
// Backwards-compatible alias. Phase 1 extracted createPendingRenderMap; Phase 2
|
|
280
|
-
// evolved it into createSurfaceStore (single store — see the SINGLE-STORE
|
|
281
|
-
// EVOLUTION note in the plan). The old name survives only as an alias so the
|
|
282
|
-
// Phase 1 import path (glasses-ui-tool re-export) keeps resolving. There is
|
|
283
|
-
// never a separate createPendingRenderMap *instance* — both names construct the
|
|
284
|
-
// one evolved store.
|
|
285
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
|
-
|
|
16
|
+
|
|
24
17
|
const STRING_ARG_FILTERS = new Set(["default", "prefix"]);
|
|
25
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
14
|
+
|
|
15
15
|
assert.match(d, /glasses-ui/);
|
|
16
16
|
});
|