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.
Files changed (84) hide show
  1. package/README.md +29 -1
  2. package/dist/config/runtime-config-session-title-model.test.js +0 -3
  3. package/dist/config/runtime-config.js +22 -33
  4. package/dist/domain/activity-status-adapter.js +0 -7
  5. package/dist/domain/activity-status-arbiter.js +3 -27
  6. package/dist/domain/activity-status-labels.js +8 -38
  7. package/dist/domain/code-span-regions.js +4 -24
  8. package/dist/domain/constant-time-equal.js +9 -0
  9. package/dist/domain/constant-time-equal.test.js +28 -0
  10. package/dist/domain/conversation-state.js +27 -138
  11. package/dist/domain/debug-bundle-cache.js +52 -0
  12. package/dist/domain/debug-bundle-format.js +60 -0
  13. package/dist/domain/debug-bundle-preview.js +123 -0
  14. package/dist/domain/debug-bundle-redaction.js +182 -0
  15. package/dist/domain/debug-bundle-save.js +11 -0
  16. package/dist/domain/debug-bundle-zip.js +15 -0
  17. package/dist/domain/debug-bundle.js +97 -0
  18. package/dist/domain/debug-store.js +6 -17
  19. package/dist/domain/debug-upload-preset.js +27 -0
  20. package/dist/domain/glasses-display-system-prompt.js +0 -5
  21. package/dist/domain/glasses-display-system-prompt.test.js +1 -1
  22. package/dist/domain/glasses-ui-content-summary.js +0 -6
  23. package/dist/domain/glasses-ui-system-prompt.test.js +1 -2
  24. package/dist/domain/message-emoji-allowlist.js +0 -7
  25. package/dist/domain/message-emoji-filter.js +3 -9
  26. package/dist/domain/neural-emoji-reactor-tag-config.js +3 -3
  27. package/dist/domain/prompt-channel-fragments.js +1 -10
  28. package/dist/domain/tagged-span-parser.js +3 -26
  29. package/dist/domain/tagged-span-strip.js +0 -7
  30. package/dist/even-ai/even-ai-endpoint.js +77 -24
  31. package/dist/even-ai/even-ai-run-waiter.js +0 -1
  32. package/dist/even-ai/even-ai-settings-store.js +11 -0
  33. package/dist/gateway/gateway-bridge.js +8 -9
  34. package/dist/gateway/gateway-timing-ledger.js +8 -6
  35. package/dist/gateway/openclaw-client.js +97 -297
  36. package/dist/gateway/sanitize-connect-reason.js +10 -0
  37. package/dist/gateway/sanitize-connect-reason.test.js +34 -0
  38. package/dist/index.js +3 -3
  39. package/dist/runtime/channel-two-hook.js +1 -6
  40. package/dist/runtime/container-env.js +1 -5
  41. package/dist/runtime/debug-bundle-handler.js +159 -0
  42. package/dist/runtime/display-toggle-states.js +6 -17
  43. package/dist/runtime/downstream-handler.js +682 -508
  44. package/dist/runtime/glasses-backpressure-latch.js +93 -0
  45. package/dist/runtime/ocuclaw-settings-store.js +10 -1
  46. package/dist/runtime/openclaw-host-version.js +5 -0
  47. package/dist/runtime/plugin-version-service.js +13 -6
  48. package/dist/runtime/provider-usage-select.js +0 -6
  49. package/dist/runtime/register-session-title-distiller.js +14 -16
  50. package/dist/runtime/relay-core.js +657 -271
  51. package/dist/runtime/relay-service.js +40 -36
  52. package/dist/runtime/relay-worker-approval-replay-cache.js +1 -1
  53. package/dist/runtime/relay-worker-entry.js +1 -2
  54. package/dist/runtime/relay-worker-health.js +2 -10
  55. package/dist/runtime/relay-worker-protocol.js +6 -1
  56. package/dist/runtime/relay-worker-supervisor.js +109 -39
  57. package/dist/runtime/relay-worker-transport.js +157 -15
  58. package/dist/runtime/session-context-service.js +5 -45
  59. package/dist/runtime/session-service.js +157 -175
  60. package/dist/runtime/session-title-distiller-budget.js +1 -5
  61. package/dist/runtime/session-title-distiller-helpers.js +14 -24
  62. package/dist/runtime/session-title-distiller.js +109 -122
  63. package/dist/runtime/session-title-record.js +0 -6
  64. package/dist/runtime/stable-prompt-snapshot.js +3 -14
  65. package/dist/runtime/upstream-runtime.js +600 -103
  66. package/dist/tools/device-info-tool.js +4 -21
  67. package/dist/tools/glasses-ui-cron.js +58 -63
  68. package/dist/tools/glasses-ui-descriptors.js +4 -33
  69. package/dist/tools/glasses-ui-limits.js +0 -13
  70. package/dist/tools/glasses-ui-paint-floor.js +22 -34
  71. package/dist/tools/glasses-ui-recipes.js +92 -101
  72. package/dist/tools/glasses-ui-surfaces.js +295 -100
  73. package/dist/tools/glasses-ui-template.js +7 -22
  74. package/dist/tools/glasses-ui-tool-description.test.js +2 -2
  75. package/dist/tools/glasses-ui-tool.js +475 -331
  76. package/dist/tools/glasses-ui-voicemail.js +242 -0
  77. package/dist/tools/glasses-ui-wake.js +195 -0
  78. package/dist/tools/session-title-tool.js +2 -7
  79. package/dist/tools/session-title-tool.test.js +1 -1
  80. package/dist/version.js +3 -2
  81. package/openclaw.plugin.json +60 -13
  82. package/package.json +3 -2
  83. package/skills/glasses-ui/SKILL.md +19 -3
  84. package/dist/runtime/protocol-adapter.js +0 -387
@@ -0,0 +1,242 @@
1
+ import { sanitizeWakeToken } from "./glasses-ui-wake.js";
2
+ import { normalizeGlassesSessionKey } from "./glasses-ui-surfaces.js";
3
+
4
+ export const DEFAULT_VOICEMAIL_TTL_MS = 30 * 60_000;
5
+ export const VOICEMAIL_MAX_ENTRIES_PER_INJECTION = 8;
6
+
7
+ export const VOICEMAIL_PENDING_CAP_PER_SESSION = 32;
8
+ const DELIVERED_KEY_CAP = 256;
9
+
10
+ const RESULT_ENUM = new Set(["selected", "back"]);
11
+
12
+ const REAP_REASON_ENUM = new Set(["drain_session", "drain_all", "exit", "pop_back"]);
13
+
14
+ function coerceInt(value) {
15
+ return Number.isFinite(value) ? Math.floor(value) : null;
16
+ }
17
+
18
+ function sanitizeResult(value) {
19
+ return RESULT_ENUM.has(value) ? value : "event";
20
+ }
21
+
22
+ const IDEMPOTENCY_KEY_PATTERN = /^[a-z0-9:._-]{1,80}$/i;
23
+ function sanitizeIdempotencyKey(value) {
24
+ const raw = String(value == null ? "" : value);
25
+ return IDEMPOTENCY_KEY_PATTERN.test(raw) ? raw : "invalid";
26
+ }
27
+
28
+ export function createGlassesVoicemail(deps = {}) {
29
+ const now = typeof deps.now === "function" ? deps.now : Date.now;
30
+ const ttlMs = Number.isFinite(deps.ttlMs) ? deps.ttlMs : DEFAULT_VOICEMAIL_TTL_MS;
31
+ const maxEntries = Number.isFinite(deps.maxEntriesPerInjection)
32
+ ? deps.maxEntriesPerInjection
33
+ : VOICEMAIL_MAX_ENTRIES_PER_INJECTION;
34
+ const drainWakeOutbox =
35
+ typeof deps.drainWakeOutbox === "function" ? deps.drainWakeOutbox : () => [];
36
+ const drainDeadLetter =
37
+ typeof deps.drainDeadLetter === "function" ? deps.drainDeadLetter : () => [];
38
+ const emitLifecycle =
39
+ typeof deps.emitLifecycle === "function" ? deps.emitLifecycle : () => {};
40
+
41
+ const pendingBySession = new Map();
42
+
43
+ const deliveredKeys = new Set();
44
+
45
+ function rememberDelivered(key) {
46
+ deliveredKeys.add(key);
47
+ if (deliveredKeys.size > DELIVERED_KEY_CAP) {
48
+ const oldest = deliveredKeys.values().next().value;
49
+ deliveredKeys.delete(oldest);
50
+ }
51
+ }
52
+
53
+ function dedupeKeyOf(entry) {
54
+ return `${entry.surfaceUuid}:${entry.eventId === null ? 0 : entry.eventId}`;
55
+ }
56
+
57
+ function ingest(entries, nowMs) {
58
+ const bySession = new Map();
59
+ for (const entry of entries) {
60
+ if (!entry.sessionKey) continue;
61
+ let batch = bySession.get(entry.sessionKey);
62
+ if (!batch) { batch = []; bySession.set(entry.sessionKey, batch); }
63
+ batch.push(entry);
64
+ }
65
+ for (const [sessionKey, batch] of bySession) {
66
+ let expired = 0;
67
+ const fresh = [];
68
+ for (const entry of batch) {
69
+ const basisMs = Number.isFinite(entry.owedSinceMs) ? entry.owedSinceMs : nowMs;
70
+ if (nowMs - basisMs > ttlMs) { expired += 1; continue; }
71
+ fresh.push(entry);
72
+ }
73
+ if (expired > 0) {
74
+ emitLifecycle("voicemail_expired", "warn", { sessionKey, dropped: expired, ttlMs });
75
+ }
76
+ if (fresh.length === 0) continue;
77
+ const list = pendingBySession.get(sessionKey) || [];
78
+ list.push(...fresh);
79
+ if (list.length > VOICEMAIL_PENDING_CAP_PER_SESSION) {
80
+ const evicted = list.splice(0, list.length - VOICEMAIL_PENDING_CAP_PER_SESSION);
81
+ emitLifecycle("voicemail_evicted", "warn", { sessionKey, evicted: evicted.length });
82
+ }
83
+ pendingBySession.set(sessionKey, list);
84
+ }
85
+ }
86
+
87
+ function fromOutbox(record) {
88
+ const surfaceUuid = sanitizeWakeToken(record && record.surfaceUuid);
89
+ const eventId = coerceInt(record && record.eventId);
90
+ return {
91
+ sessionKey: normalizeGlassesSessionKey(record && record.sessionKey) || null,
92
+ surfaceUuid,
93
+ eventId,
94
+ result: sanitizeResult(record && record.result),
95
+ itemIndex: coerceInt(record && record.itemIndex),
96
+ queuedAtMs: coerceInt(record && record.queuedAtMs),
97
+ owedSinceMs: coerceInt(record && record.failedAtMs) ?? coerceInt(record && record.queuedAtMs),
98
+ idempotencyKey: sanitizeIdempotencyKey(record && record.idempotencyKey),
99
+ via: record && record.error === "no_dispatch_lane" ? "wake_unavailable" : "wake_failed",
100
+ staleAfterMs: null,
101
+ surfaceLive: true,
102
+ };
103
+ }
104
+
105
+ function fromDeadLetter(sessionKey, record) {
106
+ const surfaceUuid = sanitizeWakeToken(record && record.surfaceUuid);
107
+ const reason =
108
+ record && REAP_REASON_ENUM.has(record.reason) ? `reaped:${record.reason}` : "reaped";
109
+ const staleAfterMs = record && Number.isFinite(record.staleAfterMs) ? record.staleAfterMs : null;
110
+ const events = record && Array.isArray(record.events) ? record.events : [];
111
+ return events.map((ev) => {
112
+ const eventId = coerceInt(ev && ev.eventId);
113
+ return {
114
+ sessionKey,
115
+ surfaceUuid,
116
+ eventId,
117
+ result: sanitizeResult(ev && ev.outcome && ev.outcome.result),
118
+ itemIndex: coerceInt(ev && ev.outcome && ev.outcome.selected_index),
119
+ queuedAtMs: coerceInt(ev && ev.queuedAtMs),
120
+ owedSinceMs: coerceInt(ev && ev.queuedAtMs) ?? coerceInt(record && record.reapedAtMs),
121
+ idempotencyKey: `glasses-voicemail:${surfaceUuid}:${eventId === null ? 0 : eventId}`,
122
+ via: reason,
123
+ staleAfterMs,
124
+ surfaceLive: false,
125
+ };
126
+ });
127
+ }
128
+
129
+ function formatEntry(entry, nowMs) {
130
+ const ageMs = Number.isFinite(entry.queuedAtMs) ? Math.max(0, nowMs - entry.queuedAtMs) : null;
131
+ const stale =
132
+ Number.isFinite(entry.staleAfterMs) && ageMs !== null && ageMs > entry.staleAfterMs;
133
+ const parts = [
134
+ `- surfaceUuid=${entry.surfaceUuid}`,
135
+ `eventId=${entry.eventId}`,
136
+ `result=${entry.result}`,
137
+ `itemIndex=${entry.itemIndex}`,
138
+ `queuedAtMs=${entry.queuedAtMs}`,
139
+ `ageMs=${ageMs}`,
140
+ `via=${entry.via}`,
141
+ `idempotencyKey=${entry.idempotencyKey}`,
142
+ ];
143
+ if (stale) parts.push("stale=true");
144
+ parts.push(
145
+ entry.surfaceLive
146
+ ? '(surface may still be live: re-render it with update:"patch" to collect)'
147
+ : "(surface no longer live: treat the refs as the wearer's parked answer to that surface; re-confirm before acting if stale)",
148
+ );
149
+ return parts.join(" ");
150
+ }
151
+
152
+ function sweepExpired(nowMs) {
153
+ for (const [key, list] of pendingBySession) {
154
+ const fresh = list.filter((entry) => {
155
+ const basisMs = Number.isFinite(entry.owedSinceMs) ? entry.owedSinceMs : nowMs;
156
+ return nowMs - basisMs <= ttlMs;
157
+ });
158
+ const dropped = list.length - fresh.length;
159
+ if (dropped > 0) {
160
+ emitLifecycle("voicemail_expired", "warn", { sessionKey: key, dropped, ttlMs });
161
+ }
162
+ if (fresh.length === 0) {
163
+ pendingBySession.delete(key);
164
+ } else if (dropped > 0) {
165
+ pendingBySession.set(key, fresh);
166
+ }
167
+ }
168
+ }
169
+
170
+ function pendingSessionCount() {
171
+ return pendingBySession.size;
172
+ }
173
+
174
+ function buildInjection(rawSessionKey) {
175
+ const sessionKey = normalizeGlassesSessionKey(rawSessionKey);
176
+ if (typeof sessionKey !== "string" || !sessionKey) return null;
177
+ const nowMs = now();
178
+ sweepExpired(nowMs);
179
+
180
+ ingest(
181
+ [
182
+ ...drainWakeOutbox().map((record) => fromOutbox(record)),
183
+ ...(drainDeadLetter(sessionKey) || []).flatMap((r) => fromDeadLetter(sessionKey, r)),
184
+ ],
185
+ nowMs,
186
+ );
187
+
188
+ const pending = pendingBySession.get(sessionKey);
189
+ if (!pending || pending.length === 0) {
190
+ pendingBySession.delete(sessionKey);
191
+ return null;
192
+ }
193
+ pendingBySession.delete(sessionKey);
194
+
195
+ const byEvent = new Map();
196
+ for (const entry of pending) {
197
+ const key = dedupeKeyOf(entry);
198
+ const existing = byEvent.get(key);
199
+ if (!existing || (existing.surfaceLive && !entry.surfaceLive)) {
200
+ byEvent.set(key, entry);
201
+ }
202
+ }
203
+
204
+ const deliverable = [];
205
+ let dropped = 0;
206
+ for (const entry of byEvent.values()) {
207
+ const basisMs = Number.isFinite(entry.owedSinceMs) ? entry.owedSinceMs : nowMs;
208
+ if (nowMs - basisMs > ttlMs) { dropped += 1; continue; }
209
+ const key = dedupeKeyOf(entry);
210
+ if (deliveredKeys.has(key)) continue;
211
+ rememberDelivered(key);
212
+ deliverable.push(entry);
213
+ }
214
+ if (dropped > 0) {
215
+
216
+ emitLifecycle("voicemail_expired", "warn", { sessionKey, dropped, ttlMs });
217
+ }
218
+ if (deliverable.length === 0) return null;
219
+
220
+ const shown = deliverable.slice(-maxEntries);
221
+ const overflow = deliverable.length - shown.length;
222
+ const lines = [
223
+ "[ocuclaw glasses-ui voicemail] Plugin-generated notification - NOT the wearer speaking.",
224
+ "Parked glasses events could not be delivered by a wake turn while you were away:",
225
+ ...shown.map((entry) => formatEntry(entry, nowMs)),
226
+ ];
227
+ if (overflow > 0) lines.push(`(+${overflow} older parked events omitted)`);
228
+ lines.push("Tapped content is never included here by design.");
229
+ const fragment = lines.join("\n");
230
+ emitLifecycle("voicemail_injected", "debug", {
231
+ sessionKey,
232
+ entries: shown.length,
233
+ overflow,
234
+ chars: fragment.length,
235
+ });
236
+ return fragment;
237
+ }
238
+
239
+ return { buildInjection, pendingSessionCount };
240
+ }
241
+
242
+ export default { createGlassesVoicemail, DEFAULT_VOICEMAIL_TTL_MS, VOICEMAIL_MAX_ENTRIES_PER_INJECTION };
@@ -0,0 +1,195 @@
1
+ export const GLASSES_WAKE_ENABLED_ORIGINS = ["gesture"];
2
+
3
+ export const DEFAULT_WAKE_COOLDOWN_MS = 5_000;
4
+
5
+ export const WAKE_OUTBOX_CAP = 64;
6
+
7
+ export const DEFAULT_AGENT_TURN_BUSY_DECAY_MS = 180_000;
8
+
9
+ const SURFACE_UUID_PATTERN = /^su-[a-z0-9]{4,24}$/i;
10
+ const WAKE_RESULT_ENUM = new Set(["selected", "back"]);
11
+
12
+ export function sanitizeWakeToken(value) {
13
+ const raw = String(value == null ? "" : value);
14
+ return SURFACE_UUID_PATTERN.test(raw) ? raw : "invalid";
15
+ }
16
+
17
+ function sanitizeWakeResult(value) {
18
+ return WAKE_RESULT_ENUM.has(value) ? value : "event";
19
+ }
20
+
21
+ function coerceInt(value) {
22
+ return Number.isFinite(value) ? Math.floor(value) : null;
23
+ }
24
+
25
+ export function buildWakeMessage(ref) {
26
+ const surfaceUuid = sanitizeWakeToken(ref && ref.surfaceUuid);
27
+ const result = sanitizeWakeResult(ref && ref.result);
28
+ const eventId = coerceInt(ref && ref.eventId);
29
+ const itemIndex = coerceInt(ref && ref.itemIndex);
30
+ const queuedAtMs = coerceInt(ref && ref.queuedAtMs);
31
+ return [
32
+ "[ocuclaw glasses-ui wake] Plugin-generated notification - NOT the wearer speaking.",
33
+ `The wearer tapped a parked glasses surface (origin=gesture). refs: surfaceUuid=${surfaceUuid}`,
34
+ `eventId=${eventId} result=${result} itemIndex=${itemIndex} queuedAtMs=${queuedAtMs}.`,
35
+ "Tapped content is not included here by design: re-render that surface",
36
+ "(update:\"patch\") to collect the parked event(s), then respond as appropriate.",
37
+ ].join(" ");
38
+ }
39
+
40
+ export function createAgentTurnTracker(deps = {}) {
41
+ const now = typeof deps.now === "function" ? deps.now : Date.now;
42
+ const busyDecayMs = Number.isFinite(deps.busyDecayMs)
43
+ ? deps.busyDecayMs
44
+ : DEFAULT_AGENT_TURN_BUSY_DECAY_MS;
45
+ const lastSeenBySession = new Map();
46
+
47
+ function normalizeKey(sessionKey) {
48
+ return sessionKey.replace(/^agent:[^:]+:/, "");
49
+ }
50
+
51
+ function markBusy(sessionKey) {
52
+ if (typeof sessionKey !== "string" || !sessionKey) return;
53
+ lastSeenBySession.set(normalizeKey(sessionKey), now());
54
+ }
55
+
56
+ function onActivity(sessionKey, phase) {
57
+ if (typeof sessionKey !== "string" || !sessionKey) return;
58
+ if (phase === "end") {
59
+ lastSeenBySession.delete(normalizeKey(sessionKey));
60
+ return;
61
+ }
62
+ lastSeenBySession.set(normalizeKey(sessionKey), now());
63
+ }
64
+
65
+ function isBusy(sessionKey) {
66
+ if (typeof sessionKey !== "string" || !sessionKey) return false;
67
+ const key = normalizeKey(sessionKey);
68
+ const lastSeen = lastSeenBySession.get(key);
69
+ if (!Number.isFinite(lastSeen)) return false;
70
+ if (now() - lastSeen >= busyDecayMs) {
71
+ lastSeenBySession.delete(key);
72
+ return false;
73
+ }
74
+ return true;
75
+ }
76
+
77
+ return { markBusy, onActivity, isBusy };
78
+ }
79
+
80
+ export function createGlassesWakeController(deps = {}) {
81
+ const dispatchWake = typeof deps.dispatchWake === "function" ? deps.dispatchWake : null;
82
+ const isAgentTurnBusy =
83
+ typeof deps.isAgentTurnBusy === "function" ? deps.isAgentTurnBusy : () => false;
84
+ const emitLifecycle =
85
+ typeof deps.emitLifecycle === "function" ? deps.emitLifecycle : () => {};
86
+ const now = typeof deps.now === "function" ? deps.now : Date.now;
87
+ const wakeCooldownMs = Number.isFinite(deps.wakeCooldownMs)
88
+ ? deps.wakeCooldownMs
89
+ : DEFAULT_WAKE_COOLDOWN_MS;
90
+
91
+ const inFlightBySession = new Map();
92
+ const lastWakeAtBySession = new Map();
93
+ const outbox = [];
94
+
95
+ function pushOutbox(entry) {
96
+ outbox.push(entry);
97
+ if (outbox.length > WAKE_OUTBOX_CAP) {
98
+ const evicted = outbox.splice(0, outbox.length - WAKE_OUTBOX_CAP);
99
+ emitLifecycle("wake_outbox_evicted", "warn", { evicted: evicted.length });
100
+ }
101
+ }
102
+
103
+ function refsOnly(ref) {
104
+ return {
105
+ sessionKey: typeof ref.sessionKey === "string" ? ref.sessionKey : null,
106
+ surfaceUuid: sanitizeWakeToken(ref.surfaceUuid),
107
+ eventId: coerceInt(ref.eventId),
108
+ result: sanitizeWakeResult(ref.result),
109
+ itemIndex: coerceInt(ref.itemIndex),
110
+
111
+ origin: typeof ref.origin === "string" ? ref.origin : "gesture",
112
+ queuedAtMs: coerceInt(ref.queuedAtMs),
113
+ };
114
+ }
115
+
116
+ function suppress(reason, refs) {
117
+ emitLifecycle("wake_suppressed", "debug", { reason, ...refs });
118
+ return { dispatched: false, reason };
119
+ }
120
+
121
+ function onParkedGesture(ref) {
122
+ const refs = refsOnly(ref || {});
123
+ if (!dispatchWake) {
124
+
125
+ if (GLASSES_WAKE_ENABLED_ORIGINS.includes(refs.origin) && refs.sessionKey) {
126
+ if (refs.queuedAtMs === null) refs.queuedAtMs = now();
127
+ const idempotencyKey = `glasses-wake:${refs.surfaceUuid}:${refs.eventId === null ? 0 : refs.eventId}`;
128
+ pushOutbox({
129
+ ...refs,
130
+ idempotencyKey,
131
+ failedAtMs: now(),
132
+ error: "no_dispatch_lane",
133
+ });
134
+ emitLifecycle("wake_unavailable_outboxed", "debug", { ...refs, idempotencyKey });
135
+ }
136
+ return { dispatched: false, reason: "no_dispatch_lane" };
137
+ }
138
+ if (!GLASSES_WAKE_ENABLED_ORIGINS.includes(refs.origin)) {
139
+ return suppress("origin_disabled", refs);
140
+ }
141
+ const sessionKey = refs.sessionKey;
142
+ if (!sessionKey) return suppress("no_session", refs);
143
+ if (isAgentTurnBusy(sessionKey)) {
144
+
145
+ return suppress("absorbed_by_active_turn", refs);
146
+ }
147
+ if (inFlightBySession.has(sessionKey)) {
148
+ emitLifecycle("wake_coalesced", "debug", refs);
149
+ return { dispatched: false, reason: "coalesced_into_inflight_wake" };
150
+ }
151
+ const lastWakeAt = lastWakeAtBySession.get(sessionKey);
152
+ if (Number.isFinite(lastWakeAt) && now() - lastWakeAt < wakeCooldownMs) {
153
+ return suppress("cooldown", refs);
154
+ }
155
+
156
+ if (refs.queuedAtMs === null) refs.queuedAtMs = now();
157
+ const message = buildWakeMessage(refs);
158
+ const idempotencyKey = `glasses-wake:${refs.surfaceUuid}:${refs.eventId === null ? 0 : refs.eventId}`;
159
+ const payload = { sessionKey, message, idempotencyKey };
160
+ lastWakeAtBySession.set(sessionKey, now());
161
+ const attempt = () => Promise.resolve(dispatchWake(payload));
162
+ const flight = attempt()
163
+ .catch(() => attempt())
164
+ .then(() => {
165
+ emitLifecycle("wake_dispatched", "debug", { ...refs, idempotencyKey });
166
+ })
167
+ .catch((err) => {
168
+
169
+ pushOutbox({
170
+ ...refs,
171
+ idempotencyKey,
172
+ failedAtMs: now(),
173
+ error: String((err && err.message) || err),
174
+ });
175
+ emitLifecycle("wake_dispatch_failed", "warn", { ...refs, idempotencyKey });
176
+ })
177
+ .finally(() => {
178
+ inFlightBySession.delete(sessionKey);
179
+ });
180
+ inFlightBySession.set(sessionKey, flight);
181
+ return { dispatched: true, idempotencyKey };
182
+ }
183
+
184
+ function peekWakeOutbox() {
185
+ return outbox.map((r) => ({ ...r }));
186
+ }
187
+
188
+ function drainWakeOutbox() {
189
+ return outbox.splice(0, outbox.length);
190
+ }
191
+
192
+ return { onParkedGesture, peekWakeOutbox, drainWakeOutbox };
193
+ }
194
+
195
+ export default { createGlassesWakeController, createAgentTurnTracker, buildWakeMessage, sanitizeWakeToken, GLASSES_WAKE_ENABLED_ORIGINS };
@@ -1,5 +1,5 @@
1
1
  export const SESSION_TITLE_LIMITS = {
2
- titleMax: 55, // 64 SDK list-item cap minus ~9 chars headroom for "<time> - " prefix
2
+ titleMax: 55,
3
3
  };
4
4
 
5
5
  export const sessionTitleParametersSchema = {
@@ -51,12 +51,7 @@ function isEvenAiDedicatedKey(sessionKey) {
51
51
  }
52
52
 
53
53
  function gateReason(sessionKey, deps) {
54
- // Explicit-rename-only: the Neural Session Names toggle governs AUTOMATIC
55
- // titling (the distiller), not user-requested renames, so feature_disabled is
56
- // gone. session_user_locked is gone too — a user who already named a session
57
- // must be able to rename it again (the lock only blocks the distiller).
58
- // The structural no_active_session / EvenAI-renamable guards live in the
59
- // handler body. Only the no-user-message guard remains here.
54
+
60
55
  if (
61
56
  typeof deps.hasRecordedUserMessage === "function" &&
62
57
  !deps.hasRecordedUserMessage(sessionKey)
@@ -26,7 +26,7 @@ test("explicit rename passes origin user_tool", async () => {
26
26
  test("a user-locked session can STILL be renamed via the tool", async () => {
27
27
  const d = deps({
28
28
  setSessionTitle: (k, t, o) => {
29
- // service-layer would allow user_tool over a lock; tool must not pre-block
29
+
30
30
  return { ok: true };
31
31
  },
32
32
  });
package/dist/version.js CHANGED
@@ -1,2 +1,3 @@
1
- export const PLUGIN_VERSION = "1.3.2";
2
- export const REQUIRES_CLIENT_VERSION = "1.3.2";
1
+ export const PLUGIN_VERSION = "1.3.4";
2
+ export const REQUIRES_CLIENT_VERSION = "1.3.4";
3
+ export const BUILD_INPUT_HASH = "sha256:2e70aa3160e383e95d494d26249e07351ccc4d5372ff13e40c532a5c408e21d0";
@@ -28,6 +28,11 @@
28
28
  "help": "Optional. Enables Soniox speech-to-text for voice input.",
29
29
  "sensitive": true
30
30
  },
31
+ "cartesiaApiKey": {
32
+ "label": "Cartesia API key",
33
+ "help": "Optional. Enables Cartesia Ink-2 speech-to-text for voice input.",
34
+ "sensitive": true
35
+ },
31
36
  "evenAiEnabled": {
32
37
  "label": "Enable Even AI",
33
38
  "help": "Routes Even AI agent requests through OcuClaw. Requires evenAiToken when enabled."
@@ -45,7 +50,7 @@
45
50
  },
46
51
  "sessionTitleModel": {
47
52
  "label": "Session-title model",
48
- "help": "Optional model override (\"provider/model\") for the background session-title distiller. Leave blank to use your normal model.",
53
+ "help": "Optional model override (\"provider/model\") for the background session-title distiller. This is a lightweight background task, so a small, fast, inexpensive model is a good choice (e.g. anthropic/claude-haiku-4-5). Leave blank to use your normal model.",
49
54
  "advanced": true
50
55
  },
51
56
  "evenAiRoutingMode": {
@@ -64,13 +69,8 @@
64
69
  "advanced": true
65
70
  },
66
71
  "sessionLimit": {
67
- "label": "Concurrent session limit",
68
- "help": "Maximum simultaneous client sessions accepted by the relay.",
69
- "advanced": true
70
- },
71
- "debugPayloadMaxBytes": {
72
- "label": "Debug payload size limit",
73
- "help": "Maximum byte size for debug payloads recorded for debugctl.",
72
+ "label": "WebUI session list size",
73
+ "help": "Recent sessions fetched for the WebUI switcher/search list. Glasses clamp to their own item-count cap.",
74
74
  "advanced": true
75
75
  },
76
76
  "debugNoisyPolicies": {
@@ -83,6 +83,26 @@
83
83
  "help": "Allow debugctl-style external tools to call debug-set, debug-dump, and remote-control.",
84
84
  "advanced": true
85
85
  },
86
+ "allowDebugUpload": {
87
+ "label": "Allow debug-trace upload",
88
+ "help": "Allow the relay to assemble user-initiated debug-trace bundles and hand them to the phone for upload. Requires External debug tools. Off by default.",
89
+ "advanced": true
90
+ },
91
+ "debugUploadMaxZipBytes": {
92
+ "label": "Debug upload max zip size (bytes)",
93
+ "help": "Maximum compressed (uploaded) bundle size; the binding upload cap. Oldest events are trimmed until the zip fits.",
94
+ "advanced": true
95
+ },
96
+ "debugUploadCapturePreset": {
97
+ "label": "Debug upload capture preset (override)",
98
+ "help": "Optional override of the always-armed debug-upload capture category list.",
99
+ "advanced": true
100
+ },
101
+ "debugBundleSaveDir": {
102
+ "label": "Debug bundle save directory",
103
+ "help": "Directory for locally-saved debug bundles. Empty = ~/.openclaw/ocuclaw-debug-bundles.",
104
+ "advanced": true
105
+ },
86
106
  "evenAiRequestTimeoutMs": {
87
107
  "label": "Even AI request timeout (ms) (deprecated)",
88
108
  "advanced": true
@@ -136,14 +156,13 @@
136
156
  "sessionLimit": {
137
157
  "type": "integer",
138
158
  "minimum": 1,
139
- "default": 10,
140
- "description": "Maximum concurrent client sessions accepted by the relay."
159
+ "default": 80,
160
+ "description": "Number of recent sessions fetched for the WebUI session switcher/search list (glasses clamp to their own item-count cap)."
141
161
  },
142
162
  "debugPayloadMaxBytes": {
143
163
  "type": "integer",
144
164
  "minimum": 1,
145
- "default": 2048,
146
- "description": "Maximum byte size for individual debug payloads recorded for debugctl."
165
+ "description": "Deprecated and ignored: debug payload truncation was removed (2026-05-13); this key has no effect. Retained in the schema only so existing configs that still set it continue to validate."
147
166
  },
148
167
  "debugNoisyPolicies": {
149
168
  "anyOf": [
@@ -164,10 +183,38 @@
164
183
  "default": false,
165
184
  "description": "Allow debugctl-style external debug tools to use debug-set, debug-dump, and remote-control."
166
185
  },
186
+ "allowDebugUpload": {
187
+ "type": "boolean",
188
+ "default": false,
189
+ "description": "Allow the relay to assemble user-initiated debug-trace upload bundles and hand them to the phone (requires externalDebugToolsEnabled). Off by default."
190
+ },
191
+ "debugUploadMaxZipBytes": {
192
+ "type": "integer",
193
+ "minimum": 100000,
194
+ "maximum": 4300000,
195
+ "default": 4000000,
196
+ "description": "Maximum compressed (uploaded) bundle size (bytes); the binding upload cap, kept under the upload backend's limit. Oldest events are trimmed until the zip fits."
197
+ },
198
+ "debugUploadCapturePreset": {
199
+ "type": "array",
200
+ "items": {
201
+ "type": "string"
202
+ },
203
+ "description": "Optional override of the always-armed debug-upload capture preset (list of category names)."
204
+ },
205
+ "debugBundleSaveDir": {
206
+ "type": "string",
207
+ "default": "",
208
+ "description": "Directory for locally-saved debug bundles; empty = ~/.openclaw/ocuclaw-debug-bundles."
209
+ },
167
210
  "sonioxApiKey": {
168
211
  "type": "string",
169
212
  "description": "Optional Soniox API key. Enables Soniox speech-to-text for voice input."
170
213
  },
214
+ "cartesiaApiKey": {
215
+ "type": "string",
216
+ "description": "Optional Cartesia API key. Enables Cartesia Ink-2 speech-to-text for voice input."
217
+ },
171
218
  "evenAiEnabled": {
172
219
  "type": "boolean",
173
220
  "default": false,
@@ -234,7 +281,7 @@
234
281
  },
235
282
  "sessionTitleModel": {
236
283
  "type": "string",
237
- "description": "Optional model override (\"provider/model\") for the background session-title distiller. When absent, the user's normal model is used."
284
+ "description": "Optional model override (\"provider/model\") for the background session-title distiller. This is a lightweight background task, so a small, fast, inexpensive model is a good choice (e.g. anthropic/claude-haiku-4-5). When absent, the user's normal model is used."
238
285
  }
239
286
  },
240
287
  "if": {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ocuclaw",
3
- "version": "1.3.2",
4
- "requiresClientVersion": "1.3.2",
3
+ "version": "1.3.4",
4
+ "requiresClientVersion": "1.3.4",
5
5
  "description": "OcuClaw for Even Realities G2 smart glasses.",
6
6
  "type": "module",
7
7
  "main": "./dist/index.js",
@@ -34,6 +34,7 @@
34
34
  }
35
35
  },
36
36
  "dependencies": {
37
+ "fflate": "^0.8.3",
37
38
  "marked": "^17.0.2",
38
39
  "undici": "^6.26.0",
39
40
  "ws": "^8.19.0"
@@ -6,7 +6,7 @@ user-invocable: false
6
6
 
7
7
  # Authoring glasses surfaces with `render_glasses_ui`
8
8
 
9
- `render_glasses_ui` paints an interactive surface on the user's Even G2 HUD instead of a text reply. The call blocks until the user selects, dismisses, or backs out. This skill is the source of truth for **authoring** surfaces; the tool description is deliberately lean.
9
+ `render_glasses_ui` paints an interactive surface on the user's Even G2 HUD instead of a text reply. Think in two verbs: the render **paints** (the surface lives on glass, ticking, until the user exits or its refresh budget ends) and the call carries **one one-shot listen** (a bounded window in which a tap resolves your call live). The paint outlives the listen by design. This skill is the source of truth for **authoring** surfaces; the tool description is deliberately lean.
10
10
 
11
11
  ## Before you author: is the tool loaded?
12
12
 
@@ -142,6 +142,20 @@ render_glasses_ui({
142
142
  })
143
143
  ```
144
144
 
145
+ ## The interaction window (the listen)
146
+
147
+ Every call carries **one one-shot listen**: the host waits **90 seconds by default** for the user to act, **up to 600000 ms (10 min) via the optional `timeoutMs` param**. Pass `timeoutMs: 300000–600000` when you expect the user to read or decide; omit it for fire-and-forget paints; never go below 60000 for anything interactive. The listen is **never renewed automatically** — re-rendering opens a fresh one.
148
+
149
+ When the listen ends without a tap you get a **non-terminal** `{ result: "window_expired", surface_still_live: true }`. **This is not an error and not a paint event** — the surface stays on glass and keeps ticking. From there:
150
+
151
+ - Taps now **park** (the user sees their tap acknowledged; nothing is lost). Re-render the same surface (`update: "patch"`) to collect parked taps in this run — chain a couple of these listens if you are actively waiting, then stop.
152
+ - Or simply **end your turn** — parked taps **wake you** (one agent turn per real parked gesture, delivered as a refs-only plugin notification; re-render to collect) or ride your next turn. Ending your turn with a surface parked is a normal, cheap state, not an abandonment.
153
+ - **Silence-as-consent**: the window doubles as a default-action deadline. Put the deadline in the body copy ("Merging in 5 min unless you stop me"), give it a matching `timeoutMs`, and treat `window_expired` as consent.
154
+
155
+ Parked deliveries arrive annotated: `surfaceUuid`, `eventId`, `origin`, `actor`, `queuedAtMs`, `parkedForMs`. For taps that **actuate** something (sell/approve/unlock), declare `staleAfterMs` per render — a tap parked longer arrives with `stale: true`: treat it as a re-confirm prompt, **never** execute it as-is.
156
+
157
+ (The `await`/listen-without-repaint verb namespace is reserved for a future version — don't repurpose those words in surface copy or tooling.)
158
+
145
159
  ## Outcomes (the `result` you get back)
146
160
 
147
161
  | result | meaning |
@@ -149,15 +163,17 @@ render_glasses_ui({
149
163
  | `selected` | user picked a list item; `selected_index` + `selected_text` returned |
150
164
  | `back` | user double-tapped above the root; they want to revise — re-render the previous step or pivot |
151
165
  | `dismissed` | dismissed at root, or no selection made |
152
- | `timeout` | no interaction within the window (non-refresh default 30 min; refresh ends at `maxDurationMs`) |
166
+ | `window_expired` | **non-terminal** the listen ended, the surface is still live; taps park (see the interaction-window section) |
167
+ | `timeout` | terminal hygiene cap (rare); a refresh surface ends at `maxDurationMs` |
153
168
  | `recipe_failed` | refresh only — initial smoke tick failed, the consecutive-failure breaker fired, or `onError:stop`; `failureReason` carries the last error |
154
169
  | `glasses_disconnected` | refresh only — the glasses client dropped mid-cron |
155
170
 
156
- Refresh results also carry: `ticks: { count, succeeded, failed, lastSuccessAt, lastFailureAt? }`, `lastBody`, `lastItems`, and `failureReason` (on `recipe_failed`).
171
+ Every delivery carries the surface's durable `surfaceUuid` plus `origin` (`gesture` for wearer actions, `system` for plugin-initiated outcomes) and an `actor` slot. Refresh results also carry: `ticks: { count, succeeded, failed, lastSuccessAt, lastFailureAt? }`, `lastBody`, `lastItems`, and `failureReason` (on `recipe_failed`).
157
172
 
158
173
  ## Quick reference
159
174
 
160
175
  - Pick the **lowest tier**: host metrics → `system-stats`; pure data API → `http` (not enabled yet). Needs interpretation → render once from your turn.
161
176
  - `update` default is `replace` (in-place). Use `push` to drill in without losing the parent; `patch` to edit fields while the cron keeps ticking.
177
+ - The listen is one-shot: default 90 s, `timeoutMs` up to 600000 (use 300000–600000 for read-or-decide). `window_expired` ≠ error — re-render to collect parked taps, or end your turn (they wake you / ride the next turn).
162
178
  - `intervalMs` ≥ 1000. Read `failureReason` on `recipe_failed` and fix the recipe.
163
179
  - After a surface resolves, a short text reply exits to chat; another render replaces; silence lets it linger.