ocuclaw 1.3.0 → 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 (44) hide show
  1. package/README.md +3 -1
  2. package/dist/config/runtime-config-session-title-model.test.js +22 -0
  3. package/dist/config/runtime-config.js +24 -15
  4. package/dist/domain/debug-store.js +18 -0
  5. package/dist/domain/glasses-display-system-prompt.js +52 -0
  6. package/dist/domain/glasses-display-system-prompt.test.js +44 -0
  7. package/dist/domain/glasses-ui-system-prompt.js +6 -22
  8. package/dist/domain/glasses-ui-system-prompt.test.js +13 -0
  9. package/dist/domain/prompt-channel-fragments.js +32 -0
  10. package/dist/domain/prompt-channel-fragments.test.js +70 -0
  11. package/dist/gateway/gateway-timing-ledger.js +15 -3
  12. package/dist/gateway/openclaw-client.js +80 -3
  13. package/dist/index.js +22 -0
  14. package/dist/runtime/channel-two-hook.js +36 -0
  15. package/dist/runtime/container-env.js +41 -0
  16. package/dist/runtime/display-toggle-states.js +98 -0
  17. package/dist/runtime/plugin-version-service.js +23 -0
  18. package/dist/runtime/register-session-title-distiller.js +100 -0
  19. package/dist/runtime/relay-core.js +307 -68
  20. package/dist/runtime/relay-service.js +120 -13
  21. package/dist/runtime/relay-worker-entry.js +26 -0
  22. package/dist/runtime/relay-worker-protocol.js +0 -4
  23. package/dist/runtime/relay-worker-supervisor.js +43 -79
  24. package/dist/runtime/relay-worker-transport.js +41 -0
  25. package/dist/runtime/session-service.js +159 -15
  26. package/dist/runtime/session-title-distiller-budget.js +36 -0
  27. package/dist/runtime/session-title-distiller-helpers.js +130 -0
  28. package/dist/runtime/session-title-distiller.js +354 -0
  29. package/dist/runtime/session-title-record.js +21 -0
  30. package/dist/runtime/stable-prompt-snapshot.js +119 -0
  31. package/dist/tools/glasses-ui-cron.js +9 -3
  32. package/dist/tools/glasses-ui-paint-floor.js +10 -3
  33. package/dist/tools/glasses-ui-recipes.js +13 -178
  34. package/dist/tools/glasses-ui-surfaces.js +8 -1
  35. package/dist/tools/glasses-ui-tool-description.test.js +16 -0
  36. package/dist/tools/glasses-ui-tool.js +98 -60
  37. package/dist/tools/session-title-tool.js +14 -76
  38. package/dist/tools/session-title-tool.test.js +53 -0
  39. package/dist/version.js +2 -2
  40. package/openclaw.plugin.json +9 -0
  41. package/package.json +6 -4
  42. package/skills/glasses-ui/SKILL.md +163 -0
  43. package/dist/runtime/downstream-server.js +0 -2057
  44. package/dist/runtime/plugin-update-service.js +0 -216
@@ -0,0 +1,36 @@
1
+ import { composeChannelTwoFragment } from "../domain/prompt-channel-fragments.js";
2
+
3
+ /**
4
+ * Build a before_prompt_build hook that injects the Channel-2 fragment.
5
+ * @param {{getDisplayStartStates:Function, getDisplayCurrentStates:Function,
6
+ * hasConnectedAppClient:Function}} service
7
+ */
8
+ export function createChannelTwoHook(service, opts = {}) {
9
+ const emitDebug = typeof opts.emitDebug === "function" ? opts.emitDebug : () => {};
10
+ return function channelTwoBeforePromptBuild(_event, ctx) {
11
+ const sessionKey =
12
+ ctx && typeof ctx.sessionKey === "string" && ctx.sessionKey.trim()
13
+ ? ctx.sessionKey
14
+ : null;
15
+ if (!sessionKey) return undefined;
16
+ try {
17
+ const fragment = composeChannelTwoFragment({
18
+ startEnabled: service.getDisplayStartStates(sessionKey),
19
+ currentEnabled: service.getDisplayCurrentStates(sessionKey),
20
+ glassesConnected:
21
+ typeof service.hasConnectedAppClient === "function"
22
+ ? service.hasConnectedAppClient()
23
+ : true,
24
+ });
25
+ if (!fragment) return undefined;
26
+ emitDebug("relay.session", "channel_two_fragment_injected", "debug",
27
+ { sessionKey }, () => ({ chars: fragment.length }));
28
+ return { appendSystemContext: fragment };
29
+ } catch (_err) {
30
+ // Defensive: any state-read failure yields no injection.
31
+ return undefined;
32
+ }
33
+ };
34
+ }
35
+
36
+ export default createChannelTwoHook;
@@ -0,0 +1,41 @@
1
+ import fs from "node:fs";
2
+
3
+ // Marker files written by the container runtimes themselves: Docker creates
4
+ // /.dockerenv, Podman creates /run/.containerenv. Kubernetes-style runtimes
5
+ // leave neither — detection there would need heuristics too fragile to ship.
6
+ const CONTAINER_MARKER_PATHS = ["/.dockerenv", "/run/.containerenv"];
7
+
8
+ export function isLoopbackBindAddress(address) {
9
+ const normalized = typeof address === "string" ? address.trim().toLowerCase() : "";
10
+ if (!normalized) return false;
11
+ return (
12
+ normalized === "localhost" ||
13
+ normalized === "::1" ||
14
+ normalized.startsWith("127.")
15
+ );
16
+ }
17
+
18
+ export function isContainerEnvironment(deps = {}) {
19
+ const existsSync = typeof deps.existsSync === "function" ? deps.existsSync : fs.existsSync;
20
+ const markerPaths = Array.isArray(deps.markerPaths) ? deps.markerPaths : CONTAINER_MARKER_PATHS;
21
+ for (const markerPath of markerPaths) {
22
+ try {
23
+ if (existsSync(markerPath)) return true;
24
+ } catch {
25
+ // No filesystem signal beats a startup failure: treat unreadable markers
26
+ // as "not a container" and keep the relay booting.
27
+ }
28
+ }
29
+ return false;
30
+ }
31
+
32
+ export function composeContainerLoopbackWarning(wsBind, wsPort) {
33
+ return (
34
+ `[ocuclaw] relay is bound to ${wsBind} inside a container — if the OcuClaw app cannot connect, this is likely why. ` +
35
+ `Connections from outside the container arrive via its network interface, which a loopback bind does not listen on, ` +
36
+ `so the relay is unreachable even though it reports healthy. ` +
37
+ `(Containers run with --network host are unaffected and can ignore this warning.) ` +
38
+ `Fix: openclaw config set plugins.entries.ocuclaw.config.wsBind "0.0.0.0" ` +
39
+ `and publish the relay port to the host loopback only (-p 127.0.0.1:${wsPort}:${wsPort}), then restart the gateway.`
40
+ );
41
+ }
@@ -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,23 @@
1
+ import { PLUGIN_VERSION, REQUIRES_CLIENT_VERSION } from "../version.js";
2
+
3
+ /**
4
+ * Plugin version provider. Exposes the build-time version constants used by the
5
+ * relay handshake. No process execution.
6
+ */
7
+ function createPluginVersionService() {
8
+ function getPluginVersion() {
9
+ return typeof PLUGIN_VERSION === "string" && PLUGIN_VERSION.length > 0
10
+ ? PLUGIN_VERSION
11
+ : null;
12
+ }
13
+
14
+ function getRequiresClientVersion() {
15
+ return typeof REQUIRES_CLIENT_VERSION === "string" && REQUIRES_CLIENT_VERSION.length > 0
16
+ ? REQUIRES_CLIENT_VERSION
17
+ : null;
18
+ }
19
+
20
+ return { getPluginVersion, getRequiresClientVersion };
21
+ }
22
+
23
+ export { createPluginVersionService };
@@ -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;