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.
- package/dist/config/runtime-config-session-title-model.test.js +22 -0
- package/dist/config/runtime-config.js +7 -1
- 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/glasses-backpressure-latch.js +115 -0
- package/dist/runtime/register-session-title-distiller.js +100 -0
- package/dist/runtime/relay-core.js +284 -33
- package/dist/runtime/relay-service.js +152 -13
- package/dist/runtime/relay-worker-entry.js +26 -0
- package/dist/runtime/relay-worker-supervisor.js +51 -2
- package/dist/runtime/relay-worker-transport.js +51 -1
- package/dist/runtime/session-service.js +136 -12
- 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 +59 -3
- package/dist/tools/glasses-ui-paint-floor.js +33 -4
- package/dist/tools/glasses-ui-surfaces.js +369 -35
- package/dist/tools/glasses-ui-tool-description.test.js +16 -0
- package/dist/tools/glasses-ui-tool.js +662 -80
- package/dist/tools/glasses-ui-voicemail.js +299 -0
- package/dist/tools/glasses-ui-wake.js +262 -0
- 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 +4 -3
- 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;
|