ocuclaw 1.3.1 → 1.3.2

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 (37) hide show
  1. package/dist/config/runtime-config-session-title-model.test.js +22 -0
  2. package/dist/config/runtime-config.js +7 -1
  3. package/dist/domain/glasses-display-system-prompt.js +52 -0
  4. package/dist/domain/glasses-display-system-prompt.test.js +44 -0
  5. package/dist/domain/glasses-ui-system-prompt.js +6 -22
  6. package/dist/domain/glasses-ui-system-prompt.test.js +13 -0
  7. package/dist/domain/prompt-channel-fragments.js +32 -0
  8. package/dist/domain/prompt-channel-fragments.test.js +70 -0
  9. package/dist/gateway/gateway-timing-ledger.js +15 -3
  10. package/dist/gateway/openclaw-client.js +80 -3
  11. package/dist/index.js +22 -0
  12. package/dist/runtime/channel-two-hook.js +36 -0
  13. package/dist/runtime/container-env.js +41 -0
  14. package/dist/runtime/display-toggle-states.js +98 -0
  15. package/dist/runtime/register-session-title-distiller.js +100 -0
  16. package/dist/runtime/relay-core.js +209 -33
  17. package/dist/runtime/relay-service.js +120 -13
  18. package/dist/runtime/relay-worker-entry.js +26 -0
  19. package/dist/runtime/relay-worker-supervisor.js +43 -2
  20. package/dist/runtime/relay-worker-transport.js +41 -0
  21. package/dist/runtime/session-service.js +136 -12
  22. package/dist/runtime/session-title-distiller-budget.js +36 -0
  23. package/dist/runtime/session-title-distiller-helpers.js +130 -0
  24. package/dist/runtime/session-title-distiller.js +354 -0
  25. package/dist/runtime/session-title-record.js +21 -0
  26. package/dist/runtime/stable-prompt-snapshot.js +119 -0
  27. package/dist/tools/glasses-ui-cron.js +9 -3
  28. package/dist/tools/glasses-ui-paint-floor.js +10 -3
  29. package/dist/tools/glasses-ui-surfaces.js +8 -1
  30. package/dist/tools/glasses-ui-tool-description.test.js +16 -0
  31. package/dist/tools/glasses-ui-tool.js +88 -14
  32. package/dist/tools/session-title-tool.js +14 -76
  33. package/dist/tools/session-title-tool.test.js +53 -0
  34. package/dist/version.js +2 -2
  35. package/openclaw.plugin.json +9 -0
  36. package/package.json +4 -3
  37. package/skills/glasses-ui/SKILL.md +7 -0
@@ -2,15 +2,22 @@
2
2
  //
3
3
  // Governs ALL plugin->glass sends (initial RebuildPageContainer render and
4
4
  // every surface_update patch). Collapses bursts to last-write-wins per field
5
- // and emits at most one frame per paintFloorMs (default 150ms, Spike D), with
6
- // a leading-edge send + a trailing send carrying the final merged patch.
5
+ // and emits at most one frame per paintFloorMs, with a leading-edge send + a
6
+ // trailing send carrying the final merged patch.
7
7
  //
8
8
  // There is NO glass-side paint-ack: the only backpressure signal is
9
9
  // relay/BLE transport-side (see the isUnderBackpressure shed in Task 13).
10
10
  // Local fake-list textContainerUpgrade scroll-swaps are client-side and never
11
11
  // reach this coalescer.
12
+ //
13
+ // 250 is the unconditionally hardware-proven floor. Spike D approved lowering
14
+ // to 150 ONLY once the backpressure shed has a live signal; the shed's
15
+ // isGlassesSendBufferOverHighWater query is implemented nowhere yet, so the
16
+ // shed is inert and 150 would run without its safety condition. Restore 150
17
+ // when the relay-service bridge lands and is validated on hardware
18
+ // (roadmap step 4, docs/superpowers/plans/2026-06-10-glasses-ui-state-reset-and-roadmap.md).
12
19
 
13
- export const DEFAULT_PAINT_FLOOR_MS = 150;
20
+ export const DEFAULT_PAINT_FLOOR_MS = 250;
14
21
 
15
22
  export function createPaintFloorCoalescer(deps) {
16
23
  const paintFloorMs = Number.isFinite(deps.paintFloorMs) ? deps.paintFloorMs : DEFAULT_PAINT_FLOOR_MS;
@@ -236,7 +236,14 @@ export function createSurfaceStore(deps = {}) {
236
236
  // be move-independent (replace, the schema DEFAULT, must behave like
237
237
  // patch here, not silently drop a latched exit).
238
238
  const priorTop = bySurface.get(top);
239
- stopCron(top);
239
+ // SILENT stop: this is slot recycling for the incoming replace render, not
240
+ // a real outcome. A non-silent stop fires the cron's onResolve with a
241
+ // synthesized `preempted`, which (with no pending call at this instant)
242
+ // latches a bogus exit onto the prior entry — carried into makeEntry below,
243
+ // it makes the very render we are applying discard-for-exit on re-attach
244
+ // ("fresh render instantly dismissed", B7 — the real B3 contamination
245
+ // mechanism, found 2026-06-11).
246
+ stopCron(top, { silent: true });
240
247
  bySurface.set(top, makeEntry(sessionKey, params && params.kind, priorTop));
241
248
  return { mode: "replace", surfaceId: top };
242
249
  }
@@ -0,0 +1,16 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { GLASSES_UI_TOOL_DESCRIPTION } from "./glasses-ui-tool.ts";
4
+
5
+ test("description now carries the follow-up + back/selected usage rules", () => {
6
+ const d = GLASSES_UI_TOOL_DESCRIPTION;
7
+ assert.match(d, /text_surface/);
8
+ assert.match(d, /list_surface/);
9
+ assert.match(d, /list_with_details_surface/);
10
+ // Channel-3 additions (moved from the old nudge):
11
+ assert.match(d, /NEXT output|next output/);
12
+ assert.match(d, /back/i);
13
+ assert.match(d, /selected/i);
14
+ // Skill pointer retained:
15
+ assert.match(d, /glasses-ui/);
16
+ });
@@ -542,8 +542,8 @@ export function createGlassesUiToolHandler(deps) {
542
542
  // parent/chat after a back/pop, and the per-surface coalescer state doesn't
543
543
  // leak across a long push/replace session. (pauseCron on push does NOT
544
544
  // dispose — the parent resumes.)
545
- stopCron: (id) => {
546
- cronEngine.stop(id, { result: "preempted" });
545
+ stopCron: (id, opts) => {
546
+ cronEngine.stop(id, { result: "preempted" }, opts);
547
547
  paintFloor.dispose(id);
548
548
  },
549
549
  mintSurfaceId: newSurfaceId,
@@ -610,11 +610,33 @@ export function createGlassesUiToolHandler(deps) {
610
610
  refreshValidated = v.refresh;
611
611
  }
612
612
 
613
+ // params.depth is the execute-level RUN-CALL ordinal (resets at agent_end).
614
+ // It is used ONLY as the "first render of this run" signal for stale-stack
615
+ // reaping below — it must NEVER reach the wire. The wire depth is derived
616
+ // from the store's true stack depth after applyRender (B6: ordinals never
617
+ // decrement on Back, so they drift past entry counts and break both the
618
+ // plugin pop reconciliation and the client's clear-vs-append decision).
613
619
  const depth = Number.isFinite(params.depth) ? Math.max(1, Math.floor(params.depth)) : 1;
614
620
  const update =
615
621
  params.spec && (params.spec.update === "patch" || params.spec.update === "push")
616
622
  ? params.spec.update
617
623
  : "replace";
624
+ // Stale-stack reaping (B3 safety net): a depth-1 render means NEW ROOT — a
625
+ // session stack still holding PUSHED children at that moment is orphan
626
+ // residue from an earlier run (e.g. a client that bailed to chat without
627
+ // popping). Reap it before registering so a stale child can't swallow this
628
+ // render's events or forward a stale latched exit. A SINGLE root entry is
629
+ // NOT stale — that's the designed patch/replace re-attach path
630
+ // (visible_awaiting_agent), which must keep its latch/queue semantics.
631
+ if (depth <= 1 && surfaceStore.stackDepth(sessionKey) > 1) {
632
+ const stackDepthBefore = surfaceStore.stackDepth(sessionKey);
633
+ const reapedPending = reapSession(sessionKey, { result: "preempted" });
634
+ emitLifecycle("stale_stack_reaped", "warn", {
635
+ sessionKey,
636
+ stackDepthBefore,
637
+ reapedPending,
638
+ });
639
+ }
618
640
  // The plugin owns surfaceIds (spec §Core model). applyRender derives the
619
641
  // target from the session's current top: patch/replace reuse the top id
620
642
  // (re-attach in place), push mints a child + pauses the parent cron, the
@@ -646,6 +668,12 @@ export function createGlassesUiToolHandler(deps) {
646
668
  }
647
669
  }
648
670
 
671
+ // The wire depth is the TRUE stack depth (entry count) after applyRender:
672
+ // root=1, push=parent+1, replace/patch=unchanged. The client keys its
673
+ // clear-vs-append-vs-swap decision and Back classification on this value,
674
+ // and handleNavEvent's pop loop compares it against the same entry counts.
675
+ const wireDepth = Math.max(1, surfaceStore.stackDepth(sessionKey));
676
+
649
677
  // Initial render uses the agent's seed (instant). Routed through the
650
678
  // paint-floor coalescer as a leading-edge render sentinel so it shares the
651
679
  // single send chokepoint (a render supersedes any queued field patch for
@@ -653,7 +681,7 @@ export function createGlassesUiToolHandler(deps) {
653
681
  paintFloor.enqueue({
654
682
  surfaceId,
655
683
  sessionKey,
656
- patch: { __render: true, __depth: depth, __spec: validation.spec },
684
+ patch: { __render: true, __depth: wireDepth, __spec: validation.spec },
657
685
  });
658
686
 
659
687
  // Live-refresh path: kick off the cron in parallel. A `patch` onto an
@@ -776,6 +804,21 @@ export function createGlassesUiToolHandler(deps) {
776
804
  guard += 1;
777
805
  }
778
806
  }
807
+ if (
808
+ popCount === 0 &&
809
+ storeDepthBefore > 1 &&
810
+ surfaceStore.topSurfaceId(sessionKey) === ev.surfaceId
811
+ ) {
812
+ // Surface-match fallback (B6): a Back event reports the surfaceId being
813
+ // backed OUT OF — the store top. If the depth comparison said no-op
814
+ // (drifted ordinals from an older client, or any depth desync) but the
815
+ // reported surface IS the top with a parent beneath, pop exactly one
816
+ // level. Push events carry the PARENT surfaceId — never the top after a
817
+ // push — so this cannot misfire on a push report; and a duplicate Back
818
+ // delivery is idempotent (after the pop the top no longer matches).
819
+ resumedParent = surfaceStore.popBack(sessionKey);
820
+ popCount += 1;
821
+ }
779
822
  // Push (newDepth > lastDepth) is already reflected in the store by the
780
823
  // agent's push render (applyRender), so it is intentionally a no-op here.
781
824
  emitLifecycle("nav_reconcile", "debug", {
@@ -791,19 +834,25 @@ export function createGlassesUiToolHandler(deps) {
791
834
  navDepthBySession.set(sessionKey, newDepth);
792
835
  }
793
836
 
837
+ // Stop crons, resolve pending calls with `outcome`, clear the session stack.
838
+ // Resolve pending + delete entries FIRST (so pending calls settle with
839
+ // `outcome`), THEN clear the per-session stack. exit() deletes entries
840
+ // without resolving, so it must NOT run before the drain or the pending
841
+ // promises would hang. Shared by the public drainSession (agent_end /
842
+ // disconnect) and the stale-stack reap in runDynamicUi (B3).
843
+ function reapSession(sessionKey, outcome) {
844
+ cronEngine.stopAllForSession(sessionKey, outcome);
845
+ const reaped = surfaceStore.drainSession(sessionKey, outcome);
846
+ surfaceStore.exit(sessionKey); // clear the stack (crons already stopped)
847
+ navDepthBySession.delete(sessionKey); // drop stale nav depth (stack is now 0)
848
+ return reaped;
849
+ }
850
+
794
851
  return {
795
852
  runDynamicUi,
796
853
  handleNavEvent,
797
854
  drainSession(sessionKey, outcome) {
798
- cronEngine.stopAllForSession(sessionKey, outcome);
799
- // Resolve pending + delete entries FIRST (so pending calls settle with
800
- // `outcome`), THEN clear the per-session stack. exit() deletes entries
801
- // without resolving, so it must NOT run before the drain or the pending
802
- // promises would hang.
803
- const reaped = surfaceStore.drainSession(sessionKey, outcome);
804
- surfaceStore.exit(sessionKey); // clear the stack (crons already stopped)
805
- navDepthBySession.delete(sessionKey); // drop stale nav depth (stack is now 0)
806
- return reaped;
855
+ return reapSession(sessionKey, outcome);
807
856
  },
808
857
  drainAll(outcome) {
809
858
  cronEngine.stopAll(outcome);
@@ -858,6 +907,13 @@ export const GLASSES_UI_TOOL_DESCRIPTION = [
858
907
  "exit-to-chat policy, the {{path|filter}} template + per-item {label,body}",
859
908
  "reference, and worked examples (including a live system-stats",
860
909
  "list_with_details surface). Keep this description lean; depth lives in the skill.",
910
+ "",
911
+ "After the call resolves, your NEXT output decides the glasses: another",
912
+ "render_glasses_ui replaces the surface (drill-down / next step); a short text",
913
+ "reply hands the screen back to chat (the surface disappears); a silent run-end",
914
+ "leaves the surface up until the user dismisses it. A \"back\" result means the",
915
+ "user wants to revise their previous answer — re-render it or pivot; after a",
916
+ "\"selected\" result, follow up with another render or a brief one-line ack.",
861
917
  ].join("\n");
862
918
 
863
919
  // Shared per-session depth counter. OpenClaw loads the plugin's register(api)
@@ -1031,10 +1087,28 @@ export function registerGlassesUiTool(api, service) {
1031
1087
  // Reconcile client nav-events with the SINGLE store stack (Task 16). On pop
1032
1088
  // the client reports the surfaceId now back on top + the post-pop depth; the
1033
1089
  // store knows it. The relay frame carries no sessionKey, so resolve it from
1034
- // the surface's store entry (sessionForSurface), falling back to "main".
1090
+ // the surface's store entry (sessionForSurface). Every plugin-load context
1091
+ // registers one of these handlers on the SHARED relay, so each nav-event
1092
+ // fans out to N contexts but at most one context's store knows the surface —
1093
+ // a context that cannot resolve it must NO-OP. (The old "main" fallback made
1094
+ // the sibling contexts reconcile an empty store carrying stale cross-session
1095
+ // lastDepth: the 3-4x duplicate nav_reconcile on hardware, bug B2.)
1035
1096
  if (typeof service.onGlassesUiNavEvent === "function") {
1036
1097
  service.onGlassesUiNavEvent((ev) => {
1037
- const sessionKey = handler.sessionForSurface(ev.surfaceId) || "main";
1098
+ const sessionKey = handler.sessionForSurface(ev.surfaceId);
1099
+ if (!sessionKey) {
1100
+ try {
1101
+ if (typeof service.emitGlassesUiLifecycle === "function") {
1102
+ service.emitGlassesUiLifecycle("nav_event_skipped_foreign_surface", "debug", {
1103
+ evSurfaceId: ev.surfaceId,
1104
+ evDepth: ev.depth,
1105
+ });
1106
+ }
1107
+ } catch (_) {
1108
+ // observability must never break the nav path
1109
+ }
1110
+ return;
1111
+ }
1038
1112
  handler.handleNavEvent(sessionKey, ev);
1039
1113
  });
1040
1114
  }
@@ -51,21 +51,12 @@ function isEvenAiDedicatedKey(sessionKey) {
51
51
  }
52
52
 
53
53
  function gateReason(sessionKey, deps) {
54
- if (typeof deps.isSessionUserLocked === "function" && deps.isSessionUserLocked(sessionKey)) {
55
- return "session_user_locked";
56
- }
57
- if (
58
- typeof deps.isNeuralSessionNamesEnabled === "function" &&
59
- !deps.isNeuralSessionNamesEnabled(sessionKey)
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
- "Set a short title for the current chat session (shown in the user's glasses session list).",
111
+ export const TOOL_DESCRIPTION = [
112
+ "Rename the current chat session (shown in the user's glasses session list).",
120
113
  "",
121
- "When: first user turn that names any concrete topic — call eagerly. Later, only when the topic has clearly shifted. Skip greetings, acknowledgments, and no-topic messages.",
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 surrounding quotes.",
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
  }
@@ -0,0 +1,53 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { createSessionTitleToolHandler, TOOL_DESCRIPTION } from "./session-title-tool.ts";
4
+
5
+ function deps(over = {}) {
6
+ const calls = [];
7
+ return {
8
+ calls,
9
+ peekSessionKey: () => "ocuclaw:123",
10
+ setSessionTitle: (k, t, o) => { calls.push({ k, t, o }); return { ok: true }; },
11
+ ...over,
12
+ };
13
+ }
14
+
15
+ test("description is explicit-rename-only", () => {
16
+ assert.match(TOOL_DESCRIPTION, /explicitly asks to rename|user explicitly/i);
17
+ });
18
+
19
+ test("explicit rename passes origin user_tool", async () => {
20
+ const d = deps();
21
+ const h = createSessionTitleToolHandler(d);
22
+ await h.setSessionTitle({ title: "Trip Planning" });
23
+ assert.equal(d.calls[0].o.origin, "user_tool");
24
+ });
25
+
26
+ test("a user-locked session can STILL be renamed via the tool", async () => {
27
+ const d = deps({
28
+ setSessionTitle: (k, t, o) => {
29
+ // service-layer would allow user_tool over a lock; tool must not pre-block
30
+ return { ok: true };
31
+ },
32
+ });
33
+ const h = createSessionTitleToolHandler(d);
34
+ const r = await h.setSessionTitle({ title: "New Name" });
35
+ assert.deepEqual(r, { ok: true });
36
+ });
37
+
38
+ test("feature-disabled does NOT block an explicit rename", async () => {
39
+ const d = deps({ isNeuralSessionNamesEnabled: () => false });
40
+ const h = createSessionTitleToolHandler(d);
41
+ const r = await h.setSessionTitle({ title: "Anything" });
42
+ assert.deepEqual(r, { ok: true });
43
+ });
44
+
45
+ test("still rejects an empty title", async () => {
46
+ const h = createSessionTitleToolHandler(deps());
47
+ await assert.rejects(() => h.setSessionTitle({ title: " " }), /title_empty/);
48
+ });
49
+
50
+ test("still rejects when no active session", async () => {
51
+ const h = createSessionTitleToolHandler(deps({ peekSessionKey: () => "" }));
52
+ await assert.rejects(() => h.setSessionTitle({ title: "X" }), /no_active_session/);
53
+ });
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
- export const PLUGIN_VERSION = "1.3.1";
2
- export const REQUIRES_CLIENT_VERSION = "1.3.1";
1
+ export const PLUGIN_VERSION = "1.3.2";
2
+ export const REQUIRES_CLIENT_VERSION = "1.3.2";
@@ -43,6 +43,11 @@
43
43
  "help": "Optional extra system prompt appended to Even AI runs only.",
44
44
  "advanced": true
45
45
  },
46
+ "sessionTitleModel": {
47
+ "label": "Session-title model",
48
+ "help": "Optional model override (\"provider/model\") for the background session-title distiller. Leave blank to use your normal model.",
49
+ "advanced": true
50
+ },
46
51
  "evenAiRoutingMode": {
47
52
  "label": "Even AI routing mode",
48
53
  "help": "active = current session; background = dedicated background session; background_new = fresh background session per request.",
@@ -226,6 +231,10 @@
226
231
  "maximum": 8000,
227
232
  "default": 5000,
228
233
  "description": "How long (ms) a fresh agent summary stays preferred over a tool label in the glasses activity status. Clamped 3000-8000."
234
+ },
235
+ "sessionTitleModel": {
236
+ "type": "string",
237
+ "description": "Optional model override (\"provider/model\") for the background session-title distiller. When absent, the user's normal model is used."
229
238
  }
230
239
  },
231
240
  "if": {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ocuclaw",
3
- "version": "1.3.1",
4
- "requiresClientVersion": "1.3.1",
3
+ "version": "1.3.2",
4
+ "requiresClientVersion": "1.3.2",
5
5
  "description": "OcuClaw for Even Realities G2 smart glasses.",
6
6
  "type": "module",
7
7
  "main": "./dist/index.js",
@@ -40,6 +40,7 @@
40
40
  },
41
41
  "scripts": {
42
42
  "build": "node ./scripts/build.mjs",
43
- "prepare": "node ./scripts/build.mjs"
43
+ "prepare": "node ./scripts/build.mjs",
44
+ "test:unit": "node --import ./scripts/test-loader.mjs --test --test-force-exit \"src/**/*.test.ts\""
44
45
  }
45
46
  }
@@ -8,6 +8,13 @@ user-invocable: false
8
8
 
9
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.
10
10
 
11
+ ## Before you author: is the tool loaded?
12
+
13
+ `render_glasses_ui` is a plugin tool, and depending on the host runtime it may not sit in your initial tool list:
14
+
15
+ 1. **Not listed but searchable** — some runtimes (e.g. the Codex harness) defer OpenClaw dynamic tools behind tool search. Search your available/deferred tools for `render_glasses_ui` (it surfaces under the `openclaw` namespace), load it, and proceed.
16
+ 2. **Not findable at all** — the host's tool policy is filtering plugin tools. Newer OpenClaw versions (2026.6+) default `tools.profile` to `"coding"`, a base allowlist that strips plugin-owned tools; skills are not policy-filtered, which is why you can read this guide for a tool you cannot call. Don't improvise a workaround: tell the user to run `openclaw config set tools.alsoAllow '["ocuclaw"]' --strict-json` (merging `"ocuclaw"` into any existing `alsoAllow` list rather than overwriting) and restart the gateway, then try again.
17
+
11
18
  ## Surface kinds
12
19
 
13
20
  | kind | use it for | caps |