ocuclaw 1.3.1 → 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/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/register-session-title-distiller.js +100 -0
- package/dist/runtime/relay-core.js +209 -33
- package/dist/runtime/relay-service.js +120 -13
- package/dist/runtime/relay-worker-entry.js +26 -0
- package/dist/runtime/relay-worker-supervisor.js +43 -2
- package/dist/runtime/relay-worker-transport.js +41 -0
- 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 +9 -3
- package/dist/tools/glasses-ui-paint-floor.js +10 -3
- 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 +88 -14
- 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 +7 -0
|
@@ -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,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;
|
|
@@ -9,6 +9,8 @@ import { composeReadabilitySystemPrompt } from "../domain/readability-system-pro
|
|
|
9
9
|
import { composeNeuralEmojiReactorSystemPrompt } from "../domain/neural-emoji-reactor-system-prompt.js";
|
|
10
10
|
import { composeNeuralPaceModulatorSystemPrompt } from "../domain/neural-pace-modulator-system-prompt.js";
|
|
11
11
|
import { composeGlassesUiNudgeSystemPrompt } from "../domain/glasses-ui-system-prompt.js";
|
|
12
|
+
import { composeGlassesDisplaySystemPrompt } from "../domain/glasses-display-system-prompt.js";
|
|
13
|
+
import { createStablePromptSnapshotStore } from "./stable-prompt-snapshot.js";
|
|
12
14
|
import { createActivityStatusAdapter } from "../domain/activity-status-adapter.js";
|
|
13
15
|
import { createEvenAiEndpoint } from "../even-ai/even-ai-endpoint.js";
|
|
14
16
|
import { createEvenAiRouter } from "../even-ai/even-ai-router.js";
|
|
@@ -359,7 +361,9 @@ function createRelay(opts) {
|
|
|
359
361
|
/** Relay-local deterministic simulate-stream run sequence counter. */
|
|
360
362
|
let simulateStreamRunSeq = 0;
|
|
361
363
|
/** Active timers for relay-local deterministic simulate-stream runs. */
|
|
362
|
-
|
|
364
|
+
// timer -> sessionKey, so new-chat//reset/new-session can cancel ONLY the
|
|
365
|
+
// affected session's pending injections (re-land of a8a29032, session-scoped).
|
|
366
|
+
const simulateStreamTimers = new Map();
|
|
363
367
|
|
|
364
368
|
// --- Structured debug state ---
|
|
365
369
|
|
|
@@ -550,7 +554,7 @@ function createRelay(opts) {
|
|
|
550
554
|
);
|
|
551
555
|
}
|
|
552
556
|
|
|
553
|
-
function scheduleSimulateStreamTimer(delayMs, callback) {
|
|
557
|
+
function scheduleSimulateStreamTimer(delayMs, callback, sessionKey) {
|
|
554
558
|
const timer = setTimeout(() => {
|
|
555
559
|
simulateStreamTimers.delete(timer);
|
|
556
560
|
try {
|
|
@@ -559,12 +563,29 @@ function createRelay(opts) {
|
|
|
559
563
|
logger.error(`[relay] simulate-stream timer failed: ${err.message}`);
|
|
560
564
|
}
|
|
561
565
|
}, delayMs);
|
|
562
|
-
simulateStreamTimers.
|
|
566
|
+
simulateStreamTimers.set(timer, typeof sessionKey === "string" ? sessionKey : null);
|
|
563
567
|
return timer;
|
|
564
568
|
}
|
|
565
569
|
|
|
570
|
+
function clearSimulateStreamTimersForSession(sessionKey) {
|
|
571
|
+
let cleared = 0;
|
|
572
|
+
for (const [timer, timerSessionKey] of simulateStreamTimers) {
|
|
573
|
+
if (timerSessionKey === sessionKey) {
|
|
574
|
+
clearTimeout(timer);
|
|
575
|
+
simulateStreamTimers.delete(timer);
|
|
576
|
+
cleared += 1;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
if (cleared > 0) {
|
|
580
|
+
logger.info(
|
|
581
|
+
`[relay] cancelled ${cleared} pending simulate-stream timer(s) for session ${sessionKey}`,
|
|
582
|
+
);
|
|
583
|
+
}
|
|
584
|
+
return cleared;
|
|
585
|
+
}
|
|
586
|
+
|
|
566
587
|
function clearSimulateStreamTimers() {
|
|
567
|
-
for (const timer of simulateStreamTimers) {
|
|
588
|
+
for (const timer of simulateStreamTimers.keys()) {
|
|
568
589
|
clearTimeout(timer);
|
|
569
590
|
}
|
|
570
591
|
simulateStreamTimers.clear();
|
|
@@ -923,37 +944,67 @@ function createRelay(opts) {
|
|
|
923
944
|
systemPrompt: opts.ocuClawSystemPrompt,
|
|
924
945
|
},
|
|
925
946
|
});
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
947
|
+
const stablePromptSnapshots = createStablePromptSnapshotStore({
|
|
948
|
+
stateDir: opts.stateDir,
|
|
949
|
+
emitDebug,
|
|
950
|
+
});
|
|
951
|
+
// Hourly TTL sweep for stale stable-prompt snapshots; started in start(),
|
|
952
|
+
// cleared in stop(). Declared here so both can see it.
|
|
953
|
+
let stablePromptSweepTimer = null;
|
|
954
|
+
|
|
955
|
+
// Channel 1: the per-session-immutable extraSystemPrompt. Computed once from
|
|
956
|
+
// the feature set AT SESSION START and then served byte-identical for the
|
|
957
|
+
// session's lifetime (see stable-prompt-snapshot). Mid-session toggles are
|
|
958
|
+
// bridged by the Channel-2 hook composer, NOT here. The glasses-UI pointer is
|
|
959
|
+
// always present (its disconnected gate moved to Channel 2); the emoji/pace
|
|
960
|
+
// blocks are included only when active at session start.
|
|
961
|
+
function computeStableChannelOne(startSignals) {
|
|
929
962
|
const baseReadability = composeReadabilitySystemPrompt(
|
|
930
963
|
ocuClawSettingsStore.getSnapshot().systemPrompt,
|
|
931
964
|
);
|
|
932
|
-
const
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
const
|
|
937
|
-
const paceState = validState(signals.neuralPaceModulatorState);
|
|
938
|
-
const reactor = composeNeuralEmojiReactorSystemPrompt({ state: reactorState });
|
|
939
|
-
const pace = composeNeuralPaceModulatorSystemPrompt({ state: paceState });
|
|
940
|
-
// Only include the glasses-UI nudge when a downstream app client is
|
|
941
|
-
// connected. Keeps the prompt clean when the agent has nowhere to render
|
|
942
|
-
// the tool's output, and keeps existing prompt-assembly tests stable
|
|
943
|
-
// (they exercise the prompt without spinning up an app client).
|
|
944
|
-
const hasAppClient =
|
|
945
|
-
server &&
|
|
946
|
-
typeof server.getConnectedAppCount === "function" &&
|
|
947
|
-
server.getConnectedAppCount() > 0;
|
|
948
|
-
const glassesUiNudge = hasAppClient ? composeGlassesUiNudgeSystemPrompt() : "";
|
|
965
|
+
const display = composeGlassesDisplaySystemPrompt({
|
|
966
|
+
emoji: startSignals.emoji,
|
|
967
|
+
pace: startSignals.pace,
|
|
968
|
+
});
|
|
969
|
+
const glassesPointer = composeGlassesUiNudgeSystemPrompt();
|
|
949
970
|
const parts = [];
|
|
950
971
|
if (baseReadability) parts.push(baseReadability);
|
|
951
|
-
if (
|
|
952
|
-
if (
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
972
|
+
if (display) parts.push(display);
|
|
973
|
+
if (glassesPointer) parts.push(glassesPointer);
|
|
974
|
+
return parts.join("\n\n");
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
function stableSendOptions(resolvedSessionKey, sessionId, perTurnSignals) {
|
|
978
|
+
const signals = perTurnSignals || {};
|
|
979
|
+
// "enabled at start" = the client's reported state on the FIRST send of the
|
|
980
|
+
// session. The 3-state client signal maps to a boolean: only "active" counts.
|
|
981
|
+
const startEmoji = signals.neuralEmojiReactorState === "active";
|
|
982
|
+
const startPace = signals.neuralPaceModulatorState === "active";
|
|
983
|
+
const extraSystemPrompt = stablePromptSnapshots.getOrCreate(
|
|
984
|
+
resolvedSessionKey,
|
|
985
|
+
sessionId,
|
|
986
|
+
() => computeStableChannelOne({ emoji: startEmoji, pace: startPace }),
|
|
987
|
+
);
|
|
988
|
+
// Churn guard: if recomputing TODAY would differ from the served snapshot,
|
|
989
|
+
// something is mutating Channel 1 mid-session (e.g. a toggle flipped the
|
|
990
|
+
// start-state signals) — the case that used to reset the CLI session.
|
|
991
|
+
// Surface it loudly; the served prompt stays the frozen snapshot.
|
|
992
|
+
if (
|
|
993
|
+
stablePromptSnapshots.wouldChurn(
|
|
994
|
+
resolvedSessionKey,
|
|
995
|
+
sessionId,
|
|
996
|
+
computeStableChannelOne({ emoji: startEmoji, pace: startPace }),
|
|
997
|
+
)
|
|
998
|
+
) {
|
|
999
|
+
emitDebug(
|
|
1000
|
+
"relay.session",
|
|
1001
|
+
"stable_prompt_churn_detected",
|
|
1002
|
+
"warn",
|
|
1003
|
+
{ sessionKey: resolvedSessionKey },
|
|
1004
|
+
() => ({ sessionId: sessionId || null }),
|
|
1005
|
+
);
|
|
1006
|
+
}
|
|
1007
|
+
return { extraSystemPrompt };
|
|
957
1008
|
}
|
|
958
1009
|
|
|
959
1010
|
function buildOcuClawSendDiagnostic(params = {}) {
|
|
@@ -1465,6 +1516,10 @@ function createRelay(opts) {
|
|
|
1465
1516
|
resolvedSessionKey,
|
|
1466
1517
|
clientDisplaySignals.neuralSessionNamesEnabled !== false,
|
|
1467
1518
|
);
|
|
1519
|
+
sessionService.recordDisplayToggleStates(resolvedSessionKey, {
|
|
1520
|
+
emoji: clientDisplaySignals.neuralEmojiReactorState === "active",
|
|
1521
|
+
pace: clientDisplaySignals.neuralPaceModulatorState === "active",
|
|
1522
|
+
});
|
|
1468
1523
|
}
|
|
1469
1524
|
const hasAttachment = !!attachment;
|
|
1470
1525
|
const sendStartedAt = Date.now();
|
|
@@ -1494,7 +1549,16 @@ function createRelay(opts) {
|
|
|
1494
1549
|
resolvedSessionKey,
|
|
1495
1550
|
attachment,
|
|
1496
1551
|
{
|
|
1497
|
-
...
|
|
1552
|
+
...stableSendOptions(
|
|
1553
|
+
resolvedSessionKey,
|
|
1554
|
+
// No synchronous OpenClaw sessionId is available at send time
|
|
1555
|
+
// (resolveSessionCanonicalKey is async). Use the sessionKey as the
|
|
1556
|
+
// snapshot's id; the sessionId-mismatch guard is therefore a no-op,
|
|
1557
|
+
// and new-session safety rests on logical-session-end eviction
|
|
1558
|
+
// (onNewSession / onNewChat / onDeleteSessions evict the snapshot).
|
|
1559
|
+
resolvedSessionKey,
|
|
1560
|
+
clientDisplaySignals,
|
|
1561
|
+
),
|
|
1498
1562
|
diagnostic: buildOcuClawSendDiagnostic({
|
|
1499
1563
|
...params,
|
|
1500
1564
|
sessionKey: resolvedSessionKey,
|
|
@@ -1768,6 +1832,14 @@ function createRelay(opts) {
|
|
|
1768
1832
|
return upstreamRuntime.compactActiveSession(sessionKey);
|
|
1769
1833
|
},
|
|
1770
1834
|
onDeleteSessions(sessionKeys, kind, switchBeforeDelete) {
|
|
1835
|
+
if (Array.isArray(sessionKeys)) {
|
|
1836
|
+
for (const key of sessionKeys) {
|
|
1837
|
+
if (typeof key === "string" && key.trim()) {
|
|
1838
|
+
stablePromptSnapshots.evict(key);
|
|
1839
|
+
sessionService.clearDisplayToggleStates(key);
|
|
1840
|
+
}
|
|
1841
|
+
}
|
|
1842
|
+
}
|
|
1771
1843
|
const action = switchBeforeDelete
|
|
1772
1844
|
? sessionService.switchAndDeleteSessions(kind, sessionKeys)
|
|
1773
1845
|
: sessionService.deleteSessions(kind, sessionKeys);
|
|
@@ -1904,7 +1976,7 @@ function createRelay(opts) {
|
|
|
1904
1976
|
totalChars: text.length,
|
|
1905
1977
|
}),
|
|
1906
1978
|
);
|
|
1907
|
-
});
|
|
1979
|
+
}, sessionKey);
|
|
1908
1980
|
}
|
|
1909
1981
|
|
|
1910
1982
|
const completeDelayMs = startDelayMs + (chunkCount * chunkIntervalMs) + thinkingTailMs;
|
|
@@ -1935,7 +2007,7 @@ function createRelay(opts) {
|
|
|
1935
2007
|
completeDelayMs,
|
|
1936
2008
|
}),
|
|
1937
2009
|
);
|
|
1938
|
-
});
|
|
2010
|
+
}, sessionKey);
|
|
1939
2011
|
|
|
1940
2012
|
return Promise.resolve({
|
|
1941
2013
|
status: "accepted",
|
|
@@ -1962,7 +2034,18 @@ function createRelay(opts) {
|
|
|
1962
2034
|
}
|
|
1963
2035
|
sessionService.invalidateSessionsCache();
|
|
1964
2036
|
resetActivityStatusAdapter();
|
|
2037
|
+
// Cancel THIS session's pending simulate-stream timers BEFORE clearing —
|
|
2038
|
+
// a deferred addMessage firing after the clear repopulates the fresh chat
|
|
2039
|
+
// (the 2026-05-15 canary-pollution mechanism).
|
|
2040
|
+
clearSimulateStreamTimersForSession(sessionService.ensureSessionKey());
|
|
1965
2041
|
conversationState.clear();
|
|
2042
|
+
// Logical session end: this key is REUSED for a fresh conversation, so the
|
|
2043
|
+
// Channel-1 snapshot must be dropped or the next send serves a stale prompt,
|
|
2044
|
+
// and ALL other per-session state keyed to it (title + upstream name,
|
|
2045
|
+
// toggles, distiller budget, first-user marker) must be cleared too.
|
|
2046
|
+
const newChatSessionKey = sessionService.ensureSessionKey();
|
|
2047
|
+
stablePromptSnapshots.evict(newChatSessionKey);
|
|
2048
|
+
sessionService.clearLogicalSessionState(newChatSessionKey);
|
|
1966
2049
|
conversationState.setAgentName(
|
|
1967
2050
|
(upstreamRuntime ? upstreamRuntime.getAgentName() : null) || "Agent",
|
|
1968
2051
|
);
|
|
@@ -2000,7 +2083,20 @@ function createRelay(opts) {
|
|
|
2000
2083
|
},
|
|
2001
2084
|
|
|
2002
2085
|
async onNewSession() {
|
|
2086
|
+
// Cancel pending simulate-stream timers scheduled under the outgoing key
|
|
2087
|
+
// BEFORE the new key is minted — a deferred addMessage firing after the
|
|
2088
|
+
// switch would repopulate the fresh session's shared conversation view.
|
|
2089
|
+
clearSimulateStreamTimersForSession(sessionService.ensureSessionKey());
|
|
2003
2090
|
const result = await sessionService.newSession();
|
|
2091
|
+
// newSession() mints a FRESH key; defensively clear only the NEW key (it
|
|
2092
|
+
// has no snapshot yet). Do NOT touch the previous key: that session stays
|
|
2093
|
+
// resumable via onSwitchSession, and dropping its frozen snapshot would
|
|
2094
|
+
// recompute — and churn — Channel 1 if the user switches back to it. The
|
|
2095
|
+
// previous session's snapshot is released by delete or the TTL sweep.
|
|
2096
|
+
if (result && typeof result.sessionKey === "string" && result.sessionKey.trim()) {
|
|
2097
|
+
stablePromptSnapshots.evict(result.sessionKey);
|
|
2098
|
+
sessionService.clearDisplayToggleStates(result.sessionKey);
|
|
2099
|
+
}
|
|
2004
2100
|
clearCurrentSessionModelConfigSnapshot("new_session");
|
|
2005
2101
|
if (upstreamRuntime && typeof upstreamRuntime.clearTyping === "function") {
|
|
2006
2102
|
upstreamRuntime.clearTyping("new_session");
|
|
@@ -2110,6 +2206,7 @@ function createRelay(opts) {
|
|
|
2110
2206
|
if (command === "/reset") {
|
|
2111
2207
|
sessionService.invalidateSessionsCache();
|
|
2112
2208
|
resetActivityStatusAdapter();
|
|
2209
|
+
clearSimulateStreamTimersForSession(sessionService.ensureSessionKey());
|
|
2113
2210
|
conversationState.clear();
|
|
2114
2211
|
if (upstreamRuntime && typeof upstreamRuntime.clearTyping === "function") {
|
|
2115
2212
|
upstreamRuntime.clearTyping("slash_reset");
|
|
@@ -2119,6 +2216,19 @@ function createRelay(opts) {
|
|
|
2119
2216
|
);
|
|
2120
2217
|
broadcastPages();
|
|
2121
2218
|
}
|
|
2219
|
+
// A user-typed /new or /reset is a logical session reset on the CURRENT
|
|
2220
|
+
// key (distinct from an automatic CLI session reset, which keeps the same
|
|
2221
|
+
// logical session and must survive). Drop the frozen Channel-1 snapshot so
|
|
2222
|
+
// the next real message recomputes it for the fresh conversation; otherwise
|
|
2223
|
+
// the old conversation's prompt + display start-state bleed into the new one.
|
|
2224
|
+
if (command === "/new" || command === "/reset") {
|
|
2225
|
+
const resetKey = sessionService.ensureSessionKey();
|
|
2226
|
+
stablePromptSnapshots.evict(resetKey);
|
|
2227
|
+
// Clear ALL per-session state keyed to the reused key (title + upstream
|
|
2228
|
+
// name, toggles, distiller budget, first-user marker) so nothing from the
|
|
2229
|
+
// old conversation bleeds into the fresh one.
|
|
2230
|
+
sessionService.clearLogicalSessionState(resetKey);
|
|
2231
|
+
}
|
|
2122
2232
|
if (upstreamRuntime && upstreamRuntime.isConnected()) {
|
|
2123
2233
|
// Bare /reset no longer elicits an agent turn on OpenClaw 2026.6.x
|
|
2124
2234
|
// (fast-reset). Append the greeting prompt so Reset gets the same
|
|
@@ -3013,6 +3123,16 @@ function createRelay(opts) {
|
|
|
3013
3123
|
* The downstream server is already listening from construction.
|
|
3014
3124
|
*/
|
|
3015
3125
|
start() {
|
|
3126
|
+
// Bounded cleanup of stale stable-prompt snapshots (14-day TTL).
|
|
3127
|
+
if (!stablePromptSweepTimer) {
|
|
3128
|
+
stablePromptSweepTimer = setInterval(
|
|
3129
|
+
() => stablePromptSnapshots.sweep(),
|
|
3130
|
+
60 * 60 * 1000, // hourly
|
|
3131
|
+
);
|
|
3132
|
+
if (typeof stablePromptSweepTimer.unref === "function") {
|
|
3133
|
+
stablePromptSweepTimer.unref();
|
|
3134
|
+
}
|
|
3135
|
+
}
|
|
3016
3136
|
const startGateway = () => Promise.resolve(gatewayBridge.start()).then(() => {
|
|
3017
3137
|
prefetchSonioxModels("relay_start").catch((err) => {
|
|
3018
3138
|
logger.warn(`[relay] Soniox models prefetch failed: ${err.message}`);
|
|
@@ -3034,6 +3154,10 @@ function createRelay(opts) {
|
|
|
3034
3154
|
*/
|
|
3035
3155
|
stop() {
|
|
3036
3156
|
clearSimulateStreamTimers();
|
|
3157
|
+
if (stablePromptSweepTimer) {
|
|
3158
|
+
clearInterval(stablePromptSweepTimer);
|
|
3159
|
+
stablePromptSweepTimer = null;
|
|
3160
|
+
}
|
|
3037
3161
|
if (evenAiEndpoint) {
|
|
3038
3162
|
evenAiEndpoint.close();
|
|
3039
3163
|
}
|
|
@@ -3113,6 +3237,49 @@ function createRelay(opts) {
|
|
|
3113
3237
|
return sessionService.isSessionUserLocked(sessionKey);
|
|
3114
3238
|
},
|
|
3115
3239
|
|
|
3240
|
+
getDisplayStartStates(sessionKey) {
|
|
3241
|
+
return sessionService.getDisplayStartStates(sessionKey);
|
|
3242
|
+
},
|
|
3243
|
+
|
|
3244
|
+
getDisplayCurrentStates(sessionKey) {
|
|
3245
|
+
return sessionService.getDisplayCurrentStates(sessionKey);
|
|
3246
|
+
},
|
|
3247
|
+
|
|
3248
|
+
// Accessors used by the session-title distiller sidecar.
|
|
3249
|
+
getSessionTitleRecord(sessionKey) {
|
|
3250
|
+
return sessionService.getSessionTitleRecord(sessionKey);
|
|
3251
|
+
},
|
|
3252
|
+
isEvenAiSessionKey(sessionKey) {
|
|
3253
|
+
return sessionService.isEvenAiSessionKey(sessionKey);
|
|
3254
|
+
},
|
|
3255
|
+
getRawMessages() {
|
|
3256
|
+
return conversationState.getRawMessages();
|
|
3257
|
+
},
|
|
3258
|
+
getDistillerBudget() {
|
|
3259
|
+
return sessionService.getDistillerBudget();
|
|
3260
|
+
},
|
|
3261
|
+
// Canonical-key cleanup for the distiller's throwaway upstream session.
|
|
3262
|
+
// The native subagent deleteSession passes the bare key straight to
|
|
3263
|
+
// sessions.delete, which the 2026.6.x gateway indexes under the canonical
|
|
3264
|
+
// agent:<id>: form — the bare-key delete silently no-ops and the
|
|
3265
|
+
// excerpt-bearing transcript survives. deleteSessions() resolves the
|
|
3266
|
+
// canonical key via sessions.resolve first.
|
|
3267
|
+
deleteDistillerSession(sessionKey) {
|
|
3268
|
+
return sessionService.deleteSessions("ocuclaw", [sessionKey]);
|
|
3269
|
+
},
|
|
3270
|
+
getStateDir() {
|
|
3271
|
+
return opts.stateDir;
|
|
3272
|
+
},
|
|
3273
|
+
emitDebug(...args) {
|
|
3274
|
+
return emitDebug(...args);
|
|
3275
|
+
},
|
|
3276
|
+
gatewayRequest(method, params, requestOpts) {
|
|
3277
|
+
return gatewayBridge.request(method, params, requestOpts);
|
|
3278
|
+
},
|
|
3279
|
+
onGatewayEvent(eventName, listener) {
|
|
3280
|
+
return gatewayBridge.on(eventName, listener);
|
|
3281
|
+
},
|
|
3282
|
+
|
|
3116
3283
|
peekSessionKey() {
|
|
3117
3284
|
return sessionService.peekSessionKey();
|
|
3118
3285
|
},
|
|
@@ -3147,6 +3314,15 @@ function createRelay(opts) {
|
|
|
3147
3314
|
return dispatchOcuClawUserSend(params || {});
|
|
3148
3315
|
},
|
|
3149
3316
|
|
|
3317
|
+
/**
|
|
3318
|
+
* Test-only: run the logical-reset state clear (the same call the /new,
|
|
3319
|
+
* /reset, and new-chat paths make) so integration tests can verify all
|
|
3320
|
+
* per-session state is dropped for a reused session key.
|
|
3321
|
+
*/
|
|
3322
|
+
_clearLogicalSessionState(sessionKey) {
|
|
3323
|
+
sessionService.clearLogicalSessionState(sessionKey);
|
|
3324
|
+
},
|
|
3325
|
+
|
|
3150
3326
|
sendGlassesUiRender(params) {
|
|
3151
3327
|
sendGlassesUiRender(params);
|
|
3152
3328
|
},
|