ocuclaw 0.1.0 → 1.2.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 CHANGED
@@ -1,9 +1,11 @@
1
1
  # OcuClaw
2
2
 
3
- OcuClaw is an OpenClaw plugin for Even G2 smart glasses.
3
+ OcuClaw is an OpenClaw plugin for Even G2 smart glasses. Use the OcuClaw application within Even Hub App Store to connect the client side.
4
4
 
5
5
  ## Install
6
6
 
7
+ Install the plugin from the OpenClaw CLI:
8
+
7
9
  ```bash
8
10
  openclaw plugins install ocuclaw
9
11
  openclaw plugins enable ocuclaw
@@ -11,15 +13,55 @@ openclaw plugins enable ocuclaw
11
13
 
12
14
  ## Configure
13
15
 
14
- Set the relay token before starting or restarting the OpenClaw gateway:
16
+ Required:
17
+
18
+ Set the OcuClaw relay token. This is a user-created password that must match the relay server token field in the OcuClaw application within Even Hub App Store.
15
19
 
16
20
  ```bash
17
21
  openclaw config set plugins.entries.ocuclaw.config.relayToken "<relay-token>"
18
22
  ```
19
23
 
20
- Optional settings live under `plugins.entries.ocuclaw.config.*`.
24
+ Recommended:
25
+
26
+ - `sonioxApiKey`: Enables Soniox speech-to-text for voice input.
27
+
28
+ ```bash
29
+ openclaw config set plugins.entries.ocuclaw.config.sonioxApiKey "<soniox-api-key>"
30
+ ```
31
+
32
+ - `evenAiEnabled`: Enables Even AI integration for OcuClaw.
21
33
 
22
- ## Notes
34
+ ```bash
35
+ openclaw config set plugins.entries.ocuclaw.config.evenAiEnabled true --strict-json
36
+ ```
37
+
38
+ - `evenAiToken`: Sets the user-created password for Even AI requests. This must match the password set in the Even AI Agent Configure section within the Even Realities app.
39
+
40
+ ```bash
41
+ openclaw config set plugins.entries.ocuclaw.config.evenAiToken "<even-ai-token>"
42
+ ```
23
43
 
24
- - Plugin id: `ocuclaw`
25
- - The WebUI is a separate artifact and is not bundled into this npm package.
44
+ Advanced optional settings:
45
+
46
+ ```bash
47
+ openclaw config set plugins.entries.ocuclaw.config.wsBind "127.0.0.1"
48
+ openclaw config set plugins.entries.ocuclaw.config.wsPort 9000 --strict-json
49
+ openclaw config set plugins.entries.ocuclaw.config.sessionLimit 10 --strict-json
50
+ openclaw config set plugins.entries.ocuclaw.config.externalDebugToolsEnabled true --strict-json
51
+ ```
52
+
53
+ ## Restart
54
+
55
+ Restart the gateway so the plugin and config changes take effect:
56
+
57
+ ```bash
58
+ openclaw gateway restart
59
+ ```
60
+
61
+ ## Verify
62
+
63
+ ```bash
64
+ openclaw plugins inspect ocuclaw
65
+ openclaw plugins doctor
66
+ openclaw gateway status
67
+ ```
@@ -458,11 +458,12 @@ function redactStringValue(value, mode, keyName) {
458
458
  }
459
459
  }
460
460
 
461
- function emit(event) {
461
+ function emit(event, options) {
462
462
  const raw = event || {};
463
463
  const cat = typeof raw.cat === "string" ? raw.cat.trim() : "";
464
+ const force = !!(options && options.force === true);
464
465
  if (!cat) return false;
465
- if (!isEnabled(cat)) return false;
466
+ if (!force && !isEnabled(cat)) return false;
466
467
 
467
468
  const ts = Number.isFinite(raw.ts) ? Math.floor(raw.ts) : nowFn();
468
469
  const eventName =
@@ -63,6 +63,8 @@ function normalizeLogger(logger) {
63
63
  * Returns true if the OpenClaw gateway connection is active.
64
64
  * @param {(clientId: string, payload: object) => void} [opts.onEventDebug]
65
65
  * Optional structured client debug-event callback.
66
+ * @param {(clientId: string, payload: object) => Promise<object>|object} [opts.onReadinessProbe]
67
+ * Optional dedicated readiness-probe dispatcher.
66
68
  * @returns {object} Handler instance
67
69
  */
68
70
  function createDownstreamHandler(opts) {
@@ -95,6 +97,7 @@ function createDownstreamHandler(opts) {
95
97
  const onDebugDump = opts.onDebugDump || null;
96
98
  const onEventDebug = opts.onEventDebug || null;
97
99
  const onRemoteControl = opts.onRemoteControl || null;
100
+ const onReadinessProbe = opts.onReadinessProbe || null;
98
101
  const getSnapshotRevision = opts.getSnapshotRevision || null;
99
102
 
100
103
  /** Client IDs subscribed to raw protocol frame forwarding. */
@@ -139,6 +142,8 @@ function createDownstreamHandler(opts) {
139
142
  pages: "ocuclaw.view.pages.snapshot",
140
143
  protocolSubscribe: "ocuclaw.protocol.tap.subscribe",
141
144
  protocolFrame: "ocuclaw.protocol.tap.frame",
145
+ readinessProbeAck: "ocuclaw.readiness.probe.ack",
146
+ readinessProbeRequest: "ocuclaw.readiness.probe.request",
142
147
  remoteControl: "ocuclaw.remote.control",
143
148
  requestSonioxTemporaryKey: "requestSonioxTemporaryKey",
144
149
  sonioxModelsGet: "ocuclaw.voice.soniox.models.get",
@@ -234,7 +239,8 @@ function createDownstreamHandler(opts) {
234
239
  return (
235
240
  messageType === "debug-set" ||
236
241
  messageType === "debug-dump" ||
237
- messageType === "remote-control"
242
+ messageType === "remote-control" ||
243
+ messageType === APP_PROTOCOL.readinessProbeRequest
238
244
  );
239
245
  }
240
246
 
@@ -827,6 +833,63 @@ function createDownstreamHandler(opts) {
827
833
  });
828
834
  }
829
835
 
836
+ function formatReadinessProbeRequest(data) {
837
+ return JSON.stringify({
838
+ type: APP_PROTOCOL.readinessProbeRequest,
839
+ requestId:
840
+ data && typeof data.requestId === "string" && data.requestId
841
+ ? data.requestId
842
+ : null,
843
+ sinceMs:
844
+ data && Number.isFinite(Number(data.sinceMs))
845
+ ? Math.max(0, Math.floor(Number(data.sinceMs)))
846
+ : 0,
847
+ sessionKey:
848
+ data && typeof data.sessionKey === "string" && data.sessionKey
849
+ ? data.sessionKey
850
+ : null,
851
+ });
852
+ }
853
+
854
+ function formatReadinessProbeAck(data) {
855
+ return JSON.stringify({
856
+ type: APP_PROTOCOL.readinessProbeAck,
857
+ ok: data && data.ok !== false,
858
+ requestId:
859
+ data && typeof data.requestId === "string" && data.requestId
860
+ ? data.requestId
861
+ : null,
862
+ reasonCode:
863
+ data && typeof data.reasonCode === "string" && data.reasonCode
864
+ ? data.reasonCode
865
+ : null,
866
+ message:
867
+ data && typeof data.message === "string" && data.message
868
+ ? data.message
869
+ : null,
870
+ activeSessionKey:
871
+ data && typeof data.activeSessionKey === "string" && data.activeSessionKey
872
+ ? data.activeSessionKey
873
+ : null,
874
+ emittedAtMs:
875
+ data && Number.isFinite(Number(data.emittedAtMs))
876
+ ? Math.max(0, Math.floor(Number(data.emittedAtMs)))
877
+ : null,
878
+ clientId:
879
+ data && typeof data.clientId === "string" && data.clientId
880
+ ? data.clientId
881
+ : null,
882
+ clientName:
883
+ data && typeof data.clientName === "string" && data.clientName
884
+ ? data.clientName
885
+ : null,
886
+ clientVersion:
887
+ data && typeof data.clientVersion === "string" && data.clientVersion
888
+ ? data.clientVersion
889
+ : null,
890
+ });
891
+ }
892
+
830
893
  function normalizeCategories(raw, fieldName) {
831
894
  if (raw === undefined || raw === null) return [];
832
895
  if (!Array.isArray(raw)) {
@@ -1124,6 +1187,26 @@ function createDownstreamHandler(opts) {
1124
1187
  ) {
1125
1188
  return "debug-close-app-client";
1126
1189
  }
1190
+ if (
1191
+ normalized === "debug-screen-off-worker-on" ||
1192
+ normalized === "debug_screen_off_worker_on" ||
1193
+ normalized === "debugscreenoffworkeron" ||
1194
+ normalized === "screen-off-worker-on" ||
1195
+ normalized === "screen_off_worker_on" ||
1196
+ normalized === "screenoffworkeron"
1197
+ ) {
1198
+ return "debug-screen-off-worker-on";
1199
+ }
1200
+ if (
1201
+ normalized === "debug-screen-off-worker-off" ||
1202
+ normalized === "debug_screen_off_worker_off" ||
1203
+ normalized === "debugscreenoffworkeroff" ||
1204
+ normalized === "screen-off-worker-off" ||
1205
+ normalized === "screen_off_worker_off" ||
1206
+ normalized === "screenoffworkeroff"
1207
+ ) {
1208
+ return "debug-screen-off-worker-off";
1209
+ }
1127
1210
  throw new Error(`unsupported remote relayAction: ${raw}`);
1128
1211
  }
1129
1212
 
@@ -1187,12 +1270,19 @@ function createDownstreamHandler(opts) {
1187
1270
 
1188
1271
  const payload = {};
1189
1272
 
1190
- const modelRef = parseOptionalTrimmedString(msg.modelRef);
1191
- if (modelRef) {
1192
- if (!modelRef.includes("/")) {
1193
- throw new Error("modelRef must be in provider/id format");
1273
+ if (Object.prototype.hasOwnProperty.call(msg, "modelRef")) {
1274
+ if (typeof msg.modelRef !== "string") {
1275
+ throw new Error("modelRef must be in provider/id format or blank");
1276
+ }
1277
+ const modelRef = msg.modelRef.trim();
1278
+ if (!modelRef) {
1279
+ payload.modelRef = "";
1280
+ } else {
1281
+ if (!modelRef.includes("/")) {
1282
+ throw new Error("modelRef must be in provider/id format");
1283
+ }
1284
+ payload.modelRef = modelRef;
1194
1285
  }
1195
- payload.modelRef = modelRef;
1196
1286
  }
1197
1287
 
1198
1288
  if (Object.prototype.hasOwnProperty.call(msg, "thinkingLevel")) {
@@ -1489,6 +1579,25 @@ function createDownstreamHandler(opts) {
1489
1579
  throw new Error(`unsupported remote-control action: ${actionRaw}`);
1490
1580
  }
1491
1581
 
1582
+ function parseReadinessProbe(msg) {
1583
+ if (!msg || typeof msg !== "object") {
1584
+ throw new Error("readiness probe payload must be an object");
1585
+ }
1586
+ const requestId = parseOptionalTrimmedString(msg.requestId);
1587
+ if (!requestId) {
1588
+ throw new Error("readiness probe requires requestId");
1589
+ }
1590
+ const sinceMs = parseOptionalNonNegativeNumber(msg.sinceMs, "sinceMs");
1591
+ if (sinceMs === undefined) {
1592
+ throw new Error("readiness probe sinceMs must be a non-negative number");
1593
+ }
1594
+ return {
1595
+ requestId,
1596
+ sinceMs,
1597
+ sessionKey: parseOptionalTrimmedString(msg.sessionKey) || null,
1598
+ };
1599
+ }
1600
+
1492
1601
  const ATTACHMENT_MAX_DECODED_BYTES = 5_000_000;
1493
1602
  const ATTACHMENT_MAX_ENCODED_CHARS =
1494
1603
  Math.ceil((ATTACHMENT_MAX_DECODED_BYTES * 4) / 3) + 16;
@@ -2591,6 +2700,63 @@ function createDownstreamHandler(opts) {
2591
2700
  }
2592
2701
  }
2593
2702
 
2703
+ function handleReadinessProbe(clientId, msg) {
2704
+ if (!onReadinessProbe) {
2705
+ return { unicast: formatError("readiness probe is not available") };
2706
+ }
2707
+
2708
+ let payload;
2709
+ try {
2710
+ payload = parseReadinessProbe(msg);
2711
+ } catch (err) {
2712
+ return { unicast: formatError(err.message) };
2713
+ }
2714
+
2715
+ const finalize = (result) => {
2716
+ const resolved = result || {};
2717
+ const requestId = resolved.requestId || payload.requestId;
2718
+ if (
2719
+ resolved.ok === false ||
2720
+ !resolved.targetClientId ||
2721
+ !resolved.probe
2722
+ ) {
2723
+ return {
2724
+ unicast: formatReadinessProbeAck({
2725
+ ok: false,
2726
+ requestId,
2727
+ reasonCode: resolved.reasonCode || null,
2728
+ message: resolved.message || "readiness probe was not dispatched",
2729
+ activeSessionKey: resolved.activeSessionKey || null,
2730
+ emittedAtMs: resolved.emittedAtMs || null,
2731
+ }),
2732
+ };
2733
+ }
2734
+
2735
+ return {
2736
+ readinessProbe: {
2737
+ requestId,
2738
+ targetClientId: resolved.targetClientId,
2739
+ message: formatReadinessProbeRequest(resolved.probe),
2740
+ },
2741
+ };
2742
+ };
2743
+
2744
+ try {
2745
+ const result = onReadinessProbe(clientId, payload);
2746
+ if (result && typeof result.then === "function") {
2747
+ return result.then(
2748
+ (resolved) => finalize(resolved),
2749
+ (err) => ({
2750
+ unicast: formatError(err.message || "readiness probe failed"),
2751
+ }),
2752
+ );
2753
+ }
2754
+ return finalize(result);
2755
+ } catch (err) {
2756
+ return { unicast: formatError(err.message || "readiness probe failed") };
2757
+ }
2758
+ }
2759
+
2594
2760
  // --- Public API ---
2595
2761
 
2596
2762
  return {
@@ -2681,6 +2847,8 @@ function createDownstreamHandler(opts) {
2681
2847
  return handleDebugDump(clientId, msg);
2682
2848
  case "remote-control":
2683
2849
  return handleRemoteControl(clientId, msg);
2850
+ case APP_PROTOCOL.readinessProbeRequest:
2851
+ return handleReadinessProbe(clientId, msg);
2684
2852
  case APP_PROTOCOL.debugEvent:
2685
2853
  return handleEventDebug(clientId, msg);
2686
2854
  default:
@@ -2721,6 +2889,8 @@ function createDownstreamHandler(opts) {
2721
2889
  formatDebugConfigSnapshot,
2722
2890
  formatRemoteControl,
2723
2891
  formatRemoteControlAck,
2892
+ formatReadinessProbeRequest,
2893
+ formatReadinessProbeAck,
2724
2894
  formatError,
2725
2895
 
2726
2896
  /**
@@ -97,6 +97,10 @@ function createDownstreamServer(opts) {
97
97
  const syncState = new Map();
98
98
  /** @type {Map<string, {visibilityState: "hidden"|"visible"|"blurred"|null, streamChars: number|null, lastHeartbeatAtMs: number|null, lastRelayStreamingActivityAtMs: number|null, interactionStage: "idle"|"listening"|"voice_handoff"|"thinking"|"streaming"|"post_turn_drain", cadenceBucket: "idle"|"active_non_stream"|"active_stream", nudgeActive: boolean, nudgeIntervalMs: number|null, nudgeStartedAtMs: number|null, lastNudgeAtMs: number|null, stalledHeartbeatCount: number, nudgeTimer: any, idleDeactivateTimer: any, staleHeartbeatTimer: any, hardTimeoutTimer: any}>} */
99
99
  const clientNudgeState = new Map();
100
+ /** @type {Map<string, {clientDebugEnabled: boolean|null, runtimeDiagnosticsVisible: boolean|null, perfNoisyDebugMuted: boolean|null, perfPayloadLiteMode: boolean|null, activeSessionKey: string|null, bundleIdentity: {kind: string|null, mode: string|null, host: string|null, port: number|null, servedDistPath: string|null}|null, emittedAtMs: number|null, updatedAtMs: number}>} */
101
+ const clientReadinessSnapshotState = new Map();
102
+ /** @type {Map<string, {requesterClientId: string, targetClientId: string, createdAtMs: number}>} */
103
+ const pendingReadinessProbeRequests = new Map();
100
104
  /** @type {Map<string, string>} */
101
105
  const unresolvedApprovals = new Map();
102
106
  let nextClientId = 1;
@@ -105,6 +109,9 @@ function createDownstreamServer(opts) {
105
109
  approvalResolved: "ocuclaw.approval.resolved",
106
110
  debugConfigSnapshot: "ocuclaw.debug.config.snapshot",
107
111
  pages: "ocuclaw.view.pages.snapshot",
112
+ readinessProbeAck: "ocuclaw.readiness.probe.ack",
113
+ readinessProbeRequest: "ocuclaw.readiness.probe.request",
114
+ readinessSnapshot: "ocuclaw.readiness.snapshot",
108
115
  resume: "ocuclaw.sync.resume",
109
116
  resumeAck: "ocuclaw.sync.resume.ack",
110
117
  status: "ocuclaw.runtime.status",
@@ -174,6 +181,20 @@ function createDownstreamServer(opts) {
174
181
  return normalized === "true" || normalized === "1" || normalized === "yes";
175
182
  }
176
183
 
184
+ function parseOptionalBoolean(value) {
185
+ if (value === true || value === false) return value;
186
+ if (typeof value === "number") return value !== 0;
187
+ if (typeof value !== "string") return null;
188
+ const normalized = value.trim().toLowerCase();
189
+ if (normalized === "true" || normalized === "1" || normalized === "yes") {
190
+ return true;
191
+ }
192
+ if (normalized === "false" || normalized === "0" || normalized === "no") {
193
+ return false;
194
+ }
195
+ return null;
196
+ }
197
+
177
198
  function parseOptionalTrimmedString(value) {
178
199
  if (typeof value !== "string") return null;
179
200
  const trimmed = value.trim();
@@ -262,6 +283,90 @@ function createDownstreamServer(opts) {
262
283
  parseOptionalTrimmedString(value.clientId),
263
284
  clientVersion: parseOptionalTrimmedString(value.clientVersion),
264
285
  sessionKey: parseOptionalTrimmedString(value.sessionKey),
286
+ readinessSnapshot: parseReadinessSnapshot(value.readinessSnapshot),
287
+ };
288
+ }
289
+
290
+ function parseReadinessBundleIdentity(value) {
291
+ if (!value || typeof value !== "object") {
292
+ return null;
293
+ }
294
+ const kind = parseOptionalTrimmedString(value.kind || value.lane);
295
+ const mode = parseOptionalTrimmedString(value.mode);
296
+ const host = parseOptionalTrimmedString(value.host);
297
+ const port = parseOptionalNonNegativeInt(value.port);
298
+ const servedDistPath =
299
+ parseOptionalTrimmedString(value.servedDistPath) ||
300
+ parseOptionalTrimmedString(value.staticDir);
301
+ if (!kind && !mode && !host && port === null && !servedDistPath) {
302
+ return null;
303
+ }
304
+ return {
305
+ kind: kind || null,
306
+ mode: mode || null,
307
+ host: host || null,
308
+ port,
309
+ servedDistPath: servedDistPath || null,
310
+ };
311
+ }
312
+
313
+ function parseReadinessSnapshot(value) {
314
+ if (!value || typeof value !== "object") {
315
+ return null;
316
+ }
317
+ const clientDebugEnabled = parseOptionalBoolean(value.clientDebugEnabled);
318
+ const runtimeDiagnosticsVisible = parseOptionalBoolean(
319
+ value.runtimeDiagnosticsVisible,
320
+ );
321
+ const perfNoisyDebugMuted = parseOptionalBoolean(value.perfNoisyDebugMuted);
322
+ const perfPayloadLiteMode = parseOptionalBoolean(value.perfPayloadLiteMode);
323
+ const activeSessionKey =
324
+ parseOptionalTrimmedString(value.activeSessionKey) ||
325
+ parseOptionalTrimmedString(value.sessionKey);
326
+ const bundleIdentity = parseReadinessBundleIdentity(
327
+ value.bundleIdentity || value.bundle,
328
+ );
329
+ const emittedAtMs = parseOptionalNonNegativeInt(value.emittedAtMs);
330
+ if (
331
+ clientDebugEnabled === null &&
332
+ runtimeDiagnosticsVisible === null &&
333
+ perfNoisyDebugMuted === null &&
334
+ perfPayloadLiteMode === null &&
335
+ !activeSessionKey &&
336
+ !bundleIdentity &&
337
+ emittedAtMs === null
338
+ ) {
339
+ return null;
340
+ }
341
+ return {
342
+ clientDebugEnabled,
343
+ runtimeDiagnosticsVisible,
344
+ perfNoisyDebugMuted,
345
+ perfPayloadLiteMode,
346
+ activeSessionKey: activeSessionKey || null,
347
+ bundleIdentity,
348
+ emittedAtMs,
349
+ updatedAtMs: Date.now(),
350
+ };
351
+ }
352
+
353
+ function parseReadinessProbeAck(value) {
354
+ if (!value || value.type !== APP_PROTOCOL.readinessProbeAck) {
355
+ return null;
356
+ }
357
+ const requestId = parseOptionalTrimmedString(value.requestId);
358
+ if (!requestId) {
359
+ return null;
360
+ }
361
+ return {
362
+ requestId,
363
+ ok: value.ok !== false,
364
+ reasonCode: parseOptionalTrimmedString(value.reasonCode),
365
+ message: parseOptionalTrimmedString(value.message),
366
+ activeSessionKey:
367
+ parseOptionalTrimmedString(value.activeSessionKey) ||
368
+ parseOptionalTrimmedString(value.sessionKey),
369
+ emittedAtMs: parseOptionalNonNegativeInt(value.emittedAtMs),
265
370
  };
266
371
  }
267
372
 
@@ -449,6 +554,81 @@ function createDownstreamServer(opts) {
449
554
  };
450
555
  }
451
556
 
557
+ function cloneReadinessBundleIdentity(value) {
558
+ if (!value) return null;
559
+ return {
560
+ kind: value.kind || null,
561
+ mode: value.mode || null,
562
+ host: value.host || null,
563
+ port: Number.isFinite(value.port) ? value.port : null,
564
+ servedDistPath: value.servedDistPath || null,
565
+ };
566
+ }
567
+
568
+ function cloneReadinessSnapshot(value) {
569
+ if (!value) return null;
570
+ return {
571
+ clientDebugEnabled:
572
+ value.clientDebugEnabled === true || value.clientDebugEnabled === false
573
+ ? value.clientDebugEnabled
574
+ : null,
575
+ runtimeDiagnosticsVisible:
576
+ value.runtimeDiagnosticsVisible === true ||
577
+ value.runtimeDiagnosticsVisible === false
578
+ ? value.runtimeDiagnosticsVisible
579
+ : null,
580
+ perfNoisyDebugMuted:
581
+ value.perfNoisyDebugMuted === true || value.perfNoisyDebugMuted === false
582
+ ? value.perfNoisyDebugMuted
583
+ : null,
584
+ perfPayloadLiteMode:
585
+ value.perfPayloadLiteMode === true || value.perfPayloadLiteMode === false
586
+ ? value.perfPayloadLiteMode
587
+ : null,
588
+ activeSessionKey: value.activeSessionKey || null,
589
+ bundleIdentity: cloneReadinessBundleIdentity(value.bundleIdentity),
590
+ emittedAtMs: Number.isFinite(value.emittedAtMs) ? value.emittedAtMs : null,
591
+ updatedAtMs: Number.isFinite(value.updatedAtMs) ? value.updatedAtMs : null,
592
+ };
593
+ }
594
+
595
+ function updateClientReadinessSnapshot(clientId, snapshot) {
596
+ if (!clientId || !snapshot) return null;
597
+ const next = cloneReadinessSnapshot({
598
+ ...snapshot,
599
+ updatedAtMs: Date.now(),
600
+ });
601
+ clientReadinessSnapshotState.set(clientId, next);
602
+ return next;
603
+ }
604
+
605
+ function clearPendingReadinessProbesForClient(clientId) {
606
+ if (!clientId) return;
607
+ for (const [requestId, pending] of pendingReadinessProbeRequests) {
608
+ if (!pending) continue;
609
+ if (
610
+ pending.requesterClientId === clientId ||
611
+ pending.targetClientId === clientId
612
+ ) {
613
+ pendingReadinessProbeRequests.delete(requestId);
614
+ }
615
+ }
616
+ }
617
+
618
+ function buildReadinessClientEntry(clientId) {
619
+ const protocol = protocolState.get(clientId);
620
+ return {
621
+ clientId,
622
+ clientKind: classifyClientKind(protocol),
623
+ clientName: protocol && protocol.clientName ? protocol.clientName : null,
624
+ clientVersion: protocol && protocol.clientVersion ? protocol.clientVersion : null,
625
+ protocolSessionKey: protocol && protocol.sessionKey ? protocol.sessionKey : null,
626
+ readinessSnapshot: cloneReadinessSnapshot(
627
+ clientReadinessSnapshotState.get(clientId) || null,
628
+ ),
629
+ };
630
+ }
631
+
452
632
  function clearClientNudgeTimer(state, key, clearFn = clearTimeout) {
453
633
  if (!state || !state[key]) return;
454
634
  clearFn(state[key]);
@@ -888,6 +1068,50 @@ function createDownstreamServer(opts) {
888
1068
  ws.send(message);
889
1069
  }
890
1070
 
1071
+ function forwardReadinessProbeAck(clientId, ack) {
1072
+ if (!ack || !ack.requestId) {
1073
+ return;
1074
+ }
1075
+ const pending = pendingReadinessProbeRequests.get(ack.requestId);
1076
+ if (!pending || pending.targetClientId !== clientId) {
1077
+ return;
1078
+ }
1079
+ pendingReadinessProbeRequests.delete(ack.requestId);
1080
+ const requesterWs = clients.get(pending.requesterClientId);
1081
+ if (!requesterWs || requesterWs.readyState !== WebSocket.OPEN) {
1082
+ return;
1083
+ }
1084
+ const protocol = protocolState.get(clientId);
1085
+ const message =
1086
+ handler && typeof handler.formatReadinessProbeAck === "function"
1087
+ ? handler.formatReadinessProbeAck({
1088
+ ok: ack.ok !== false,
1089
+ requestId: ack.requestId,
1090
+ reasonCode: ack.reasonCode || null,
1091
+ message: ack.message || null,
1092
+ activeSessionKey: ack.activeSessionKey || null,
1093
+ emittedAtMs: ack.emittedAtMs,
1094
+ clientId,
1095
+ clientName: protocol && protocol.clientName ? protocol.clientName : null,
1096
+ clientVersion:
1097
+ protocol && protocol.clientVersion ? protocol.clientVersion : null,
1098
+ })
1099
+ : JSON.stringify({
1100
+ type: APP_PROTOCOL.readinessProbeAck,
1101
+ ok: ack.ok !== false,
1102
+ requestId: ack.requestId,
1103
+ reasonCode: ack.reasonCode || null,
1104
+ message: ack.message || null,
1105
+ activeSessionKey: ack.activeSessionKey || null,
1106
+ emittedAtMs: ack.emittedAtMs,
1107
+ clientId,
1108
+ clientName: protocol && protocol.clientName ? protocol.clientName : null,
1109
+ clientVersion:
1110
+ protocol && protocol.clientVersion ? protocol.clientVersion : null,
1111
+ });
1112
+ sendMessageToClient(pending.requesterClientId, requesterWs, message);
1113
+ }
1114
+
891
1115
  function getServerResumeState() {
892
1116
  if (!getCurrentResumeState) {
893
1117
  return { pagesRevision: null, statusRevision: null };
@@ -1191,6 +1415,9 @@ function createDownstreamServer(opts) {
1191
1415
  clientVersion: protocolHello.clientVersion,
1192
1416
  sessionKey: protocolHello.sessionKey,
1193
1417
  });
1418
+ if (protocolHello.readinessSnapshot) {
1419
+ updateClientReadinessSnapshot(clientId, protocolHello.readinessSnapshot);
1420
+ }
1194
1421
  logger.info(
1195
1422
  `[downstream] ${clientId} identified protocol=${state.selectedVersion || "n/a"} client=${describeProtocolClient(state)} kind=${classifyClientKind(state)} session=${state.sessionKey || "n/a"}`,
1196
1423
  );
@@ -1244,6 +1471,21 @@ function createDownstreamServer(opts) {
1244
1471
  return;
1245
1472
  }
1246
1473
 
1474
+ if (parsed && parsed.type === APP_PROTOCOL.readinessSnapshot) {
1475
+ if (classifyClientKind(state) === "app") {
1476
+ updateClientReadinessSnapshot(clientId, parsed);
1477
+ }
1478
+ return;
1479
+ }
1480
+
1481
+ const readinessProbeAck = parseReadinessProbeAck(parsed);
1482
+ if (readinessProbeAck) {
1483
+ if (classifyClientKind(state) === "app") {
1484
+ forwardReadinessProbeAck(clientId, readinessProbeAck);
1485
+ }
1486
+ return;
1487
+ }
1488
+
1247
1489
  if (parsed && parsed.type === APP_PROTOCOL.resume) {
1248
1490
  const sync = syncState.get(clientId);
1249
1491
  if (sync) sync.resumeReceived = true;
@@ -1307,6 +1549,8 @@ function createDownstreamServer(opts) {
1307
1549
  const protocol = protocolState.get(clientId);
1308
1550
  syncState.delete(clientId);
1309
1551
  protocolState.delete(clientId);
1552
+ clientReadinessSnapshotState.delete(clientId);
1553
+ clearPendingReadinessProbesForClient(clientId);
1310
1554
  clearClientNudgeTimers(clientId);
1311
1555
  clientNudgeState.delete(clientId);
1312
1556
  handler.removeClient(clientId);
@@ -1379,6 +1623,54 @@ function createDownstreamServer(opts) {
1379
1623
  }
1380
1624
  }
1381
1625
 
1626
+ if (result.readinessProbe) {
1627
+ const requestId = parseOptionalTrimmedString(
1628
+ result.readinessProbe.requestId,
1629
+ );
1630
+ const targetClientId = parseOptionalTrimmedString(
1631
+ result.readinessProbe.targetClientId,
1632
+ );
1633
+ const message =
1634
+ typeof result.readinessProbe.message === "string"
1635
+ ? result.readinessProbe.message
1636
+ : null;
1637
+ const targetWs = targetClientId ? clients.get(targetClientId) : null;
1638
+ if (
1639
+ !requestId ||
1640
+ !targetClientId ||
1641
+ !message ||
1642
+ !targetWs ||
1643
+ targetWs.readyState !== WebSocket.OPEN ||
1644
+ !isAppClient(targetClientId)
1645
+ ) {
1646
+ const requesterWs = clients.get(senderId);
1647
+ if (
1648
+ requesterWs &&
1649
+ requesterWs.readyState === WebSocket.OPEN &&
1650
+ handler &&
1651
+ typeof handler.formatReadinessProbeAck === "function"
1652
+ ) {
1653
+ sendMessageToClient(
1654
+ senderId,
1655
+ requesterWs,
1656
+ handler.formatReadinessProbeAck({
1657
+ ok: false,
1658
+ requestId,
1659
+ reasonCode: "no_downstream_client",
1660
+ message: "No downstream app client connected",
1661
+ }),
1662
+ );
1663
+ }
1664
+ } else {
1665
+ pendingReadinessProbeRequests.set(requestId, {
1666
+ requesterClientId: senderId,
1667
+ targetClientId,
1668
+ createdAtMs: Date.now(),
1669
+ });
1670
+ sendMessageToClient(targetClientId, targetWs, message);
1671
+ }
1672
+ }
1673
+
1382
1674
  if (result.broadcast) {
1383
1675
  if (Array.isArray(result.broadcast)) {
1384
1676
  for (const msg of result.broadcast) broadcast(msg);
@@ -1519,6 +1811,37 @@ function createDownstreamServer(opts) {
1519
1811
  return countConnectedAppClients(excludeClientId);
1520
1812
  }
1521
1813
 
1814
+ function getReadinessSnapshot() {
1815
+ const clientsOut = [];
1816
+ let latestUpdatedAtMs = null;
1817
+ for (const [clientId, ws] of clients) {
1818
+ if (!ws || ws.readyState !== WebSocket.OPEN) {
1819
+ continue;
1820
+ }
1821
+ if (!isAppClient(clientId)) {
1822
+ continue;
1823
+ }
1824
+ const entry = buildReadinessClientEntry(clientId);
1825
+ clientsOut.push(entry);
1826
+ const updatedAtMs =
1827
+ entry &&
1828
+ entry.readinessSnapshot &&
1829
+ Number.isFinite(entry.readinessSnapshot.updatedAtMs)
1830
+ ? entry.readinessSnapshot.updatedAtMs
1831
+ : null;
1832
+ if (updatedAtMs !== null && (latestUpdatedAtMs === null || updatedAtMs > latestUpdatedAtMs)) {
1833
+ latestUpdatedAtMs = updatedAtMs;
1834
+ }
1835
+ }
1836
+ clientsOut.sort((left, right) => String(left.clientId).localeCompare(String(right.clientId)));
1837
+ return {
1838
+ connectedClientCount: clientsOut.length,
1839
+ fanoutRecipientCount: clientsOut.length,
1840
+ updatedAtMs: latestUpdatedAtMs,
1841
+ clients: clientsOut,
1842
+ };
1843
+ }
1844
+
1522
1845
  /**
1523
1846
  * Shut down the WebSocket server and disconnect all clients.
1524
1847
  *
@@ -1534,6 +1857,8 @@ function createDownstreamServer(opts) {
1534
1857
  clearClientNudgeTimers(clientId);
1535
1858
  }
1536
1859
  clientNudgeState.clear();
1860
+ clientReadinessSnapshotState.clear();
1861
+ pendingReadinessProbeRequests.clear();
1537
1862
  for (const [, ws] of clients) {
1538
1863
  ws.close();
1539
1864
  }
@@ -1547,6 +1872,7 @@ function createDownstreamServer(opts) {
1547
1872
  unicast,
1548
1873
  getClientIds,
1549
1874
  getConnectedAppCount,
1875
+ getReadinessSnapshot,
1550
1876
  closeConnectedAppClients,
1551
1877
  getClientNudgeState(clientId) {
1552
1878
  return cloneClientNudgeState(clientNudgeState.get(clientId) || null);
@@ -371,8 +371,9 @@ function createRelay(opts) {
371
371
  * @param {object} context
372
372
  * @param {() => object} buildData
373
373
  */
374
- function emitDebug(cat, event, severity, context, buildData) {
375
- if (!debugStore.isEnabled(cat)) return;
374
+ function emitDebug(cat, event, severity, context, buildData, options) {
375
+ const force = !!(options && options.force === true);
376
+ if (!force && !debugStore.isEnabled(cat)) return;
376
377
 
377
378
  let data = {};
378
379
  if (typeof buildData === "function") {
@@ -394,7 +395,15 @@ function createRelay(opts) {
394
395
  if (context && context.runId) payload.runId = context.runId;
395
396
  if (context && context.screen) payload.screen = context.screen;
396
397
 
397
- debugStore.emit(payload);
398
+ debugStore.emit(payload, { force });
399
+ }
400
+
401
+ function isForcedReadinessProofEvent(payload) {
402
+ return !!(
403
+ payload &&
404
+ payload.cat === "app.lifecycle" &&
405
+ payload.event === "readiness_probe_received"
406
+ );
398
407
  }
399
408
 
400
409
  function scheduleSimulateStreamTimer(delayMs, callback) {
@@ -879,6 +888,20 @@ function createRelay(opts) {
879
888
  getAgentName() {
880
889
  return upstreamRuntime ? upstreamRuntime.getAgentName() : null;
881
890
  },
891
+ isPinnedFirstUserMessageKey(sessionKey) {
892
+ const normalizedSessionKey = normalizeEvenAiSessionKeyForLookup(sessionKey);
893
+ if (!normalizedSessionKey) {
894
+ return false;
895
+ }
896
+ const trackedThrowawayKeys =
897
+ typeof evenAiSettingsStore.getTrackedThrowawayKeys === "function"
898
+ ? evenAiSettingsStore.getTrackedThrowawayKeys()
899
+ : [];
900
+ return dedupeNormalizedSessionKeys(trackedThrowawayKeys).some(
901
+ (trackedKey) =>
902
+ trackedKey.toLowerCase() === normalizedSessionKey.toLowerCase(),
903
+ );
904
+ },
882
905
  onSessionStateReset: resetActivityStatusAdapter,
883
906
  onPagesChanged: cachePages,
884
907
  onStatusChanged: broadcastStatus,
@@ -1324,7 +1347,7 @@ function createRelay(opts) {
1324
1347
  },
1325
1348
 
1326
1349
  onGetStatus() {
1327
- return buildStatusObject();
1350
+ return buildStatusObject({ includeDownstreamReadiness: true });
1328
1351
  },
1329
1352
 
1330
1353
  onGetSessionModelConfig() {
@@ -1459,7 +1482,8 @@ function createRelay(opts) {
1459
1482
  onEventDebug(clientId, payload) {
1460
1483
  if (!payload || typeof payload !== "object") return;
1461
1484
  const cat = payload.cat;
1462
- if (!debugStore.isEnabled(cat)) return;
1485
+ const forceStore = isForcedReadinessProofEvent(payload);
1486
+ if (!forceStore && !debugStore.isEnabled(cat)) return;
1463
1487
  emitDebug(
1464
1488
  cat,
1465
1489
  payload.event,
@@ -1473,6 +1497,7 @@ function createRelay(opts) {
1473
1497
  clientId,
1474
1498
  ...(payload.data || {}),
1475
1499
  }),
1500
+ { force: forceStore },
1476
1501
  );
1477
1502
  },
1478
1503
 
@@ -1637,6 +1662,110 @@ function createRelay(opts) {
1637
1662
  control,
1638
1663
  };
1639
1664
  },
1665
+
1666
+ onReadinessProbe(clientId, request) {
1667
+ const now = Date.now();
1668
+ const requestId =
1669
+ (typeof request.requestId === "string" && request.requestId.trim()) ||
1670
+ `readiness-${now}-${Math.random().toString(16).slice(2, 8)}`;
1671
+ const sinceMs = Number.isFinite(Number(request && request.sinceMs))
1672
+ ? Math.max(0, Math.floor(Number(request.sinceMs)))
1673
+ : now;
1674
+ const snapshot =
1675
+ server && typeof server.getReadinessSnapshot === "function"
1676
+ ? server.getReadinessSnapshot()
1677
+ : {
1678
+ connectedClientCount: 0,
1679
+ fanoutRecipientCount: 0,
1680
+ clients: [],
1681
+ };
1682
+ const targetClientId =
1683
+ snapshot &&
1684
+ snapshot.connectedClientCount === 1 &&
1685
+ snapshot.fanoutRecipientCount === 1 &&
1686
+ Array.isArray(snapshot.clients) &&
1687
+ snapshot.clients.length === 1 &&
1688
+ typeof snapshot.clients[0].clientId === "string"
1689
+ ? snapshot.clients[0].clientId
1690
+ : null;
1691
+
1692
+ emitDebug(
1693
+ "relay.protocol",
1694
+ "readiness_probe_requested",
1695
+ "info",
1696
+ { sessionKey: sessionService.ensureSessionKey() },
1697
+ () => ({
1698
+ clientId,
1699
+ requestId,
1700
+ sinceMs,
1701
+ requestedSessionKey:
1702
+ typeof request.sessionKey === "string" && request.sessionKey.trim()
1703
+ ? request.sessionKey.trim()
1704
+ : null,
1705
+ connectedClientCount:
1706
+ snapshot && Number.isFinite(snapshot.connectedClientCount)
1707
+ ? snapshot.connectedClientCount
1708
+ : 0,
1709
+ fanoutRecipientCount:
1710
+ snapshot && Number.isFinite(snapshot.fanoutRecipientCount)
1711
+ ? snapshot.fanoutRecipientCount
1712
+ : 0,
1713
+ }),
1714
+ );
1715
+
1716
+ if (
1717
+ !snapshot ||
1718
+ snapshot.connectedClientCount <= 0 ||
1719
+ snapshot.fanoutRecipientCount <= 0
1720
+ ) {
1721
+ return {
1722
+ ok: false,
1723
+ requestId,
1724
+ reasonCode: "no_downstream_client",
1725
+ message: "No downstream app clients connected",
1726
+ };
1727
+ }
1728
+
1729
+ if (
1730
+ snapshot.connectedClientCount > 1 ||
1731
+ snapshot.fanoutRecipientCount > 1 ||
1732
+ !targetClientId
1733
+ ) {
1734
+ return {
1735
+ ok: false,
1736
+ requestId,
1737
+ reasonCode: "multi_recipient_fanout",
1738
+ message: "Multiple downstream app clients connected",
1739
+ };
1740
+ }
1741
+
1742
+ emitDebug(
1743
+ "relay.protocol",
1744
+ "readiness_probe_dispatched",
1745
+ "info",
1746
+ { sessionKey: sessionService.ensureSessionKey() },
1747
+ () => ({
1748
+ clientId,
1749
+ requestId,
1750
+ targetClientId,
1751
+ sinceMs,
1752
+ }),
1753
+ );
1754
+
1755
+ return {
1756
+ ok: true,
1757
+ requestId,
1758
+ targetClientId,
1759
+ probe: {
1760
+ requestId,
1761
+ sinceMs,
1762
+ sessionKey:
1763
+ typeof request.sessionKey === "string" && request.sessionKey.trim()
1764
+ ? request.sessionKey.trim()
1765
+ : null,
1766
+ },
1767
+ };
1768
+ },
1640
1769
  });
1641
1770
 
1642
1771
  // --- Downstream server ---
@@ -1739,8 +1868,9 @@ function createRelay(opts) {
1739
1868
 
1740
1869
  // --- Helpers ---
1741
1870
 
1742
- function buildStatusObject() {
1743
- return {
1871
+ function buildStatusObject(options = {}) {
1872
+ const includeDownstreamReadiness = options.includeDownstreamReadiness === true;
1873
+ const status = {
1744
1874
  openclaw:
1745
1875
  upstreamRuntime && upstreamRuntime.isConnected()
1746
1876
  ? "connected"
@@ -1749,6 +1879,18 @@ function createRelay(opts) {
1749
1879
  session: sessionService.ensureSessionKey(),
1750
1880
  evenAiEnabled: opts.evenAiEnabled === true,
1751
1881
  };
1882
+ if (includeDownstreamReadiness) {
1883
+ status.downstreamReadiness =
1884
+ server && typeof server.getReadinessSnapshot === "function"
1885
+ ? server.getReadinessSnapshot()
1886
+ : {
1887
+ connectedClientCount: 0,
1888
+ fanoutRecipientCount: 0,
1889
+ updatedAtMs: null,
1890
+ clients: [],
1891
+ };
1892
+ }
1893
+ return status;
1752
1894
  }
1753
1895
 
1754
1896
  function cachePages(pages) {
@@ -53,6 +53,10 @@ export function createSessionService(opts = {}) {
53
53
  typeof opts.onSessionModelConfig === "function"
54
54
  ? opts.onSessionModelConfig
55
55
  : null;
56
+ const isPinnedFirstUserMessageKey =
57
+ typeof opts.isPinnedFirstUserMessageKey === "function"
58
+ ? opts.isPinnedFirstUserMessageKey
59
+ : null;
56
60
 
57
61
  /** Current session key. Generated on first use. */
58
62
  let currentSessionKey = null;
@@ -390,7 +394,7 @@ export function createSessionService(opts = {}) {
390
394
  }
391
395
  const request = { key: canonicalKey };
392
396
  if (patch && typeof patch.modelRef === "string") {
393
- request.model = patch.modelRef;
397
+ request.model = patch.modelRef.trim() ? patch.modelRef : null;
394
398
  }
395
399
  if (patch && Object.prototype.hasOwnProperty.call(patch, "thinkingLevel")) {
396
400
  request.thinkingLevel =
@@ -717,11 +721,7 @@ export function createSessionService(opts = {}) {
717
721
  if (!sessionKey || !normalized) continue;
718
722
  out.set(sessionKey, normalized);
719
723
  }
720
- while (out.size > firstUserMessageCacheLimit) {
721
- const oldestKey = out.keys().next().value;
722
- if (oldestKey === undefined) break;
723
- out.delete(oldestKey);
724
- }
724
+ pruneFirstUserMessageEntries(out);
725
725
  return out;
726
726
  } catch {
727
727
  return new Map();
@@ -756,11 +756,7 @@ export function createSessionService(opts = {}) {
756
756
  }
757
757
 
758
758
  function pruneFirstSentUserMessageCache() {
759
- while (firstSentUserMessageBySession.size > firstUserMessageCacheLimit) {
760
- const oldestKey = firstSentUserMessageBySession.keys().next().value;
761
- if (oldestKey === undefined) break;
762
- firstSentUserMessageBySession.delete(oldestKey);
763
- }
759
+ pruneFirstUserMessageEntries(firstSentUserMessageBySession);
764
760
  }
765
761
 
766
762
  function recordFirstSentUserMessage(sessionKey, text) {
@@ -810,10 +806,41 @@ export function createSessionService(opts = {}) {
810
806
  }
811
807
 
812
808
  function pruneFirstUserMessageCache() {
813
- while (firstUserMessageCache.size > firstUserMessageCacheLimit) {
814
- const oldestKey = firstUserMessageCache.keys().next().value;
815
- if (oldestKey === undefined) break;
816
- firstUserMessageCache.delete(oldestKey);
809
+ pruneFirstUserMessageEntries(firstUserMessageCache);
810
+ }
811
+
812
+ function shouldPinFirstUserMessageKey(sessionKey) {
813
+ if (!isPinnedFirstUserMessageKey || typeof sessionKey !== "string") {
814
+ return false;
815
+ }
816
+ const normalizedKey = sessionKey.trim();
817
+ if (!normalizedKey) {
818
+ return false;
819
+ }
820
+ try {
821
+ return isPinnedFirstUserMessageKey(normalizedKey) === true;
822
+ } catch (err) {
823
+ logger.warn(
824
+ `[relay] first-user cache pin callback failed for ${normalizedKey}: ${err && err.message ? err.message : err}`,
825
+ );
826
+ return false;
827
+ }
828
+ }
829
+
830
+ function pruneFirstUserMessageEntries(cache) {
831
+ while (cache.size > firstUserMessageCacheLimit) {
832
+ let evicted = false;
833
+ for (const sessionKey of cache.keys()) {
834
+ if (shouldPinFirstUserMessageKey(sessionKey)) {
835
+ continue;
836
+ }
837
+ cache.delete(sessionKey);
838
+ evicted = true;
839
+ break;
840
+ }
841
+ if (!evicted) {
842
+ break;
843
+ }
817
844
  }
818
845
  }
819
846
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ocuclaw",
3
- "version": "0.1.0",
3
+ "version": "1.2.4",
4
4
  "description": "OcuClaw for Even G2 smart glasses, powered by OpenClaw.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",