ocuclaw 1.2.4 → 1.3.0

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 (59) hide show
  1. package/README.md +18 -5
  2. package/dist/config/runtime-config.js +81 -3
  3. package/dist/domain/activity-status-adapter.js +138 -605
  4. package/dist/domain/activity-status-arbiter.js +109 -0
  5. package/dist/domain/activity-status-labels.js +906 -0
  6. package/dist/domain/code-span-regions.js +103 -0
  7. package/dist/domain/conversation-state.js +14 -1
  8. package/dist/domain/debug-store.js +38 -182
  9. package/dist/domain/glasses-ui-content-summary.js +62 -0
  10. package/dist/domain/glasses-ui-system-prompt.js +28 -0
  11. package/dist/domain/message-emoji-allowlist.js +16 -0
  12. package/dist/domain/message-emoji-filter.js +33 -55
  13. package/dist/domain/neural-emoji-reactor-system-prompt.js +43 -0
  14. package/dist/domain/neural-emoji-reactor-tag-config.js +56 -0
  15. package/dist/domain/neural-pace-modulator-system-prompt.js +32 -0
  16. package/dist/domain/neural-pace-modulator-tag-config.js +51 -0
  17. package/dist/domain/tagged-span-parser.js +121 -0
  18. package/dist/domain/tagged-span-strip.js +38 -0
  19. package/dist/even-ai/even-ai-endpoint.js +91 -0
  20. package/dist/even-ai/even-ai-run-waiter.js +14 -0
  21. package/dist/even-ai/even-ai-settings-store.js +14 -0
  22. package/dist/gateway/gateway-bridge.js +14 -2
  23. package/dist/gateway/gateway-timing-ledger.js +457 -0
  24. package/dist/gateway/openclaw-client.js +462 -38
  25. package/dist/index.js +28 -1
  26. package/dist/runtime/downstream-handler.js +754 -83
  27. package/dist/runtime/downstream-server.js +700 -534
  28. package/dist/runtime/ocuclaw-settings-store.js +74 -31
  29. package/dist/runtime/plugin-update-service.js +216 -0
  30. package/dist/runtime/protocol-adapter.js +9 -0
  31. package/dist/runtime/provider-usage-select.js +168 -0
  32. package/dist/runtime/relay-client-nudge-controller.js +553 -0
  33. package/dist/runtime/relay-core.js +1209 -204
  34. package/dist/runtime/relay-health-monitor.js +172 -0
  35. package/dist/runtime/relay-operation-registry.js +263 -0
  36. package/dist/runtime/relay-service.js +201 -1
  37. package/dist/runtime/relay-worker-approval-replay-cache.js +68 -0
  38. package/dist/runtime/relay-worker-entry.js +32 -0
  39. package/dist/runtime/relay-worker-health.js +272 -0
  40. package/dist/runtime/relay-worker-protocol.js +285 -0
  41. package/dist/runtime/relay-worker-queue.js +202 -0
  42. package/dist/runtime/relay-worker-supervisor.js +1081 -0
  43. package/dist/runtime/relay-worker-transport.js +1051 -0
  44. package/dist/runtime/session-context-service.js +189 -0
  45. package/dist/runtime/session-service.js +615 -24
  46. package/dist/runtime/upstream-runtime.js +1167 -60
  47. package/dist/tools/device-info-tool.js +242 -0
  48. package/dist/tools/glasses-ui-cron.js +427 -0
  49. package/dist/tools/glasses-ui-descriptors.js +261 -0
  50. package/dist/tools/glasses-ui-limits.js +21 -0
  51. package/dist/tools/glasses-ui-paint-floor.js +99 -0
  52. package/dist/tools/glasses-ui-recipes.js +746 -0
  53. package/dist/tools/glasses-ui-surfaces.js +278 -0
  54. package/dist/tools/glasses-ui-template.js +182 -0
  55. package/dist/tools/glasses-ui-tool.js +1147 -0
  56. package/dist/tools/session-title-tool.js +209 -0
  57. package/dist/version.js +2 -0
  58. package/openclaw.plugin.json +163 -15
  59. package/package.json +12 -4
@@ -0,0 +1,278 @@
1
+ // Per-session surface store for the glasses-UI tool. Extracted from
2
+ // glasses-ui-tool.ts (Phase 1) so this stateful logic can be unit-tested in
3
+ // isolation and so the tool file thins toward a schema+wiring layer (spec
4
+ // §Lifecycle detail — Surface/stack lifecycle store).
5
+ //
6
+ // Phase 2 evolves the Phase-1 createPendingRenderMap IN PLACE into
7
+ // createSurfaceStore (SINGLE store — there is never a second store alongside
8
+ // it). The decoupled lifecycle means a surface's lifetime is independent of
9
+ // any one tool call: resolve() settles the in-flight pending call but the
10
+ // surface entry PERSISTS (keyed by surfaceId), and a new register() does NOT
11
+ // preempt the previous surface. The legacy createPendingRenderMap name survives
12
+ // only as an alias (bottom of this file) for the Phase 1 import path.
13
+
14
+ // A terminal outcome ends the surface (teardown / exit). A nonterminal outcome
15
+ // (selected/back) keeps the surface alive and hands the turn to the agent.
16
+ const TERMINAL_RESULTS = new Set(["dismissed", "timeout", "glasses_disconnected", "preempted", "recipe_failed"]);
17
+
18
+ export function isTerminalOutcome(outcome) {
19
+ return !!(outcome && typeof outcome.result === "string" && TERMINAL_RESULTS.has(outcome.result));
20
+ }
21
+
22
+ export function createSurfaceStore(deps = {}) {
23
+ const pauseCron = typeof deps.pauseCron === "function" ? deps.pauseCron : () => {};
24
+ const resumeCron = typeof deps.resumeCron === "function" ? deps.resumeCron : () => {};
25
+ const stopCron = typeof deps.stopCron === "function" ? deps.stopCron : () => {};
26
+ const mintSurfaceId =
27
+ typeof deps.mintSurfaceId === "function"
28
+ ? deps.mintSurfaceId
29
+ : () => `ui-${Math.random().toString(36).slice(2, 10)}`;
30
+ // surfaceId -> { sessionKey, kind, pending: (resolver|null), lastContent,
31
+ // state, queuedEvent, exitLatched }
32
+ const bySurface = new Map();
33
+ const stackBySession = new Map(); // sessionKey -> [surfaceId, ...] (bottom→top)
34
+
35
+ function stackFor(sessionKey) {
36
+ let s = stackBySession.get(sessionKey);
37
+ if (!s) { s = []; stackBySession.set(sessionKey, s); }
38
+ return s;
39
+ }
40
+
41
+ // A fresh entry starts un-latched with an empty queue. A `prior` entry (an
42
+ // in-place content swap of the SAME surfaceId — i.e. `replace`) carries its
43
+ // latched exit / last-wins queued event forward so reattach semantics are
44
+ // MOVE-INDEPENDENT: a terminal latched (or a nonterminal queued) during
45
+ // `visible_awaiting_agent` is honored on the agent's NEXT render whether that
46
+ // render is patch, replace (the schema DEFAULT) or push — never silently
47
+ // dropped by the rebuild. (Without this carry-forward, a `replace` rebuild
48
+ // would reset exitLatched=false / queuedEvent=null and onReattached would
49
+ // return "reattached" instead of "discarded_for_exit", failing to tear down.)
50
+ function makeEntry(sessionKey, kind, prior) {
51
+ return {
52
+ sessionKey, kind: kind || null, pending: null, lastContent: null,
53
+ state: "visible_pending",
54
+ queuedEvent: prior ? prior.queuedEvent : null,
55
+ exitLatched: prior ? !!prior.exitLatched : false,
56
+ };
57
+ }
58
+
59
+ function register(sessionKey, surfaceId, meta) {
60
+ return new Promise((resolve) => {
61
+ const existing = bySurface.get(surfaceId);
62
+ if (existing) {
63
+ // Re-attach to an existing surface: replace its pending resolver,
64
+ // keep the entry. No preempt of a different surface. Restore the
65
+ // pending state so a re-render returns to visible_pending before
66
+ // onReattached flushes any queued event / latched exit.
67
+ existing.pending = resolve;
68
+ if (meta && meta.kind) existing.kind = meta.kind;
69
+ existing.sessionKey = sessionKey;
70
+ existing.state = "visible_pending";
71
+ return;
72
+ }
73
+ const entry = makeEntry(sessionKey, meta && meta.kind ? meta.kind : null);
74
+ entry.pending = resolve;
75
+ bySurface.set(surfaceId, entry);
76
+ });
77
+ }
78
+
79
+ function resolve(surfaceId, outcome) {
80
+ const entry = bySurface.get(surfaceId);
81
+ if (!entry || !entry.pending) return false;
82
+ const pending = entry.pending;
83
+ entry.pending = null; // settle the call; surface entry persists
84
+ if (isTerminalOutcome(outcome)) {
85
+ entry.state = "exiting";
86
+ } else {
87
+ entry.state = "visible_awaiting_agent";
88
+ }
89
+ pending(outcome);
90
+ return true;
91
+ }
92
+
93
+ function hasSurface(surfaceId) {
94
+ return bySurface.has(surfaceId);
95
+ }
96
+
97
+ function isPending(surfaceId) {
98
+ const entry = bySurface.get(surfaceId);
99
+ return !!(entry && entry.pending);
100
+ }
101
+
102
+ // Carried over from createPendingRenderMap (Phase 1). drainSession/drainAll
103
+ // resolve any in-flight pending call; Task 11 extends them to also stop
104
+ // crons and clear the per-session stack. They are the only resolve path that
105
+ // also DELETES surfaces (terminal teardown), unlike resolve() above.
106
+ function drainSession(sessionKey, outcome) {
107
+ let n = 0;
108
+ for (const [surfaceId, entry] of [...bySurface]) {
109
+ if (entry.sessionKey !== sessionKey) continue;
110
+ const pending = entry.pending;
111
+ entry.pending = null;
112
+ bySurface.delete(surfaceId);
113
+ if (pending) { pending(outcome); n += 1; }
114
+ }
115
+ return n;
116
+ }
117
+
118
+ function drainAll(outcome) {
119
+ let n = 0;
120
+ for (const [surfaceId, entry] of [...bySurface]) {
121
+ const pending = entry.pending;
122
+ entry.pending = null;
123
+ bySurface.delete(surfaceId);
124
+ if (pending) { pending(outcome); n += 1; }
125
+ }
126
+ return n;
127
+ }
128
+
129
+ function stateOf(surfaceId) {
130
+ const entry = bySurface.get(surfaceId);
131
+ return entry ? entry.state : null;
132
+ }
133
+
134
+ function queueEvent(surfaceId, event) {
135
+ const entry = bySurface.get(surfaceId);
136
+ if (!entry) return false;
137
+ if (isTerminalOutcome(event)) {
138
+ entry.exitLatched = true; // latched exit beats any queued nonterminal
139
+ entry.queuedEvent = event;
140
+ } else if (!entry.exitLatched) {
141
+ entry.queuedEvent = event; // last-wins among nonterminals
142
+ }
143
+ return true;
144
+ }
145
+
146
+ function isExitLatched(surfaceId) {
147
+ const entry = bySurface.get(surfaceId);
148
+ return !!(entry && entry.exitLatched);
149
+ }
150
+
151
+ function onReattached(surfaceId) {
152
+ const entry = bySurface.get(surfaceId);
153
+ if (!entry) return "no_surface";
154
+ if (entry.exitLatched) {
155
+ // A terminal latched during the agent's turn. Discard this render and
156
+ // resolve the freshly-established pending call (the re-attach render's
157
+ // promise) with the latched terminal outcome so the tool call settles
158
+ // instead of hanging, then tear down.
159
+ entry.state = "exiting";
160
+ const terminal = entry.queuedEvent || { result: "dismissed" };
161
+ entry.queuedEvent = null;
162
+ if (entry.pending) {
163
+ const pending = entry.pending;
164
+ entry.pending = null;
165
+ pending(terminal);
166
+ }
167
+ return "discarded_for_exit";
168
+ }
169
+ entry.state = "reattached";
170
+ const queued = entry.queuedEvent;
171
+ entry.queuedEvent = null;
172
+ if (queued && entry.pending) {
173
+ const pending = entry.pending;
174
+ entry.pending = null;
175
+ entry.state = "visible_awaiting_agent";
176
+ pending(queued);
177
+ }
178
+ return "reattached";
179
+ }
180
+
181
+ function topSurfaceId(sessionKey) {
182
+ const s = stackBySession.get(sessionKey);
183
+ return s && s.length ? s[s.length - 1] : null;
184
+ }
185
+
186
+ function stackDepth(sessionKey) {
187
+ const s = stackBySession.get(sessionKey);
188
+ return s ? s.length : 0;
189
+ }
190
+
191
+ function sessionForSurface(surfaceId) {
192
+ const entry = bySurface.get(surfaceId);
193
+ return entry ? entry.sessionKey : null;
194
+ }
195
+
196
+ // Derive the target surfaceId from the session's current top. The agent
197
+ // never supplies a surfaceId (spec §Core model — the plugin owns them,
198
+ // keyed by session + stack). Returns { mode, surfaceId } so the caller can
199
+ // render against the bound id.
200
+ function applyRender(sessionKey, params) {
201
+ const stack = stackFor(sessionKey);
202
+ const top = stack[stack.length - 1] || null;
203
+ // First render in a session (empty stack): create a root at depth 1,
204
+ // regardless of the requested update (there is nothing to patch/push onto).
205
+ if (!top) {
206
+ const id = mintSurfaceId();
207
+ stack.push(id);
208
+ bySurface.set(id, makeEntry(sessionKey, params && params.kind));
209
+ return { mode: "root", surfaceId: id };
210
+ }
211
+ const update = params && params.update === "patch" ? "patch"
212
+ : params && params.update === "push" ? "push"
213
+ : "replace";
214
+ if (update === "patch") {
215
+ // Re-attach in place: reuse the top id, keep its entry + cron. The
216
+ // caller sends a field patch; the body content slot is updated by the
217
+ // render send, not here.
218
+ const entry = bySurface.get(top);
219
+ if (entry && params && params.kind) entry.kind = params.kind;
220
+ if (entry) entry.state = "visible_pending";
221
+ return { mode: "patch", surfaceId: top };
222
+ }
223
+ if (update === "push") {
224
+ pauseCron(top);
225
+ const id = mintSurfaceId();
226
+ stack.push(id);
227
+ bySurface.set(id, makeEntry(sessionKey, params && params.kind));
228
+ return { mode: "push", surfaceId: id };
229
+ }
230
+ // replace: swap the current top's CONTENT in place, reusing its id (no
231
+ // new back-target, depth unchanged). The entry is reset to a fresh
232
+ // pending state; the cron for this slot is reset for new content. CARRY
233
+ // FORWARD the prior entry's latched exit / queued event (same surfaceId)
234
+ // so a terminal/nonterminal recorded during visible_awaiting_agent is
235
+ // still honored by the subsequent onReattached — reattach semantics must
236
+ // be move-independent (replace, the schema DEFAULT, must behave like
237
+ // patch here, not silently drop a latched exit).
238
+ const priorTop = bySurface.get(top);
239
+ stopCron(top);
240
+ bySurface.set(top, makeEntry(sessionKey, params && params.kind, priorTop));
241
+ return { mode: "replace", surfaceId: top };
242
+ }
243
+
244
+ function popBack(sessionKey) {
245
+ const stack = stackFor(sessionKey);
246
+ const child = stack.pop();
247
+ if (child) { stopCron(child); bySurface.delete(child); }
248
+ const parent = stack[stack.length - 1] || null;
249
+ if (parent) resumeCron(parent);
250
+ return parent;
251
+ }
252
+
253
+ function exit(sessionKey) {
254
+ const stack = stackFor(sessionKey);
255
+ for (const id of stack) { stopCron(id); bySurface.delete(id); }
256
+ stackBySession.set(sessionKey, []);
257
+ return true;
258
+ }
259
+
260
+ function sessionKeys() {
261
+ return [...stackBySession.keys()];
262
+ }
263
+
264
+ return {
265
+ register, resolve, hasSurface, isPending, drainSession, drainAll,
266
+ stateOf, queueEvent, isExitLatched, onReattached,
267
+ applyRender, popBack, exit, topSurfaceId, stackDepth, sessionKeys, sessionForSurface,
268
+ _bySurface: bySurface,
269
+ };
270
+ }
271
+
272
+ // Backwards-compatible alias. Phase 1 extracted createPendingRenderMap; Phase 2
273
+ // evolved it into createSurfaceStore (single store — see the SINGLE-STORE
274
+ // EVOLUTION note in the plan). The old name survives only as an alias so the
275
+ // Phase 1 import path (glasses-ui-tool re-export) keeps resolving. There is
276
+ // never a separate createPendingRenderMap *instance* — both names construct the
277
+ // one evolved store.
278
+ export const createPendingRenderMap = createSurfaceStore;
@@ -0,0 +1,182 @@
1
+ // Template engine for glasses UI refresh recipes.
2
+ // Pure functions. No I/O. Parses {{path | filter:arg | filter:arg}} expressions,
3
+ // resolves paths against a data object (plus an optional previous-tick object
4
+ // exposed under {{previous.*}}), and applies a small filter set sized exactly
5
+ // to the spec's stated use cases.
6
+
7
+ const KNOWN_FILTERS = new Set([
8
+ "trim",
9
+ "lower",
10
+ "upper",
11
+ "int",
12
+ "round",
13
+ "percent",
14
+ "truncate",
15
+ "default",
16
+ "prefix",
17
+ "minus",
18
+ "plus",
19
+ ]);
20
+
21
+ // Filters that require a numeric arg.
22
+ const NUMERIC_ARG_FILTERS = new Set(["round", "truncate"]);
23
+ // Filters that require a string arg (a quoted literal in the template).
24
+ const STRING_ARG_FILTERS = new Set(["default", "prefix"]);
25
+ // Filters that require a path arg (resolves against the data object).
26
+ const PATH_ARG_FILTERS = new Set(["minus", "plus"]);
27
+
28
+ function resolvePath(path, data, previous) {
29
+ if (typeof path !== "string" || path === "") return undefined;
30
+ const segments = path.split(".");
31
+ let cursor;
32
+ if (segments[0] === "previous") {
33
+ cursor = previous;
34
+ segments.shift();
35
+ } else {
36
+ cursor = data;
37
+ }
38
+ for (const segment of segments) {
39
+ if (cursor === null || cursor === undefined) return undefined;
40
+ if (Array.isArray(cursor)) {
41
+ const idx = Number(segment);
42
+ if (!Number.isInteger(idx) || idx < 0 || idx >= cursor.length) return undefined;
43
+ cursor = cursor[idx];
44
+ } else if (typeof cursor === "object") {
45
+ cursor = cursor[segment];
46
+ } else {
47
+ return undefined;
48
+ }
49
+ }
50
+ return cursor;
51
+ }
52
+
53
+ function parseFilter(filterSrc) {
54
+ // Format: `name` or `name:arg` where arg can be:
55
+ // - a quoted string: `default:"x"` or `prefix:"+"`
56
+ // - a number: `round:2` or `truncate:18`
57
+ // - a path: `minus:previous.value`
58
+ const colonIdx = filterSrc.indexOf(":");
59
+ const name = (colonIdx === -1 ? filterSrc : filterSrc.slice(0, colonIdx)).trim();
60
+ const rawArg = colonIdx === -1 ? "" : filterSrc.slice(colonIdx + 1).trim();
61
+ if (!KNOWN_FILTERS.has(name)) {
62
+ return { ok: false, code: "refresh_template_invalid", message: `unknown filter: ${name}` };
63
+ }
64
+ if (NUMERIC_ARG_FILTERS.has(name)) {
65
+ const n = Number(rawArg);
66
+ if (rawArg === "" || !Number.isFinite(n)) {
67
+ return { ok: false, code: "refresh_template_invalid", message: `filter ${name} requires numeric arg, got: ${JSON.stringify(rawArg)}` };
68
+ }
69
+ return { ok: true, name, arg: n };
70
+ }
71
+ if (STRING_ARG_FILTERS.has(name)) {
72
+ const m = rawArg.match(/^"([^"]*)"$/);
73
+ if (!m) {
74
+ return { ok: false, code: "refresh_template_invalid", message: `filter ${name} requires quoted-string arg, got: ${JSON.stringify(rawArg)}` };
75
+ }
76
+ return { ok: true, name, arg: m[1] };
77
+ }
78
+ if (PATH_ARG_FILTERS.has(name)) {
79
+ if (!rawArg) {
80
+ return { ok: false, code: "refresh_template_invalid", message: `filter ${name} requires a path arg` };
81
+ }
82
+ return { ok: true, name, arg: rawArg };
83
+ }
84
+ // No-arg filters.
85
+ return { ok: true, name, arg: null };
86
+ }
87
+
88
+ function applyFilter(value, filter, data, previous) {
89
+ switch (filter.name) {
90
+ case "trim":
91
+ return typeof value === "string" ? value.trim() : value;
92
+ case "lower":
93
+ return typeof value === "string" ? value.toLowerCase() : value;
94
+ case "upper":
95
+ return typeof value === "string" ? value.toUpperCase() : value;
96
+ case "int": {
97
+ const n = Number(value);
98
+ return Number.isFinite(n) ? Math.trunc(n) : value;
99
+ }
100
+ case "round": {
101
+ const n = Number(value);
102
+ if (!Number.isFinite(n)) return value;
103
+ const m = Math.pow(10, filter.arg);
104
+ return Math.round(n * m) / m;
105
+ }
106
+ case "percent": {
107
+ const n = Number(value);
108
+ if (!Number.isFinite(n)) return value;
109
+ return `${Math.round(n * 1000) / 10}%`;
110
+ }
111
+ case "truncate":
112
+ return typeof value === "string" ? value.slice(0, filter.arg) : value;
113
+ case "default":
114
+ return value === undefined || value === null || value === "" ? filter.arg : value;
115
+ case "prefix": {
116
+ // Truthy = non-zero, non-empty. Numbers >= 0 with prefix:"+" should
117
+ // still prepend on positive values; here we treat "truthy" as the
118
+ // value's actual truthiness — agent uses prefix when they want the
119
+ // literal prepended on a non-empty value.
120
+ if (value === undefined || value === null || value === "") return value;
121
+ if (typeof value === "number" && value === 0) return value;
122
+ return `${filter.arg}${value}`;
123
+ }
124
+ case "minus":
125
+ case "plus": {
126
+ const a = Number(value);
127
+ const b = Number(resolvePath(filter.arg, data, previous));
128
+ if (!Number.isFinite(a) || !Number.isFinite(b)) return value;
129
+ return filter.name === "minus" ? a - b : a + b;
130
+ }
131
+ default:
132
+ return value;
133
+ }
134
+ }
135
+
136
+ function stringify(value) {
137
+ if (value === undefined || value === null) return "";
138
+ if (typeof value === "string") return value;
139
+ return String(value);
140
+ }
141
+
142
+ // PUBLIC ----
143
+
144
+ export function substituteTemplate(template, data, opts) {
145
+ if (typeof template !== "string") return "";
146
+ const previous = opts && opts.previous ? opts.previous : null;
147
+ return template.replace(/\{\{([^}]+)\}\}/g, (match, exprSrc) => {
148
+ const parts = exprSrc.split("|").map((s) => s.trim());
149
+ const path = parts[0];
150
+ let value = resolvePath(path, data, previous);
151
+ for (let i = 1; i < parts.length; i += 1) {
152
+ const f = parseFilter(parts[i]);
153
+ if (!f.ok) return stringify(value); // shouldn't happen if validateTemplate was called
154
+ value = applyFilter(value, f, data, previous);
155
+ }
156
+ return stringify(value);
157
+ });
158
+ }
159
+
160
+ export function validateTemplate(template) {
161
+ if (typeof template !== "string") {
162
+ return { ok: false, code: "refresh_template_invalid", message: "template must be a string" };
163
+ }
164
+ // Detect unclosed braces.
165
+ const openCount = (template.match(/\{\{/g) || []).length;
166
+ const closeCount = (template.match(/\}\}/g) || []).length;
167
+ if (openCount !== closeCount) {
168
+ return { ok: false, code: "refresh_template_invalid", message: "unmatched {{ or }} in template" };
169
+ }
170
+ const re = /\{\{([^}]+)\}\}/g;
171
+ let m;
172
+ while ((m = re.exec(template)) !== null) {
173
+ const parts = m[1].split("|").map((s) => s.trim());
174
+ for (let i = 1; i < parts.length; i += 1) {
175
+ const f = parseFilter(parts[i]);
176
+ if (!f.ok) return f;
177
+ }
178
+ }
179
+ return { ok: true };
180
+ }
181
+
182
+ export default { substituteTemplate, validateTemplate };