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.
Files changed (37) 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/register-session-title-distiller.js +100 -0
  16. package/dist/runtime/relay-core.js +209 -33
  17. package/dist/runtime/relay-service.js +120 -13
  18. package/dist/runtime/relay-worker-entry.js +26 -0
  19. package/dist/runtime/relay-worker-supervisor.js +43 -2
  20. package/dist/runtime/relay-worker-transport.js +41 -0
  21. package/dist/runtime/session-service.js +136 -12
  22. package/dist/runtime/session-title-distiller-budget.js +36 -0
  23. package/dist/runtime/session-title-distiller-helpers.js +130 -0
  24. package/dist/runtime/session-title-distiller.js +354 -0
  25. package/dist/runtime/session-title-record.js +21 -0
  26. package/dist/runtime/stable-prompt-snapshot.js +119 -0
  27. package/dist/tools/glasses-ui-cron.js +9 -3
  28. package/dist/tools/glasses-ui-paint-floor.js +10 -3
  29. package/dist/tools/glasses-ui-surfaces.js +8 -1
  30. package/dist/tools/glasses-ui-tool-description.test.js +16 -0
  31. package/dist/tools/glasses-ui-tool.js +88 -14
  32. package/dist/tools/session-title-tool.js +14 -76
  33. package/dist/tools/session-title-tool.test.js +53 -0
  34. package/dist/version.js +2 -2
  35. package/openclaw.plugin.json +9 -0
  36. package/package.json +4 -3
  37. 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
- const simulateStreamTimers = new Set();
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.add(timer);
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
- function currentOcuClawSendOptions(perTurnSignals) {
928
- const signals = perTurnSignals || {};
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 validState = (raw) =>
933
- raw === "active" || raw === "recently-disabled" || raw === "inactive"
934
- ? raw
935
- : "inactive";
936
- const reactorState = validState(signals.neuralEmojiReactorState);
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 (reactor) parts.push(reactor);
952
- if (pace) parts.push(pace);
953
- if (glassesUiNudge) parts.push(glassesUiNudge);
954
- return {
955
- extraSystemPrompt: parts.join("\n\n"),
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
- ...currentOcuClawSendOptions(clientDisplaySignals),
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
  },