ocuclaw 1.3.1 → 1.3.3
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/dist/config/runtime-config-session-title-model.test.js +22 -0
- package/dist/config/runtime-config.js +7 -1
- package/dist/domain/glasses-display-system-prompt.js +52 -0
- package/dist/domain/glasses-display-system-prompt.test.js +44 -0
- package/dist/domain/glasses-ui-system-prompt.js +6 -22
- package/dist/domain/glasses-ui-system-prompt.test.js +13 -0
- package/dist/domain/prompt-channel-fragments.js +32 -0
- package/dist/domain/prompt-channel-fragments.test.js +70 -0
- package/dist/gateway/gateway-timing-ledger.js +15 -3
- package/dist/gateway/openclaw-client.js +80 -3
- package/dist/index.js +22 -0
- package/dist/runtime/channel-two-hook.js +36 -0
- package/dist/runtime/container-env.js +41 -0
- package/dist/runtime/display-toggle-states.js +98 -0
- package/dist/runtime/glasses-backpressure-latch.js +115 -0
- package/dist/runtime/register-session-title-distiller.js +100 -0
- package/dist/runtime/relay-core.js +284 -33
- package/dist/runtime/relay-service.js +152 -13
- package/dist/runtime/relay-worker-entry.js +26 -0
- package/dist/runtime/relay-worker-supervisor.js +51 -2
- package/dist/runtime/relay-worker-transport.js +51 -1
- package/dist/runtime/session-service.js +136 -12
- package/dist/runtime/session-title-distiller-budget.js +36 -0
- package/dist/runtime/session-title-distiller-helpers.js +130 -0
- package/dist/runtime/session-title-distiller.js +354 -0
- package/dist/runtime/session-title-record.js +21 -0
- package/dist/runtime/stable-prompt-snapshot.js +119 -0
- package/dist/tools/glasses-ui-cron.js +59 -3
- package/dist/tools/glasses-ui-paint-floor.js +33 -4
- package/dist/tools/glasses-ui-surfaces.js +369 -35
- package/dist/tools/glasses-ui-tool-description.test.js +16 -0
- package/dist/tools/glasses-ui-tool.js +662 -80
- package/dist/tools/glasses-ui-voicemail.js +299 -0
- package/dist/tools/glasses-ui-wake.js +262 -0
- package/dist/tools/session-title-tool.js +14 -76
- package/dist/tools/session-title-tool.test.js +53 -0
- package/dist/version.js +2 -2
- package/openclaw.plugin.json +9 -0
- package/package.json +4 -3
- package/skills/glasses-ui/SKILL.md +26 -3
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
// Voicemail delivery for parked glasses events (roadmap 7b, §2.6 W+P+L).
|
|
2
|
+
//
|
|
3
|
+
// A parked, ✓-acked event whose wake could not run (no dispatch lane, or the
|
|
4
|
+
// dispatch failed past its retry — the 6f wake outbox) or whose surface was
|
|
5
|
+
// destructively reaped (the 6a per-session dead-letter) must not wait in
|
|
6
|
+
// silence for the wearer to speak first. This module drains both sources into
|
|
7
|
+
// a refs-only system-context fragment for the session's NEXT genuine turn —
|
|
8
|
+
// the before_prompt_build hook registered by registerGlassesUiTool calls
|
|
9
|
+
// buildInjection(sessionKey) and returns {appendSystemContext} (Channel-2
|
|
10
|
+
// class: hash-exempt, per-turn).
|
|
11
|
+
//
|
|
12
|
+
// Locked rules carried over from the 6f security review (§2.6 amendments):
|
|
13
|
+
// - REFS ONLY: surfaceUuid is pattern-validated against the plugin-mint shape
|
|
14
|
+
// and REPLACED WHOLESALE when off-pattern; result is enum-allowlisted; ints
|
|
15
|
+
// are Number.isFinite-coerced. Outcome/label text (selected_text etc.)
|
|
16
|
+
// NEVER reaches the prompt — tapped content arrives only through a surface
|
|
17
|
+
// collect, as tool-result data.
|
|
18
|
+
// - Explicit non-wearer provenance header.
|
|
19
|
+
// - Idempotency: an entry injects at most once (keyed on the wake
|
|
20
|
+
// idempotencyKey / a minted voicemail key), so re-entering entries and
|
|
21
|
+
// double-firing hooks cannot duplicate a delivery.
|
|
22
|
+
// - TTL: stale voicemail drops LOUDLY (voicemail_expired lifecycle), never
|
|
23
|
+
// silently; dead-letter staleAfterMs (6b) additionally flags entries the
|
|
24
|
+
// agent should re-confirm before acting on.
|
|
25
|
+
//
|
|
26
|
+
// Leaf module (CJS emitter constraint): all state injected, no runtime deps.
|
|
27
|
+
|
|
28
|
+
import { sanitizeWakeToken } from "./glasses-ui-wake.js";
|
|
29
|
+
import { normalizeGlassesSessionKey } from "./glasses-ui-surfaces.js";
|
|
30
|
+
|
|
31
|
+
export const DEFAULT_VOICEMAIL_TTL_MS = 30 * 60_000;
|
|
32
|
+
export const VOICEMAIL_MAX_ENTRIES_PER_INJECTION = 8;
|
|
33
|
+
// Bound on a session's buffered (not-yet-prompting) entries — a rotated or
|
|
34
|
+
// disconnected session that never speaks again must not grow memory forever
|
|
35
|
+
// (review P3). Newest kept, eviction is loud.
|
|
36
|
+
export const VOICEMAIL_PENDING_CAP_PER_SESSION = 32;
|
|
37
|
+
const DELIVERED_KEY_CAP = 256;
|
|
38
|
+
|
|
39
|
+
const RESULT_ENUM = new Set(["selected", "back"]);
|
|
40
|
+
// Store reap reasons (glasses-ui-surfaces deadLetterEntryEvents call sites).
|
|
41
|
+
const REAP_REASON_ENUM = new Set(["drain_session", "drain_all", "exit", "pop_back"]);
|
|
42
|
+
|
|
43
|
+
function coerceInt(value) {
|
|
44
|
+
return Number.isFinite(value) ? Math.floor(value) : null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function sanitizeResult(value) {
|
|
48
|
+
return RESULT_ENUM.has(value) ? value : "event";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// idempotencyKey reaches the prompt verbatim-ish; constrain to the same safe
|
|
52
|
+
// charset family as the minted forms and replace wholesale when off-pattern.
|
|
53
|
+
const IDEMPOTENCY_KEY_PATTERN = /^[a-z0-9:._-]{1,80}$/i;
|
|
54
|
+
function sanitizeIdempotencyKey(value) {
|
|
55
|
+
const raw = String(value == null ? "" : value);
|
|
56
|
+
return IDEMPOTENCY_KEY_PATTERN.test(raw) ? raw : "invalid";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function createGlassesVoicemail(deps = {}) {
|
|
60
|
+
const now = typeof deps.now === "function" ? deps.now : Date.now;
|
|
61
|
+
const ttlMs = Number.isFinite(deps.ttlMs) ? deps.ttlMs : DEFAULT_VOICEMAIL_TTL_MS;
|
|
62
|
+
const maxEntries = Number.isFinite(deps.maxEntriesPerInjection)
|
|
63
|
+
? deps.maxEntriesPerInjection
|
|
64
|
+
: VOICEMAIL_MAX_ENTRIES_PER_INJECTION;
|
|
65
|
+
const drainWakeOutbox =
|
|
66
|
+
typeof deps.drainWakeOutbox === "function" ? deps.drainWakeOutbox : () => [];
|
|
67
|
+
const drainDeadLetter =
|
|
68
|
+
typeof deps.drainDeadLetter === "function" ? deps.drainDeadLetter : () => [];
|
|
69
|
+
const emitLifecycle =
|
|
70
|
+
typeof deps.emitLifecycle === "function" ? deps.emitLifecycle : () => {};
|
|
71
|
+
|
|
72
|
+
// Outbox entries arrive for ALL sessions on each drain; buffer the
|
|
73
|
+
// non-target sessions' entries here so one session's prompt build never
|
|
74
|
+
// destroys another session's owed voicemail. Bounded per session and
|
|
75
|
+
// TTL-checked AT INGESTION (review P3) — buckets for silent sessions
|
|
76
|
+
// cannot grow without bound, and empty buckets are deleted.
|
|
77
|
+
const pendingBySession = new Map(); // normalized sessionKey -> [entry]
|
|
78
|
+
// Injected-once guard (bounded FIFO), keyed on the CANONICAL per-event key
|
|
79
|
+
// (surfaceUuid:eventId) so the same parked tap arriving via BOTH the wake
|
|
80
|
+
// outbox and the dead-letter injects exactly once (review P2).
|
|
81
|
+
const deliveredKeys = new Set();
|
|
82
|
+
|
|
83
|
+
function rememberDelivered(key) {
|
|
84
|
+
deliveredKeys.add(key);
|
|
85
|
+
if (deliveredKeys.size > DELIVERED_KEY_CAP) {
|
|
86
|
+
const oldest = deliveredKeys.values().next().value;
|
|
87
|
+
deliveredKeys.delete(oldest);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function dedupeKeyOf(entry) {
|
|
92
|
+
return `${entry.surfaceUuid}:${entry.eventId === null ? 0 : entry.eventId}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Route a batch of normalized entries into their sessions' pending buffers:
|
|
96
|
+
// TTL-expired entries drop here (loudly), and each bucket trims to the cap
|
|
97
|
+
// (newest kept, loudly). One emit per session per batch.
|
|
98
|
+
function ingest(entries, nowMs) {
|
|
99
|
+
const bySession = new Map();
|
|
100
|
+
for (const entry of entries) {
|
|
101
|
+
if (!entry.sessionKey) continue;
|
|
102
|
+
let batch = bySession.get(entry.sessionKey);
|
|
103
|
+
if (!batch) { batch = []; bySession.set(entry.sessionKey, batch); }
|
|
104
|
+
batch.push(entry);
|
|
105
|
+
}
|
|
106
|
+
for (const [sessionKey, batch] of bySession) {
|
|
107
|
+
let expired = 0;
|
|
108
|
+
const fresh = [];
|
|
109
|
+
for (const entry of batch) {
|
|
110
|
+
const basisMs = Number.isFinite(entry.owedSinceMs) ? entry.owedSinceMs : nowMs;
|
|
111
|
+
if (nowMs - basisMs > ttlMs) { expired += 1; continue; }
|
|
112
|
+
fresh.push(entry);
|
|
113
|
+
}
|
|
114
|
+
if (expired > 0) {
|
|
115
|
+
emitLifecycle("voicemail_expired", "warn", { sessionKey, dropped: expired, ttlMs });
|
|
116
|
+
}
|
|
117
|
+
if (fresh.length === 0) continue;
|
|
118
|
+
const list = pendingBySession.get(sessionKey) || [];
|
|
119
|
+
list.push(...fresh);
|
|
120
|
+
if (list.length > VOICEMAIL_PENDING_CAP_PER_SESSION) {
|
|
121
|
+
const evicted = list.splice(0, list.length - VOICEMAIL_PENDING_CAP_PER_SESSION);
|
|
122
|
+
emitLifecycle("voicemail_evicted", "warn", { sessionKey, evicted: evicted.length });
|
|
123
|
+
}
|
|
124
|
+
pendingBySession.set(sessionKey, list);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Normalize an owed wake (outbox shape) into the voicemail entry form.
|
|
129
|
+
function fromOutbox(record) {
|
|
130
|
+
const surfaceUuid = sanitizeWakeToken(record && record.surfaceUuid);
|
|
131
|
+
const eventId = coerceInt(record && record.eventId);
|
|
132
|
+
return {
|
|
133
|
+
sessionKey: normalizeGlassesSessionKey(record && record.sessionKey) || null,
|
|
134
|
+
surfaceUuid,
|
|
135
|
+
eventId,
|
|
136
|
+
result: sanitizeResult(record && record.result),
|
|
137
|
+
itemIndex: coerceInt(record && record.itemIndex),
|
|
138
|
+
queuedAtMs: coerceInt(record && record.queuedAtMs),
|
|
139
|
+
owedSinceMs: coerceInt(record && record.failedAtMs) ?? coerceInt(record && record.queuedAtMs),
|
|
140
|
+
idempotencyKey: sanitizeIdempotencyKey(record && record.idempotencyKey),
|
|
141
|
+
via: record && record.error === "no_dispatch_lane" ? "wake_unavailable" : "wake_failed",
|
|
142
|
+
staleAfterMs: null,
|
|
143
|
+
surfaceLive: true,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Normalize a dead-letter record's events (store shape) into entries.
|
|
148
|
+
function fromDeadLetter(sessionKey, record) {
|
|
149
|
+
const surfaceUuid = sanitizeWakeToken(record && record.surfaceUuid);
|
|
150
|
+
const reason =
|
|
151
|
+
record && REAP_REASON_ENUM.has(record.reason) ? `reaped:${record.reason}` : "reaped";
|
|
152
|
+
const staleAfterMs = record && Number.isFinite(record.staleAfterMs) ? record.staleAfterMs : null;
|
|
153
|
+
const events = record && Array.isArray(record.events) ? record.events : [];
|
|
154
|
+
return events.map((ev) => {
|
|
155
|
+
const eventId = coerceInt(ev && ev.eventId);
|
|
156
|
+
return {
|
|
157
|
+
sessionKey,
|
|
158
|
+
surfaceUuid,
|
|
159
|
+
eventId,
|
|
160
|
+
result: sanitizeResult(ev && ev.outcome && ev.outcome.result),
|
|
161
|
+
itemIndex: coerceInt(ev && ev.outcome && ev.outcome.selected_index),
|
|
162
|
+
queuedAtMs: coerceInt(ev && ev.queuedAtMs),
|
|
163
|
+
owedSinceMs: coerceInt(ev && ev.queuedAtMs) ?? coerceInt(record && record.reapedAtMs),
|
|
164
|
+
idempotencyKey: `glasses-voicemail:${surfaceUuid}:${eventId === null ? 0 : eventId}`,
|
|
165
|
+
via: reason,
|
|
166
|
+
staleAfterMs,
|
|
167
|
+
surfaceLive: false,
|
|
168
|
+
};
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function formatEntry(entry, nowMs) {
|
|
173
|
+
const ageMs = Number.isFinite(entry.queuedAtMs) ? Math.max(0, nowMs - entry.queuedAtMs) : null;
|
|
174
|
+
const stale =
|
|
175
|
+
Number.isFinite(entry.staleAfterMs) && ageMs !== null && ageMs > entry.staleAfterMs;
|
|
176
|
+
const parts = [
|
|
177
|
+
`- surfaceUuid=${entry.surfaceUuid}`,
|
|
178
|
+
`eventId=${entry.eventId}`,
|
|
179
|
+
`result=${entry.result}`,
|
|
180
|
+
`itemIndex=${entry.itemIndex}`,
|
|
181
|
+
`queuedAtMs=${entry.queuedAtMs}`,
|
|
182
|
+
`ageMs=${ageMs}`,
|
|
183
|
+
`via=${entry.via}`,
|
|
184
|
+
`idempotencyKey=${entry.idempotencyKey}`,
|
|
185
|
+
];
|
|
186
|
+
if (stale) parts.push("stale=true");
|
|
187
|
+
parts.push(
|
|
188
|
+
entry.surfaceLive
|
|
189
|
+
? '(surface may still be live: re-render it with update:"patch" to collect)'
|
|
190
|
+
: "(surface no longer live: treat the refs as the wearer's parked answer to that surface; re-confirm before acting if stale)",
|
|
191
|
+
);
|
|
192
|
+
return parts.join(" ");
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Sweep ALL buffered buckets for TTL expiry on every build (review round 2):
|
|
196
|
+
// session keys rotate on relay restarts/reconnects, so abandoned sessions
|
|
197
|
+
// are a recurring shape — without this, their buckets (each capped, but
|
|
198
|
+
// unbounded in NUMBER) would outlive them forever. After a sweep, total
|
|
199
|
+
// buffered memory is bounded by sessions active within one TTL window.
|
|
200
|
+
function sweepExpired(nowMs) {
|
|
201
|
+
for (const [key, list] of pendingBySession) {
|
|
202
|
+
const fresh = list.filter((entry) => {
|
|
203
|
+
const basisMs = Number.isFinite(entry.owedSinceMs) ? entry.owedSinceMs : nowMs;
|
|
204
|
+
return nowMs - basisMs <= ttlMs;
|
|
205
|
+
});
|
|
206
|
+
const dropped = list.length - fresh.length;
|
|
207
|
+
if (dropped > 0) {
|
|
208
|
+
emitLifecycle("voicemail_expired", "warn", { sessionKey: key, dropped, ttlMs });
|
|
209
|
+
}
|
|
210
|
+
if (fresh.length === 0) {
|
|
211
|
+
pendingBySession.delete(key);
|
|
212
|
+
} else if (dropped > 0) {
|
|
213
|
+
pendingBySession.set(key, fresh);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function pendingSessionCount() {
|
|
219
|
+
return pendingBySession.size;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function buildInjection(rawSessionKey) {
|
|
223
|
+
const sessionKey = normalizeGlassesSessionKey(rawSessionKey);
|
|
224
|
+
if (typeof sessionKey !== "string" || !sessionKey) return null;
|
|
225
|
+
const nowMs = now();
|
|
226
|
+
sweepExpired(nowMs);
|
|
227
|
+
|
|
228
|
+
// Pull both sources through the bounded ingest. The outbox drain returns
|
|
229
|
+
// every session's entries — non-target ones land in their own buffers.
|
|
230
|
+
ingest(
|
|
231
|
+
[
|
|
232
|
+
...drainWakeOutbox().map((record) => fromOutbox(record)),
|
|
233
|
+
...(drainDeadLetter(sessionKey) || []).flatMap((r) => fromDeadLetter(sessionKey, r)),
|
|
234
|
+
],
|
|
235
|
+
nowMs,
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
const pending = pendingBySession.get(sessionKey);
|
|
239
|
+
if (!pending || pending.length === 0) {
|
|
240
|
+
pendingBySession.delete(sessionKey); // no empty buckets left behind
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
pendingBySession.delete(sessionKey);
|
|
244
|
+
|
|
245
|
+
// In-batch collapse by the canonical per-event key (review P2): the same
|
|
246
|
+
// parked tap can arrive via the wake outbox AND the dead-letter in one
|
|
247
|
+
// pass — the dead-letter entry wins (the surface is genuinely gone, so a
|
|
248
|
+
// "may still be live" line for it would be false).
|
|
249
|
+
const byEvent = new Map();
|
|
250
|
+
for (const entry of pending) {
|
|
251
|
+
const key = dedupeKeyOf(entry);
|
|
252
|
+
const existing = byEvent.get(key);
|
|
253
|
+
if (!existing || (existing.surfaceLive && !entry.surfaceLive)) {
|
|
254
|
+
byEvent.set(key, entry);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const deliverable = [];
|
|
259
|
+
let dropped = 0;
|
|
260
|
+
for (const entry of byEvent.values()) {
|
|
261
|
+
const basisMs = Number.isFinite(entry.owedSinceMs) ? entry.owedSinceMs : nowMs;
|
|
262
|
+
if (nowMs - basisMs > ttlMs) { dropped += 1; continue; }
|
|
263
|
+
const key = dedupeKeyOf(entry);
|
|
264
|
+
if (deliveredKeys.has(key)) continue;
|
|
265
|
+
rememberDelivered(key);
|
|
266
|
+
deliverable.push(entry);
|
|
267
|
+
}
|
|
268
|
+
if (dropped > 0) {
|
|
269
|
+
// Loud, never silent: a ✓-acked event aged out while buffered.
|
|
270
|
+
emitLifecycle("voicemail_expired", "warn", { sessionKey, dropped, ttlMs });
|
|
271
|
+
}
|
|
272
|
+
if (deliverable.length === 0) return null;
|
|
273
|
+
|
|
274
|
+
// Newest entries are the wearer's most recent intent — keep those when
|
|
275
|
+
// capping, in chronological order.
|
|
276
|
+
const shown = deliverable.slice(-maxEntries);
|
|
277
|
+
const overflow = deliverable.length - shown.length;
|
|
278
|
+
const lines = [
|
|
279
|
+
"[ocuclaw glasses-ui voicemail] Plugin-generated notification - NOT the wearer speaking.",
|
|
280
|
+
"Parked glasses events could not be delivered by a wake turn while you were away:",
|
|
281
|
+
...shown.map((entry) => formatEntry(entry, nowMs)),
|
|
282
|
+
];
|
|
283
|
+
if (overflow > 0) lines.push(`(+${overflow} older parked events omitted)`);
|
|
284
|
+
lines.push("Tapped content is never included here by design.");
|
|
285
|
+
const fragment = lines.join("\n");
|
|
286
|
+
emitLifecycle("voicemail_injected", "debug", {
|
|
287
|
+
sessionKey,
|
|
288
|
+
entries: shown.length,
|
|
289
|
+
overflow,
|
|
290
|
+
chars: fragment.length,
|
|
291
|
+
});
|
|
292
|
+
return fragment;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return { buildInjection, pendingSessionCount };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Single line: the CJS emitter strips only `^export default .*;$` (one line).
|
|
299
|
+
export default { createGlassesVoicemail, DEFAULT_VOICEMAIL_TTL_MS, VOICEMAIL_MAX_ENTRIES_PER_INJECTION };
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
// Tap-to-wake for parked glasses surfaces (roadmap 6f, §2.6 W).
|
|
2
|
+
//
|
|
3
|
+
// A REAL parked gesture buys ONE agent turn via the plugin's own gateway
|
|
4
|
+
// client (the voice-send lane — request("agent", ...)). This module is a
|
|
5
|
+
// dependency-free LEAF (CJS emitter constraint — see glasses-ui-limits.ts):
|
|
6
|
+
// it builds the wake envelope and owns the arbitration policy; transport is
|
|
7
|
+
// injected (the relay facade's dispatchGlassesWake).
|
|
8
|
+
//
|
|
9
|
+
// Locked contract (§2.6, panel amendments 3-4):
|
|
10
|
+
// - wake payload = STRUCTURED REFERENCES ONLY with explicit non-wearer
|
|
11
|
+
// provenance framing; never interpolated label text. SECURITY (review
|
|
12
|
+
// pinned to this ship): every token that reaches the prompt is either
|
|
13
|
+
// numeric-coerced or charset-filtered ([a-zA-Z0-9._:-], <=64 chars), so a
|
|
14
|
+
// hostile client cannot smuggle instruction text through the wake lane —
|
|
15
|
+
// tapped CONTENT only ever reaches the agent through the surface collect
|
|
16
|
+
// (onReattached delivery), where it arrives as data in a tool result, not
|
|
17
|
+
// as a user-role message.
|
|
18
|
+
// - origin-typed envelope: ONLY "gesture" enabled at launch. schedule/
|
|
19
|
+
// threshold/system are reserved categories (event-to-wake is deferred,
|
|
20
|
+
// not foreclosed) — enabling one later is a policy edit here, not a
|
|
21
|
+
// schema migration.
|
|
22
|
+
// - voice absorbs wake: an in-flight/imminent genuine turn (voice send,
|
|
23
|
+
// user send, or an earlier wake's run) suppresses the dispatch; the
|
|
24
|
+
// parked tap rides that turn's collect instead.
|
|
25
|
+
// - taps during an in-flight wake COALESCE into its delivery — never a
|
|
26
|
+
// second submission. A per-session cooldown additionally bounds gesture
|
|
27
|
+
// storms (hostile/buggy client spamming taps) to <=1 turn per window;
|
|
28
|
+
// suppressed taps stay parked in the 6a event log — bounded, never silent.
|
|
29
|
+
// - failed submission: one retry (same idempotencyKey — the gateway honors
|
|
30
|
+
// it as the runId, so a double-delivery dedupes), then the durable wake
|
|
31
|
+
// outbox + a warn lifecycle. Never silent after the ✓-ack (amendment 1);
|
|
32
|
+
// the parked event itself survives in the surface log / dead-letter
|
|
33
|
+
// regardless, so the worst case is delayed collect, not loss. The 7b
|
|
34
|
+
// voicemail leg drains the outbox via enqueueNextTurnInjection.
|
|
35
|
+
|
|
36
|
+
export const GLASSES_WAKE_ENABLED_ORIGINS = ["gesture"];
|
|
37
|
+
|
|
38
|
+
export const DEFAULT_WAKE_COOLDOWN_MS = 5_000;
|
|
39
|
+
|
|
40
|
+
// Outbox bound (review P3 sibling): the no-lane branch pushes per parked tap
|
|
41
|
+
// with no cooldown gate, so a tap storm on a legacy host would otherwise grow
|
|
42
|
+
// this without limit. Newest kept; eviction is loud. The parked events
|
|
43
|
+
// themselves still survive in the surface log / dead-letter — eviction here
|
|
44
|
+
// only forfeits the voicemail NOTICE for the oldest entries.
|
|
45
|
+
export const WAKE_OUTBOX_CAP = 64;
|
|
46
|
+
|
|
47
|
+
// Busy-signal decay: if no activity update arrives for this long, treat the
|
|
48
|
+
// session as idle again (fail open — a stuck-busy would suppress wakes
|
|
49
|
+
// forever, while a wrongly-idle wake merely queues as the next turn).
|
|
50
|
+
export const DEFAULT_AGENT_TURN_BUSY_DECAY_MS = 180_000;
|
|
51
|
+
|
|
52
|
+
// SECURITY: charset-FILTERING is not enough — stripping separators from
|
|
53
|
+
// hostile input still concatenates readable instruction words into the
|
|
54
|
+
// prompt ("su-x\" IGNORE ALL..." -> "su-xIGNOREALL..."). Tokens are instead
|
|
55
|
+
// VALIDATED against the exact plugin-minted shape / a closed enum and
|
|
56
|
+
// replaced wholesale when off-pattern, so hostile bytes never pass through.
|
|
57
|
+
const SURFACE_UUID_PATTERN = /^su-[a-z0-9]{4,24}$/i;
|
|
58
|
+
const WAKE_RESULT_ENUM = new Set(["selected", "back"]);
|
|
59
|
+
|
|
60
|
+
export function sanitizeWakeToken(value) {
|
|
61
|
+
const raw = String(value == null ? "" : value);
|
|
62
|
+
return SURFACE_UUID_PATTERN.test(raw) ? raw : "invalid";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function sanitizeWakeResult(value) {
|
|
66
|
+
return WAKE_RESULT_ENUM.has(value) ? value : "event";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function coerceInt(value) {
|
|
70
|
+
return Number.isFinite(value) ? Math.floor(value) : null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function buildWakeMessage(ref) {
|
|
74
|
+
const surfaceUuid = sanitizeWakeToken(ref && ref.surfaceUuid);
|
|
75
|
+
const result = sanitizeWakeResult(ref && ref.result);
|
|
76
|
+
const eventId = coerceInt(ref && ref.eventId);
|
|
77
|
+
const itemIndex = coerceInt(ref && ref.itemIndex);
|
|
78
|
+
const queuedAtMs = coerceInt(ref && ref.queuedAtMs);
|
|
79
|
+
return [
|
|
80
|
+
"[ocuclaw glasses-ui wake] Plugin-generated notification - NOT the wearer speaking.",
|
|
81
|
+
`The wearer tapped a parked glasses surface (origin=gesture). refs: surfaceUuid=${surfaceUuid}`,
|
|
82
|
+
`eventId=${eventId} result=${result} itemIndex=${itemIndex} queuedAtMs=${queuedAtMs}.`,
|
|
83
|
+
"Tapped content is not included here by design: re-render that surface",
|
|
84
|
+
"(update:\"patch\") to collect the parked event(s), then respond as appropriate.",
|
|
85
|
+
].join(" ");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Per-session "an agent turn is in flight or imminent" signal. Fed by the
|
|
89
|
+
// relay: markBusy on every dispatched send (voice/user/wake) and onActivity
|
|
90
|
+
// from the gateway activity stream (normalized phase: start/update = busy
|
|
91
|
+
// refresh, end = idle). Lives here (leaf) so it is unit-testable and shared
|
|
92
|
+
// with the relay without a runtime-module cycle.
|
|
93
|
+
export function createAgentTurnTracker(deps = {}) {
|
|
94
|
+
const now = typeof deps.now === "function" ? deps.now : Date.now;
|
|
95
|
+
const busyDecayMs = Number.isFinite(deps.busyDecayMs)
|
|
96
|
+
? deps.busyDecayMs
|
|
97
|
+
: DEFAULT_AGENT_TURN_BUSY_DECAY_MS;
|
|
98
|
+
const lastSeenBySession = new Map(); // normalized sessionKey -> lastSeenMs
|
|
99
|
+
|
|
100
|
+
// One session arrives under TWO key forms: the relay send path marks busy
|
|
101
|
+
// with the stripped relay key ("ocuclaw:<ts>") while the surface store /
|
|
102
|
+
// gateway hook contexts use the canonical "agent:<id>:<key>". Normalize so
|
|
103
|
+
// both name the same busy slot — without this, a tap in the send→run-start
|
|
104
|
+
// window dispatches a second agent submission during an in-flight genuine
|
|
105
|
+
// turn (live-proven, 2026-06-12 Wave-1 e2e leg 3a).
|
|
106
|
+
function normalizeKey(sessionKey) {
|
|
107
|
+
return sessionKey.replace(/^agent:[^:]+:/, "");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function markBusy(sessionKey) {
|
|
111
|
+
if (typeof sessionKey !== "string" || !sessionKey) return;
|
|
112
|
+
lastSeenBySession.set(normalizeKey(sessionKey), now());
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function onActivity(sessionKey, phase) {
|
|
116
|
+
if (typeof sessionKey !== "string" || !sessionKey) return;
|
|
117
|
+
if (phase === "end") {
|
|
118
|
+
lastSeenBySession.delete(normalizeKey(sessionKey));
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
lastSeenBySession.set(normalizeKey(sessionKey), now());
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function isBusy(sessionKey) {
|
|
125
|
+
if (typeof sessionKey !== "string" || !sessionKey) return false;
|
|
126
|
+
const key = normalizeKey(sessionKey);
|
|
127
|
+
const lastSeen = lastSeenBySession.get(key);
|
|
128
|
+
if (!Number.isFinite(lastSeen)) return false;
|
|
129
|
+
if (now() - lastSeen >= busyDecayMs) {
|
|
130
|
+
lastSeenBySession.delete(key);
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return { markBusy, onActivity, isBusy };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function createGlassesWakeController(deps = {}) {
|
|
140
|
+
const dispatchWake = typeof deps.dispatchWake === "function" ? deps.dispatchWake : null;
|
|
141
|
+
const isAgentTurnBusy =
|
|
142
|
+
typeof deps.isAgentTurnBusy === "function" ? deps.isAgentTurnBusy : () => false;
|
|
143
|
+
const emitLifecycle =
|
|
144
|
+
typeof deps.emitLifecycle === "function" ? deps.emitLifecycle : () => {};
|
|
145
|
+
const now = typeof deps.now === "function" ? deps.now : Date.now;
|
|
146
|
+
const wakeCooldownMs = Number.isFinite(deps.wakeCooldownMs)
|
|
147
|
+
? deps.wakeCooldownMs
|
|
148
|
+
: DEFAULT_WAKE_COOLDOWN_MS;
|
|
149
|
+
|
|
150
|
+
const inFlightBySession = new Map(); // sessionKey -> promise
|
|
151
|
+
const lastWakeAtBySession = new Map(); // sessionKey -> ms
|
|
152
|
+
const outbox = []; // failed/unavailable wakes owed to the 7b voicemail leg
|
|
153
|
+
|
|
154
|
+
function pushOutbox(entry) {
|
|
155
|
+
outbox.push(entry);
|
|
156
|
+
if (outbox.length > WAKE_OUTBOX_CAP) {
|
|
157
|
+
const evicted = outbox.splice(0, outbox.length - WAKE_OUTBOX_CAP);
|
|
158
|
+
emitLifecycle("wake_outbox_evicted", "warn", { evicted: evicted.length });
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function refsOnly(ref) {
|
|
163
|
+
return {
|
|
164
|
+
sessionKey: typeof ref.sessionKey === "string" ? ref.sessionKey : null,
|
|
165
|
+
surfaceUuid: sanitizeWakeToken(ref.surfaceUuid),
|
|
166
|
+
eventId: coerceInt(ref.eventId),
|
|
167
|
+
result: sanitizeWakeResult(ref.result),
|
|
168
|
+
itemIndex: coerceInt(ref.itemIndex),
|
|
169
|
+
// The origin gate below compares against the enum, so a non-enum
|
|
170
|
+
// origin can never dispatch; keep the raw string for the gate.
|
|
171
|
+
origin: typeof ref.origin === "string" ? ref.origin : "gesture",
|
|
172
|
+
queuedAtMs: coerceInt(ref.queuedAtMs),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function suppress(reason, refs) {
|
|
177
|
+
emitLifecycle("wake_suppressed", "debug", { reason, ...refs });
|
|
178
|
+
return { dispatched: false, reason };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function onParkedGesture(ref) {
|
|
182
|
+
const refs = refsOnly(ref || {});
|
|
183
|
+
if (!dispatchWake) {
|
|
184
|
+
// Wake disabled (legacy host without the relay lane): the parked tap
|
|
185
|
+
// keeps its collect-on-next-render semantics AND is owed to the 7b
|
|
186
|
+
// voicemail leg so the next genuine turn hears about it — no longer a
|
|
187
|
+
// silent early-return. Same gates as a dispatch: enabled origin + a
|
|
188
|
+
// session to deliver to.
|
|
189
|
+
if (GLASSES_WAKE_ENABLED_ORIGINS.includes(refs.origin) && refs.sessionKey) {
|
|
190
|
+
if (refs.queuedAtMs === null) refs.queuedAtMs = now();
|
|
191
|
+
const idempotencyKey = `glasses-wake:${refs.surfaceUuid}:${refs.eventId === null ? 0 : refs.eventId}`;
|
|
192
|
+
pushOutbox({
|
|
193
|
+
...refs,
|
|
194
|
+
idempotencyKey,
|
|
195
|
+
failedAtMs: now(),
|
|
196
|
+
error: "no_dispatch_lane",
|
|
197
|
+
});
|
|
198
|
+
emitLifecycle("wake_unavailable_outboxed", "debug", { ...refs, idempotencyKey });
|
|
199
|
+
}
|
|
200
|
+
return { dispatched: false, reason: "no_dispatch_lane" };
|
|
201
|
+
}
|
|
202
|
+
if (!GLASSES_WAKE_ENABLED_ORIGINS.includes(refs.origin)) {
|
|
203
|
+
return suppress("origin_disabled", refs);
|
|
204
|
+
}
|
|
205
|
+
const sessionKey = refs.sessionKey;
|
|
206
|
+
if (!sessionKey) return suppress("no_session", refs);
|
|
207
|
+
if (isAgentTurnBusy(sessionKey)) {
|
|
208
|
+
// Voice absorbs wake (§2.6c): the parked event rides the active turn's
|
|
209
|
+
// collect render or the next genuine turn — no second submission.
|
|
210
|
+
return suppress("absorbed_by_active_turn", refs);
|
|
211
|
+
}
|
|
212
|
+
if (inFlightBySession.has(sessionKey)) {
|
|
213
|
+
emitLifecycle("wake_coalesced", "debug", refs);
|
|
214
|
+
return { dispatched: false, reason: "coalesced_into_inflight_wake" };
|
|
215
|
+
}
|
|
216
|
+
const lastWakeAt = lastWakeAtBySession.get(sessionKey);
|
|
217
|
+
if (Number.isFinite(lastWakeAt) && now() - lastWakeAt < wakeCooldownMs) {
|
|
218
|
+
return suppress("cooldown", refs);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (refs.queuedAtMs === null) refs.queuedAtMs = now();
|
|
222
|
+
const message = buildWakeMessage(refs);
|
|
223
|
+
const idempotencyKey = `glasses-wake:${refs.surfaceUuid}:${refs.eventId === null ? 0 : refs.eventId}`;
|
|
224
|
+
const payload = { sessionKey, message, idempotencyKey };
|
|
225
|
+
lastWakeAtBySession.set(sessionKey, now());
|
|
226
|
+
const attempt = () => Promise.resolve(dispatchWake(payload));
|
|
227
|
+
const flight = attempt()
|
|
228
|
+
.catch(() => attempt()) // one retry; same idempotencyKey dedupes upstream
|
|
229
|
+
.then(() => {
|
|
230
|
+
emitLifecycle("wake_dispatched", "debug", { ...refs, idempotencyKey });
|
|
231
|
+
})
|
|
232
|
+
.catch((err) => {
|
|
233
|
+
// Never silent after the ✓-ack: the owed wake lands in the durable
|
|
234
|
+
// outbox for the 7b voicemail leg, loudly.
|
|
235
|
+
pushOutbox({
|
|
236
|
+
...refs,
|
|
237
|
+
idempotencyKey,
|
|
238
|
+
failedAtMs: now(),
|
|
239
|
+
error: String((err && err.message) || err),
|
|
240
|
+
});
|
|
241
|
+
emitLifecycle("wake_dispatch_failed", "warn", { ...refs, idempotencyKey });
|
|
242
|
+
})
|
|
243
|
+
.finally(() => {
|
|
244
|
+
inFlightBySession.delete(sessionKey);
|
|
245
|
+
});
|
|
246
|
+
inFlightBySession.set(sessionKey, flight);
|
|
247
|
+
return { dispatched: true, idempotencyKey };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function peekWakeOutbox() {
|
|
251
|
+
return outbox.map((r) => ({ ...r }));
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function drainWakeOutbox() {
|
|
255
|
+
return outbox.splice(0, outbox.length);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return { onParkedGesture, peekWakeOutbox, drainWakeOutbox };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Single line: the CJS emitter strips only `^export default .*;$` (one line).
|
|
262
|
+
export default { createGlassesWakeController, createAgentTurnTracker, buildWakeMessage, sanitizeWakeToken, GLASSES_WAKE_ENABLED_ORIGINS };
|
|
@@ -51,21 +51,12 @@ function isEvenAiDedicatedKey(sessionKey) {
|
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
function gateReason(sessionKey, deps) {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
) {
|
|
61
|
-
return "feature_disabled";
|
|
62
|
-
}
|
|
63
|
-
// Hard guard against the agent titling a session before the user has sent
|
|
64
|
-
// their first real message. The synthetic session-starter prompt the agent
|
|
65
|
-
// sees on /new + the proactive prompt-hook nudge can otherwise tempt the
|
|
66
|
-
// model into titling from a non-user input (observed: titles like "New
|
|
67
|
-
// session"). Real user sends are recorded via dispatchOcuClawUserSend ->
|
|
68
|
-
// sessionService.recordFirstSentUserMessage; the synthetic starter never is.
|
|
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.
|
|
69
60
|
if (
|
|
70
61
|
typeof deps.hasRecordedUserMessage === "function" &&
|
|
71
62
|
!deps.hasRecordedUserMessage(sessionKey)
|
|
@@ -104,7 +95,9 @@ export function createSessionTitleToolHandler(deps) {
|
|
|
104
95
|
err.code = blockedReason;
|
|
105
96
|
throw err;
|
|
106
97
|
}
|
|
107
|
-
const result = await deps.setSessionTitle(sessionKey, validation.spec.title
|
|
98
|
+
const result = await deps.setSessionTitle(sessionKey, validation.spec.title, {
|
|
99
|
+
origin: "user_tool",
|
|
100
|
+
});
|
|
108
101
|
if (result && result.ok === false) {
|
|
109
102
|
const err = new Error(`${result.code}: ${result.message || "set rejected"}`);
|
|
110
103
|
err.code = result.code;
|
|
@@ -115,60 +108,15 @@ export function createSessionTitleToolHandler(deps) {
|
|
|
115
108
|
return { setSessionTitle };
|
|
116
109
|
}
|
|
117
110
|
|
|
118
|
-
const TOOL_DESCRIPTION = [
|
|
119
|
-
"
|
|
111
|
+
export const TOOL_DESCRIPTION = [
|
|
112
|
+
"Rename the current chat session (shown in the user's glasses session list).",
|
|
120
113
|
"",
|
|
121
|
-
"
|
|
114
|
+
"Call ONLY when the user explicitly asks to rename or retitle the session.",
|
|
115
|
+
"Automatic titling is handled elsewhere — do not call this proactively.",
|
|
122
116
|
"",
|
|
123
|
-
"Title: 2-5 word noun phrase, ≤55 chars, no trailing punctuation or
|
|
124
|
-
"",
|
|
125
|
-
"Do not announce the rename unless the user explicitly asked to retitle.",
|
|
117
|
+
"Title: 2-5 word noun phrase, ≤55 chars, no trailing punctuation or quotes.",
|
|
126
118
|
].join("\n");
|
|
127
119
|
|
|
128
|
-
export function createSessionTitlePromptHook(deps) {
|
|
129
|
-
return function sessionTitleBeforePromptBuild(_event, ctx) {
|
|
130
|
-
const sessionKey = ctx && typeof ctx.sessionKey === "string" ? ctx.sessionKey : null;
|
|
131
|
-
if (!sessionKey) return undefined;
|
|
132
|
-
const title = typeof deps.getSessionTitle === "function" ? deps.getSessionTitle(sessionKey) : null;
|
|
133
|
-
const userLocked =
|
|
134
|
-
typeof deps.isSessionUserLocked === "function" && deps.isSessionUserLocked(sessionKey);
|
|
135
|
-
const featureEnabled =
|
|
136
|
-
typeof deps.isNeuralSessionNamesEnabled === "function"
|
|
137
|
-
? deps.isNeuralSessionNamesEnabled(sessionKey)
|
|
138
|
-
: true;
|
|
139
|
-
|
|
140
|
-
const fragments = [];
|
|
141
|
-
if (title) {
|
|
142
|
-
fragments.push(`Current session title: "${title}".`);
|
|
143
|
-
}
|
|
144
|
-
if (userLocked) {
|
|
145
|
-
fragments.push(
|
|
146
|
-
"The user has set a custom title; do not call set_session_title.",
|
|
147
|
-
);
|
|
148
|
-
} else if (!featureEnabled) {
|
|
149
|
-
fragments.push(
|
|
150
|
-
"Neural Topic Distiller is disabled; do not call set_session_title.",
|
|
151
|
-
);
|
|
152
|
-
} else if (title) {
|
|
153
|
-
fragments.push(
|
|
154
|
-
"Call set_session_title only if the topic has clearly shifted.",
|
|
155
|
-
);
|
|
156
|
-
} else {
|
|
157
|
-
const hasUserMessage =
|
|
158
|
-
typeof deps.hasRecordedUserMessage !== "function" ||
|
|
159
|
-
deps.hasRecordedUserMessage(sessionKey);
|
|
160
|
-
if (hasUserMessage) {
|
|
161
|
-
fragments.push(
|
|
162
|
-
"No session title is set yet. Call set_session_title now if the user's latest message names any concrete topic.",
|
|
163
|
-
);
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
if (fragments.length === 0) return undefined;
|
|
168
|
-
return { appendSystemContext: fragments.join(" ") };
|
|
169
|
-
};
|
|
170
|
-
}
|
|
171
|
-
|
|
172
120
|
export function registerSessionTitleTool(api, service) {
|
|
173
121
|
if (!api || typeof api.registerTool !== "function") {
|
|
174
122
|
throw new Error("registerSessionTitleTool requires api.registerTool");
|
|
@@ -196,14 +144,4 @@ export function registerSessionTitleTool(api, service) {
|
|
|
196
144
|
};
|
|
197
145
|
},
|
|
198
146
|
});
|
|
199
|
-
|
|
200
|
-
if (typeof api.on === "function") {
|
|
201
|
-
const hook = createSessionTitlePromptHook({
|
|
202
|
-
getSessionTitle: (sessionKey) => service.getSessionTitle(sessionKey),
|
|
203
|
-
isSessionUserLocked: (sessionKey) => service.isSessionUserLocked(sessionKey),
|
|
204
|
-
isNeuralSessionNamesEnabled: (sessionKey) => service.isNeuralSessionNamesEnabled(sessionKey),
|
|
205
|
-
hasRecordedUserMessage: (sessionKey) => service.hasRecordedUserMessage(sessionKey),
|
|
206
|
-
});
|
|
207
|
-
api.on("before_prompt_build", hook);
|
|
208
|
-
}
|
|
209
147
|
}
|