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.
Files changed (40) 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/glasses-backpressure-latch.js +115 -0
  16. package/dist/runtime/register-session-title-distiller.js +100 -0
  17. package/dist/runtime/relay-core.js +284 -33
  18. package/dist/runtime/relay-service.js +152 -13
  19. package/dist/runtime/relay-worker-entry.js +26 -0
  20. package/dist/runtime/relay-worker-supervisor.js +51 -2
  21. package/dist/runtime/relay-worker-transport.js +51 -1
  22. package/dist/runtime/session-service.js +136 -12
  23. package/dist/runtime/session-title-distiller-budget.js +36 -0
  24. package/dist/runtime/session-title-distiller-helpers.js +130 -0
  25. package/dist/runtime/session-title-distiller.js +354 -0
  26. package/dist/runtime/session-title-record.js +21 -0
  27. package/dist/runtime/stable-prompt-snapshot.js +119 -0
  28. package/dist/tools/glasses-ui-cron.js +59 -3
  29. package/dist/tools/glasses-ui-paint-floor.js +33 -4
  30. package/dist/tools/glasses-ui-surfaces.js +369 -35
  31. package/dist/tools/glasses-ui-tool-description.test.js +16 -0
  32. package/dist/tools/glasses-ui-tool.js +662 -80
  33. package/dist/tools/glasses-ui-voicemail.js +299 -0
  34. package/dist/tools/glasses-ui-wake.js +262 -0
  35. package/dist/tools/session-title-tool.js +14 -76
  36. package/dist/tools/session-title-tool.test.js +53 -0
  37. package/dist/version.js +2 -2
  38. package/openclaw.plugin.json +9 -0
  39. package/package.json +4 -3
  40. package/skills/glasses-ui/SKILL.md +26 -3
@@ -0,0 +1,98 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+
4
+ const DEFAULTS = { emoji: false, pace: false };
5
+ const STORE_FILENAME = "ocuclaw-display-toggles.json";
6
+
7
+ /**
8
+ * Tracks, per session, the display-feature toggle state at session start
9
+ * (frozen on first record) and the latest reported state.
10
+ *
11
+ * The FROZEN start-state is persisted to stateDir so it survives a relay/plugin
12
+ * restart — the Channel-1 prompt snapshot is also persisted, so without this the
13
+ * Channel-2 disable stop-notice would be lost after a restart (a session that
14
+ * started with a feature ON would re-freeze start=OFF from the first post-restart
15
+ * send and never notice the disable). The CURRENT state is NOT persisted: it
16
+ * re-derives from the next send, so persisting it would mean a write per turn.
17
+ */
18
+ export function createDisplayToggleTracker(opts = {}) {
19
+ const limit = Number.isFinite(opts.limit) ? opts.limit : 200;
20
+ const statePath =
21
+ typeof opts.stateDir === "string" && opts.stateDir.trim()
22
+ ? path.join(opts.stateDir.trim(), STORE_FILENAME)
23
+ : null;
24
+ /** @type {Map<string,{start:{emoji,pace},current:{emoji,pace}}>} */
25
+ const byKey = new Map();
26
+
27
+ function norm(v) {
28
+ return { emoji: !!(v && v.emoji), pace: !!(v && v.pace) };
29
+ }
30
+
31
+ function load() {
32
+ if (!statePath) return;
33
+ try {
34
+ const parsed = JSON.parse(fs.readFileSync(statePath, "utf8"));
35
+ if (parsed && parsed.entries && typeof parsed.entries === "object") {
36
+ for (const [k, v] of Object.entries(parsed.entries)) {
37
+ if (v && v.start) {
38
+ const start = norm(v.start);
39
+ // current re-derives on the next send; seed it from start until then.
40
+ byKey.set(k, { start, current: { ...start } });
41
+ }
42
+ }
43
+ }
44
+ } catch (_e) {
45
+ // Missing/corrupt store is non-fatal: start empty.
46
+ }
47
+ }
48
+
49
+ function persist() {
50
+ if (!statePath) return;
51
+ try {
52
+ const entries = {};
53
+ for (const [k, v] of byKey.entries()) entries[k] = { start: v.start };
54
+ const tmp = `${statePath}.tmp`;
55
+ fs.writeFileSync(tmp, JSON.stringify({ version: 1, entries }), { mode: 0o600 });
56
+ fs.renameSync(tmp, statePath);
57
+ } catch (_e) {
58
+ // Best-effort persistence.
59
+ }
60
+ }
61
+
62
+ function evictIfNeeded() {
63
+ while (byKey.size > limit) {
64
+ const oldest = byKey.keys().next().value;
65
+ if (oldest === undefined) break;
66
+ byKey.delete(oldest);
67
+ }
68
+ }
69
+
70
+ load();
71
+
72
+ return {
73
+ record(sessionKey, states) {
74
+ const cur = norm(states);
75
+ const existing = byKey.get(sessionKey);
76
+ if (!existing) {
77
+ byKey.set(sessionKey, { start: cur, current: cur });
78
+ evictIfNeeded();
79
+ persist(); // a new frozen start-state was recorded
80
+ return;
81
+ }
82
+ existing.current = cur; // start stays frozen; no persist (start unchanged)
83
+ },
84
+ getStart(sessionKey) {
85
+ const e = byKey.get(sessionKey);
86
+ return e ? { ...e.start } : { ...DEFAULTS };
87
+ },
88
+ getCurrent(sessionKey) {
89
+ const e = byKey.get(sessionKey);
90
+ return e ? { ...e.current } : { ...DEFAULTS };
91
+ },
92
+ clear(sessionKey) {
93
+ if (byKey.delete(sessionKey)) persist();
94
+ },
95
+ };
96
+ }
97
+
98
+ export default createDisplayToggleTracker;
@@ -0,0 +1,115 @@
1
+ // Main-side latch over the worker's send-buffer high-water count (roadmap
2
+ // step 4a). The worker transport counts app clients whose ws.bufferedAmount
3
+ // exceeds the high-water mark every health heartbeat and posts the count to
4
+ // main; this latch converts those reports into the boolean the glasses-ui
5
+ // paint-floor shed consumes (relay-service.isGlassesSendBufferOverHighWater).
6
+ //
7
+ // LEAF MODULE: must not import other relay runtime modules (the CJS emitter
8
+ // resolves bidirectional imports to {} mid-cycle — see
9
+ // memory/ocuclaw-cjs-emitter-import-cycle).
10
+ //
11
+ // Semantics:
12
+ // - latch on any report with sendBufferHighWaterClients >= 1
13
+ // - hysteresis: a 0-report clears only after recoveredHoldMs has elapsed
14
+ // since the last >=1 report (pressure flaps at paint cadence otherwise)
15
+ // - decay: with no fresh report inside staleMs the latch fails OPEN (false)
16
+ // — a dead or wedged worker must never freeze glasses paints forever
17
+ // - a report from a new workerEpoch discards prior state (worker restart)
18
+ // - false->true / true->false transitions emit debug events so the 4b
19
+ // hardware validation can observe the shed firing
20
+
21
+ const DEFAULT_RECOVERED_HOLD_MS = 3_000;
22
+ const DEFAULT_STALE_MS = 5_000;
23
+
24
+ export function createGlassesBackpressureLatch(options = {}) {
25
+ const now = typeof options.now === "function" ? options.now : Date.now;
26
+ const recoveredHoldMs = Number.isFinite(options.recoveredHoldMs)
27
+ ? options.recoveredHoldMs
28
+ : DEFAULT_RECOVERED_HOLD_MS;
29
+ const staleMs = Number.isFinite(options.staleMs) ? options.staleMs : DEFAULT_STALE_MS;
30
+ const emitDebug = typeof options.emitDebug === "function" ? options.emitDebug : () => {};
31
+
32
+ let latched = false;
33
+ let latchedAtMs = null;
34
+ let lastOverAtMs = null;
35
+ let lastReportAtMs = null;
36
+ let workerEpoch = null;
37
+
38
+ function clearState() {
39
+ latched = false;
40
+ latchedAtMs = null;
41
+ lastOverAtMs = null;
42
+ lastReportAtMs = null;
43
+ }
44
+
45
+ function emitTransition(nextLatched, reason, atMs) {
46
+ if (nextLatched === latched) return;
47
+ if (nextLatched) {
48
+ latchedAtMs = atMs;
49
+ emitDebug("glasses_backpressure_latched", "warn", { reason });
50
+ } else {
51
+ emitDebug("glasses_backpressure_cleared", "info", {
52
+ reason,
53
+ latchedForMs: latchedAtMs === null ? null : Math.max(0, atMs - latchedAtMs),
54
+ });
55
+ latchedAtMs = null;
56
+ }
57
+ latched = nextLatched;
58
+ }
59
+
60
+ /** Re-evaluate decay/hysteresis clears against the clock. */
61
+ function evaluate(atMs) {
62
+ if (!latched) return;
63
+ if (lastReportAtMs !== null && atMs - lastReportAtMs > staleMs) {
64
+ emitTransition(false, "stale_reports", atMs);
65
+ return;
66
+ }
67
+ if (
68
+ lastOverAtMs !== null &&
69
+ atMs - lastOverAtMs >= recoveredHoldMs &&
70
+ lastReportAtMs !== null &&
71
+ lastReportAtMs > lastOverAtMs
72
+ ) {
73
+ // The newest report said 0 and the hold window since the last >=1
74
+ // report has fully elapsed.
75
+ emitTransition(false, "recovered", atMs);
76
+ }
77
+ }
78
+
79
+ function report(params) {
80
+ const atMs = now();
81
+ const count =
82
+ params && Number.isFinite(params.sendBufferHighWaterClients)
83
+ ? params.sendBufferHighWaterClients
84
+ : null;
85
+ if (count === null) return;
86
+ const epoch = params && Number.isFinite(params.workerEpoch) ? params.workerEpoch : null;
87
+ if (epoch !== null && workerEpoch !== null && epoch !== workerEpoch) {
88
+ // Worker restarted: prior pressure belongs to dead sockets.
89
+ clearState();
90
+ }
91
+ if (epoch !== null) workerEpoch = epoch;
92
+ lastReportAtMs = atMs;
93
+ if (count >= 1) {
94
+ lastOverAtMs = atMs;
95
+ emitTransition(true, "over_high_water", atMs);
96
+ return;
97
+ }
98
+ evaluate(atMs);
99
+ }
100
+
101
+ function isOverHighWater() {
102
+ evaluate(now());
103
+ return latched;
104
+ }
105
+
106
+ function reset(reason) {
107
+ const atMs = now();
108
+ emitTransition(false, typeof reason === "string" ? reason : "reset", atMs);
109
+ clearState();
110
+ }
111
+
112
+ return { report, isOverHighWater, reset };
113
+ }
114
+
115
+ export default { createGlassesBackpressureLatch };
@@ -0,0 +1,100 @@
1
+ import * as fs from "node:fs/promises";
2
+ import { createSessionTitleDistiller } from "./session-title-distiller.js";
3
+ import { stripAgentSessionPrefix } from "./session-title-distiller-helpers.js";
4
+
5
+ function genRunId() {
6
+ const c = globalThis && globalThis.crypto;
7
+ if (c && typeof c.randomUUID === "function") return `ocuclaw-title-${c.randomUUID()}`;
8
+ return `ocuclaw-title-${Date.now()}-${Math.random().toString(16).slice(2)}`;
9
+ }
10
+
11
+ export function registerSessionTitleDistiller(api, service) {
12
+ if (!api || typeof api.on !== "function") return () => {};
13
+ // The budget lives in sessionService (so a logical reset clears it with the
14
+ // title record + toggle state). Resolve it lazily per call: at registration
15
+ // the live relay may not exist yet, and a sibling context resolves the shared
16
+ // relay's budget.
17
+ const budget = {
18
+ canRun: (k) => {
19
+ const b = service.getDistillerBudget();
20
+ return b ? b.canRun(k) : true;
21
+ },
22
+ recordTurn: (k) => {
23
+ const b = service.getDistillerBudget();
24
+ if (b) b.recordTurn(k);
25
+ },
26
+ recordOutcome: (k, o) => {
27
+ const b = service.getDistillerBudget();
28
+ if (b) b.recordOutcome(k, o);
29
+ },
30
+ };
31
+ const distiller = createSessionTitleDistiller({
32
+ // Lazy getter: the real state dir only arrives at service.start(), after
33
+ // this distiller is constructed during registration.
34
+ getStateDir: () => (service.getStateDir ? service.getStateDir() : undefined),
35
+ nowMs: () => Date.now(),
36
+ genId: genRunId,
37
+ emitDebug: (...a) => (service.emitDebug ? service.emitDebug(...a) : undefined),
38
+ getSessionTitleModel: () => service.getRuntimeConfig().sessionTitleModel || "",
39
+ conversationState: { getRawMessages: () => service.getRawMessages() },
40
+ sessionService: {
41
+ getSessionTitleRecord: (k) => service.getSessionTitleRecord(k),
42
+ isNeuralSessionNamesEnabled: (k) => service.isNeuralSessionNamesEnabled(k),
43
+ isSessionUserLocked: (k) => service.isSessionUserLocked(k),
44
+ hasRecordedUserMessage: (k) => service.hasRecordedUserMessage(k),
45
+ setSessionTitle: (k, t, o) => service.setSessionTitle(k, t, o),
46
+ },
47
+ isEvenAiSessionKey: (k) => service.isEvenAiSessionKey(k),
48
+ cleanupDistillerSession: (k) =>
49
+ typeof service.deleteDistillerSession === "function"
50
+ ? service.deleteDistillerSession(k)
51
+ : Promise.resolve(null),
52
+ subagentRuntime: (() => {
53
+ const sa = api && api.runtime && api.runtime.subagent;
54
+ if (
55
+ !sa ||
56
+ typeof sa.run !== "function" ||
57
+ typeof sa.waitForRun !== "function" ||
58
+ typeof sa.getSessionMessages !== "function" ||
59
+ typeof sa.deleteSession !== "function"
60
+ ) {
61
+ return null;
62
+ }
63
+ return {
64
+ run: (p) => sa.run(p),
65
+ waitForRun: (p) => sa.waitForRun(p),
66
+ getSessionMessages: (p) => sa.getSessionMessages(p),
67
+ deleteSession: (p) => sa.deleteSession(p),
68
+ };
69
+ })(),
70
+ gatewayBridge: {
71
+ request: (m, p, o) => service.gatewayRequest(m, p, o),
72
+ on: (evt, cb) => service.onGatewayEvent(evt, cb),
73
+ },
74
+ fs,
75
+ budget,
76
+ });
77
+
78
+ return api.on("agent_end", (event, ctx) => {
79
+ // 2026.6.x delivers the canonical `agent:<id>:<key>` form; relay state is
80
+ // keyed by the bare relay key — normalize at the boundary.
81
+ const rawSessionKey = ctx && typeof ctx.sessionKey === "string" ? ctx.sessionKey : null;
82
+ const sessionKey = stripAgentSessionPrefix(rawSessionKey);
83
+ if (!sessionKey) return;
84
+ // Capture the completed run's messages NOW, before the fire-and-forget run
85
+ // defers: agent_end carries the run's final messages (the authoritative
86
+ // source for THIS session). Fall back to a synchronous snapshot of the live
87
+ // conversation state. Either way the messages are pinned here so a later
88
+ // session switch / clear can't make the distiller title from another
89
+ // session's transcript.
90
+ const eventMessages = event && Array.isArray(event.messages) ? event.messages : null;
91
+ const messages =
92
+ eventMessages && eventMessages.length
93
+ ? eventMessages
94
+ : service.getRawMessages();
95
+ // Fire-and-forget; never block run teardown.
96
+ Promise.resolve(distiller.maybeRun(sessionKey, { messages })).catch(() => {});
97
+ });
98
+ }
99
+
100
+ export default registerSessionTitleDistiller;