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.
- package/README.md +3 -1
- package/dist/config/runtime-config-session-title-model.test.js +22 -0
- package/dist/config/runtime-config.js +24 -15
- package/dist/domain/debug-store.js +18 -0
- package/dist/domain/glasses-display-system-prompt.js +52 -0
- package/dist/domain/glasses-display-system-prompt.test.js +44 -0
- package/dist/domain/glasses-ui-system-prompt.js +6 -22
- package/dist/domain/glasses-ui-system-prompt.test.js +13 -0
- package/dist/domain/prompt-channel-fragments.js +32 -0
- package/dist/domain/prompt-channel-fragments.test.js +70 -0
- package/dist/gateway/gateway-timing-ledger.js +15 -3
- package/dist/gateway/openclaw-client.js +80 -3
- package/dist/index.js +22 -0
- package/dist/runtime/channel-two-hook.js +36 -0
- package/dist/runtime/container-env.js +41 -0
- package/dist/runtime/display-toggle-states.js +98 -0
- package/dist/runtime/plugin-version-service.js +23 -0
- package/dist/runtime/register-session-title-distiller.js +100 -0
- package/dist/runtime/relay-core.js +307 -68
- package/dist/runtime/relay-service.js +120 -13
- package/dist/runtime/relay-worker-entry.js +26 -0
- package/dist/runtime/relay-worker-protocol.js +0 -4
- package/dist/runtime/relay-worker-supervisor.js +43 -79
- package/dist/runtime/relay-worker-transport.js +41 -0
- package/dist/runtime/session-service.js +159 -15
- package/dist/runtime/session-title-distiller-budget.js +36 -0
- package/dist/runtime/session-title-distiller-helpers.js +130 -0
- package/dist/runtime/session-title-distiller.js +354 -0
- package/dist/runtime/session-title-record.js +21 -0
- package/dist/runtime/stable-prompt-snapshot.js +119 -0
- package/dist/tools/glasses-ui-cron.js +9 -3
- package/dist/tools/glasses-ui-paint-floor.js +10 -3
- package/dist/tools/glasses-ui-recipes.js +13 -178
- package/dist/tools/glasses-ui-surfaces.js +8 -1
- package/dist/tools/glasses-ui-tool-description.test.js +16 -0
- package/dist/tools/glasses-ui-tool.js +98 -60
- package/dist/tools/session-title-tool.js +14 -76
- package/dist/tools/session-title-tool.test.js +53 -0
- package/dist/version.js +2 -2
- package/openclaw.plugin.json +9 -0
- package/package.json +6 -4
- package/skills/glasses-ui/SKILL.md +163 -0
- package/dist/runtime/downstream-server.js +0 -2057
- 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;
|