ocuclaw 1.3.3 → 1.3.4

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 (83) hide show
  1. package/README.md +29 -1
  2. package/dist/config/runtime-config-session-title-model.test.js +0 -3
  3. package/dist/config/runtime-config.js +22 -33
  4. package/dist/domain/activity-status-adapter.js +0 -7
  5. package/dist/domain/activity-status-arbiter.js +3 -27
  6. package/dist/domain/activity-status-labels.js +8 -38
  7. package/dist/domain/code-span-regions.js +4 -24
  8. package/dist/domain/constant-time-equal.js +9 -0
  9. package/dist/domain/constant-time-equal.test.js +28 -0
  10. package/dist/domain/conversation-state.js +27 -138
  11. package/dist/domain/debug-bundle-cache.js +52 -0
  12. package/dist/domain/debug-bundle-format.js +60 -0
  13. package/dist/domain/debug-bundle-preview.js +123 -0
  14. package/dist/domain/debug-bundle-redaction.js +182 -0
  15. package/dist/domain/debug-bundle-save.js +11 -0
  16. package/dist/domain/debug-bundle-zip.js +15 -0
  17. package/dist/domain/debug-bundle.js +97 -0
  18. package/dist/domain/debug-store.js +6 -17
  19. package/dist/domain/debug-upload-preset.js +27 -0
  20. package/dist/domain/glasses-display-system-prompt.js +0 -5
  21. package/dist/domain/glasses-display-system-prompt.test.js +1 -1
  22. package/dist/domain/glasses-ui-content-summary.js +0 -6
  23. package/dist/domain/glasses-ui-system-prompt.test.js +1 -2
  24. package/dist/domain/message-emoji-allowlist.js +0 -7
  25. package/dist/domain/message-emoji-filter.js +3 -9
  26. package/dist/domain/neural-emoji-reactor-tag-config.js +3 -3
  27. package/dist/domain/prompt-channel-fragments.js +1 -10
  28. package/dist/domain/tagged-span-parser.js +3 -26
  29. package/dist/domain/tagged-span-strip.js +0 -7
  30. package/dist/even-ai/even-ai-endpoint.js +77 -24
  31. package/dist/even-ai/even-ai-run-waiter.js +0 -1
  32. package/dist/even-ai/even-ai-settings-store.js +11 -0
  33. package/dist/gateway/gateway-bridge.js +8 -9
  34. package/dist/gateway/gateway-timing-ledger.js +8 -6
  35. package/dist/gateway/openclaw-client.js +97 -297
  36. package/dist/gateway/sanitize-connect-reason.js +10 -0
  37. package/dist/gateway/sanitize-connect-reason.test.js +34 -0
  38. package/dist/index.js +3 -3
  39. package/dist/runtime/channel-two-hook.js +1 -6
  40. package/dist/runtime/container-env.js +1 -5
  41. package/dist/runtime/debug-bundle-handler.js +159 -0
  42. package/dist/runtime/display-toggle-states.js +6 -17
  43. package/dist/runtime/downstream-handler.js +682 -508
  44. package/dist/runtime/glasses-backpressure-latch.js +2 -24
  45. package/dist/runtime/ocuclaw-settings-store.js +10 -1
  46. package/dist/runtime/openclaw-host-version.js +5 -0
  47. package/dist/runtime/plugin-version-service.js +13 -6
  48. package/dist/runtime/provider-usage-select.js +0 -6
  49. package/dist/runtime/register-session-title-distiller.js +14 -16
  50. package/dist/runtime/relay-core.js +601 -290
  51. package/dist/runtime/relay-service.js +19 -47
  52. package/dist/runtime/relay-worker-approval-replay-cache.js +1 -1
  53. package/dist/runtime/relay-worker-entry.js +1 -2
  54. package/dist/runtime/relay-worker-health.js +2 -10
  55. package/dist/runtime/relay-worker-protocol.js +6 -1
  56. package/dist/runtime/relay-worker-supervisor.js +103 -41
  57. package/dist/runtime/relay-worker-transport.js +150 -17
  58. package/dist/runtime/session-context-service.js +5 -45
  59. package/dist/runtime/session-service.js +157 -175
  60. package/dist/runtime/session-title-distiller-budget.js +1 -5
  61. package/dist/runtime/session-title-distiller-helpers.js +14 -24
  62. package/dist/runtime/session-title-distiller.js +109 -122
  63. package/dist/runtime/session-title-record.js +0 -6
  64. package/dist/runtime/stable-prompt-snapshot.js +3 -14
  65. package/dist/runtime/upstream-runtime.js +600 -103
  66. package/dist/tools/device-info-tool.js +4 -21
  67. package/dist/tools/glasses-ui-cron.js +22 -77
  68. package/dist/tools/glasses-ui-descriptors.js +4 -33
  69. package/dist/tools/glasses-ui-limits.js +0 -13
  70. package/dist/tools/glasses-ui-paint-floor.js +5 -39
  71. package/dist/tools/glasses-ui-recipes.js +92 -101
  72. package/dist/tools/glasses-ui-surfaces.js +31 -163
  73. package/dist/tools/glasses-ui-template.js +7 -22
  74. package/dist/tools/glasses-ui-tool-description.test.js +2 -2
  75. package/dist/tools/glasses-ui-tool.js +87 -451
  76. package/dist/tools/glasses-ui-voicemail.js +6 -63
  77. package/dist/tools/glasses-ui-wake.js +9 -76
  78. package/dist/tools/session-title-tool.js +2 -7
  79. package/dist/tools/session-title-tool.test.js +1 -1
  80. package/dist/version.js +3 -2
  81. package/openclaw.plugin.json +60 -13
  82. package/package.json +3 -2
  83. package/dist/runtime/protocol-adapter.js +0 -387
@@ -8,27 +8,12 @@ import { createDistillerBudget } from "./session-title-distiller-budget.js";
8
8
  const SESSION_FIRST_USER_CACHE_FILE = "session-first-user-cache.json";
9
9
  const SESSION_TITLE_CACHE_FILE = "session-title-cache.json";
10
10
  const SESSION_PIN_CACHE_FILE = "ocuclaw-session-pins.json";
11
+ const SESSION_AGENT_CACHE_FILE = "ocuclaw-session-agents.json";
11
12
  const PIN_CAP_PER_KIND = 20;
12
13
 
13
- /**
14
- * Greeting-eliciting content appended to /new and /reset so OpenClaw runs an
15
- * agent turn (the new-session "welcome"). OpenClaw 2026.6.x ("make bare reset
16
- * commands fast", gateway commit 2c6a3f6b04) made a BARE /new or /reset return
17
- * a synchronous ack and run NO agent turn — so a bare reset no longer produces
18
- * a welcome and leaves the glasses stuck on the "starting new session"
19
- * placeholder. Sending "/new <prompt>" / "/reset <prompt>" routes through the
20
- * gateway's normal agent path (content = the prompt) and elicits the welcome.
21
- * The wording matches the prompt OpenClaw used to inject itself, so the
22
- * existing synthetic-session-starter filters (conversation-state display hide +
23
- * isSyntheticSessionStarter title/preview filter) keep it hidden from the
24
- * transcript and session titles. NOTE: newSession() does NOT call
25
- * conversationState.addMessage("user", ...), so this content never renders as a
26
- * user bubble on the glasses.
27
- */
28
14
  export const NEW_SESSION_GREETING_PROMPT =
29
15
  "A new session was started via /new or /reset. Execute your Session Startup sequence now - read the required files before responding to the user. If BOOTSTRAP.md exists in the provided Project Context, read it and follow its instructions first. Then greet the user in your configured persona, if one is provided. Be yourself - use your defined voice, mannerisms, and mood. Keep it to 1-3 sentences and ask what they want to do. If the runtime model differs from default_model in the system prompt, mention the default model. Do not mention internal steps, files, tools, or reasoning.";
30
16
 
31
-
32
17
  function normalizeLogger(logger) {
33
18
  if (!logger || typeof logger !== "object") {
34
19
  return console;
@@ -66,6 +51,18 @@ function resolveSessionPinCachePath(stateDir) {
66
51
  return path.join(resolvedStateDir, SESSION_PIN_CACHE_FILE);
67
52
  }
68
53
 
54
+ function resolveSessionAgentCachePath(stateDir) {
55
+ const resolvedStateDir = normalizeStateDir(stateDir);
56
+ if (!resolvedStateDir) return null;
57
+ return path.join(resolvedStateDir, SESSION_AGENT_CACHE_FILE);
58
+ }
59
+
60
+ function deriveAgentIdFromFullKey(fullKey) {
61
+ if (typeof fullKey !== "string") return "";
62
+ const match = /^agent:([a-z0-9][a-z0-9_-]*):/i.exec(fullKey.trim());
63
+ return match ? match[1] : "";
64
+ }
65
+
69
66
  function sanitizeAssistantContentBlocks(content) {
70
67
  if (typeof content === "string") {
71
68
  return stripAllTaggedSpans(content);
@@ -85,6 +82,14 @@ export function createSessionService(opts = {}) {
85
82
  const emitDebug = typeof opts.emitDebug === "function" ? opts.emitDebug : () => {};
86
83
  const getAgentName =
87
84
  typeof opts.getAgentName === "function" ? opts.getAgentName : () => null;
85
+ const getAgentDisplayName =
86
+ typeof opts.getAgentDisplayName === "function"
87
+ ? opts.getAgentDisplayName
88
+ : () => null;
89
+ const getDefaultAgentId =
90
+ typeof opts.getDefaultAgentId === "function"
91
+ ? opts.getDefaultAgentId
92
+ : () => "";
88
93
  const isUpstreamConnected =
89
94
  typeof opts.isUpstreamConnected === "function"
90
95
  ? opts.isUpstreamConnected
@@ -108,9 +113,8 @@ export function createSessionService(opts = {}) {
108
113
  ? opts.isPinnedFirstUserMessageKey
109
114
  : null;
110
115
 
111
- /** Current session key. Generated on first use. */
112
116
  let currentSessionKey = null;
113
- /** Locally-created session key pending visibility in upstream sessions.list. */
117
+
114
118
  let pendingSessionListKey = null;
115
119
  let lastGeneratedSessionTimestamp = 0;
116
120
  const DEFAULT_SESSION_KEY_PREFIX =
@@ -127,68 +131,54 @@ export function createSessionService(opts = {}) {
127
131
  (prefix) => String(prefix || "").toLowerCase(),
128
132
  );
129
133
 
130
- /** Maximum number of sessions to fetch. */
131
- // Default raised to 100 so the WebUI Sessions panel can show a full
132
- // scrollable history without being chopped to 10 most-recent.
133
134
  const sessionLimit = opts.sessionLimit || 100;
134
- /** Whether to persist first real user message cache to disk. */
135
+
135
136
  const persistFirstUserMessages = opts.persistFirstUserMessages !== false;
136
- /**
137
- * Strict mode by default: only use first real downstream sends for session preview labels.
138
- * Set to false to re-enable legacy chat.history/fallback extraction.
139
- */
137
+
140
138
  const strictFirstUserMessage = opts.strictFirstUserMessage !== false;
141
- /** Path for first real user message cache file. */
139
+
142
140
  const firstUserMessageCachePath = resolveSessionFirstUserMessageCachePath(
143
141
  opts.stateDir,
144
142
  );
145
143
 
146
- /** TTL for cached sessions.list payloads. */
147
144
  const sessionCacheTtlMs =
148
145
  Number.isFinite(opts.sessionCacheTtlMs) && opts.sessionCacheTtlMs > 0
149
146
  ? Math.floor(opts.sessionCacheTtlMs)
150
147
  : 5000;
151
- /** @type {Array<{key: string, updatedAt: number, preview: string, firstUserMessage: string, title: string|null, pinned: boolean, pinnedAtMs: number|null}>|null} */
148
+
152
149
  let cachedSessions = null;
153
- /** Epoch ms when cachedSessions was last refreshed. */
150
+
154
151
  let cachedSessionsFetchedAt = 0;
155
- /** @type {Promise<Array<{key: string, updatedAt: number, preview: string, firstUserMessage: string, title: string|null, pinned: boolean, pinnedAtMs: number|null}>>|null} */
152
+
156
153
  let inFlightSessionsFetch = null;
157
154
 
158
- /** Last-known session model config per session key. */
159
155
  const sessionModelConfigCache = new Map();
160
- /** New OcuClaw sessions awaiting one-time default model/thinking seeding. */
156
+
161
157
  const pendingInitialConfigSessionKeys = new Set();
162
- /** @type {Map<string, {updatedAt: number, firstUserMessage: string}>} */
158
+
163
159
  const firstUserMessageCache = new Map();
164
160
  const firstUserMessageCacheLimit = Math.max(64, sessionLimit * 8);
165
- /** @type {Map<string, string>} First real user text observed from downstream send events. */
161
+
166
162
  const firstSentUserMessageBySession = loadFirstSentUserMessageCache();
167
163
 
168
- /** Path for session title cache file. */
169
164
  const sessionTitleCachePath = resolveSessionTitleCachePath(opts.stateDir);
170
- /** @type {Map<string, {title: string, setAtMs: number, userSet: boolean}>} Per-session agent- or user-set title. */
165
+
171
166
  const sessionTitleByKey = loadSessionTitleCache();
172
- /** @type {Map<string, boolean>} Per-session Neural Session Names toggle state. */
167
+
173
168
  const neuralSessionNamesEnabledByKey = new Map();
174
- /** Per-session display-feature (emoji/pace) toggle states: frozen start + latest.
175
- * The frozen start-state persists to stateDir so a relay restart can't lose it
176
- * (the Channel-1 snapshot persists too — see stable-prompt-snapshot). */
169
+
177
170
  const displayToggleTracker = createDisplayToggleTracker({ stateDir: opts.stateDir });
178
- /** Per-session SKIP-exempt budget for the background title distiller. Owned
179
- * here (alongside the title record + toggle tracker) so a logical session
180
- * reset clears all per-session distiller state in one place. */
171
+
181
172
  const distillerBudget = createDistillerBudget({});
182
173
 
183
- /** Path for session pin metadata cache file. */
184
174
  const sessionPinCachePath = resolveSessionPinCachePath(opts.stateDir);
185
- /** @type {Map<string, {pinned: boolean, pinnedAtMs: number}>} Per-session pin metadata. */
175
+
186
176
  const sessionPinByKey = loadSessionPinCache();
187
177
 
188
- /**
189
- * Generate a new OcuClaw session key.
190
- * @returns {string} e.g. "ocuclaw:1739500000000"
191
- */
178
+ const sessionAgentCachePath = resolveSessionAgentCachePath(opts.stateDir);
179
+
180
+ const sessionAgentByKey = loadSessionAgentCache();
181
+
192
182
  function generateSessionKey(rawPrefix = DEFAULT_SESSION_KEY_PREFIX) {
193
183
  const effectivePrefix =
194
184
  typeof rawPrefix === "string" && rawPrefix.trim()
@@ -203,10 +193,6 @@ export function createSessionService(opts = {}) {
203
193
  return `${effectivePrefix}${nextTimestamp}`;
204
194
  }
205
195
 
206
- /**
207
- * Get or create the current session key.
208
- * @returns {string}
209
- */
210
196
  function ensureSessionKey() {
211
197
  if (!currentSessionKey) {
212
198
  currentSessionKey = generateSessionKey();
@@ -267,8 +253,6 @@ export function createSessionService(opts = {}) {
267
253
  return "off";
268
254
  }
269
255
 
270
- // Preserves all four gateway values — "ask" is the alias of "on" and must
271
- // not be collapsed, or snapshot read-back would lie about /elevated ask.
272
256
  function normalizeElevatedLevel(raw) {
273
257
  if (typeof raw !== "string") return "off";
274
258
  const normalized = raw.trim().toLowerCase();
@@ -312,6 +296,8 @@ export function createSessionService(opts = {}) {
312
296
  verboseLevel: normalizeVerboseLevel(row && row.verboseLevel),
313
297
  fastMode: !!(row && row.fastMode === true),
314
298
  elevatedLevel: normalizeElevatedLevel(row && row.elevatedLevel),
299
+
300
+ agentId: sessionAgentOverrideId(sessionKey),
315
301
  };
316
302
  }
317
303
 
@@ -320,7 +306,7 @@ export function createSessionService(opts = {}) {
320
306
  search,
321
307
  includeGlobal: false,
322
308
  includeUnknown: false,
323
- limit: 10,
309
+ limit: sessionLimit,
324
310
  });
325
311
  }
326
312
 
@@ -339,7 +325,7 @@ export function createSessionService(opts = {}) {
339
325
  return resolved.key.trim();
340
326
  }
341
327
  } catch {
342
- // keep raw key fallback
328
+
343
329
  }
344
330
  return sessionKey;
345
331
  }
@@ -433,6 +419,8 @@ export function createSessionService(opts = {}) {
433
419
  patch && Object.prototype.hasOwnProperty.call(patch, "elevatedLevel")
434
420
  ? normalizeElevatedLevel(patch.elevatedLevel)
435
421
  : base.elevatedLevel,
422
+
423
+ agentId: sessionAgentOverrideId(sessionKey),
436
424
  };
437
425
  sessionModelConfigCache.set(sessionKey, config);
438
426
  return config;
@@ -548,11 +536,6 @@ export function createSessionService(opts = {}) {
548
536
  return setSessionModelConfig(ensureSessionKey(), patch);
549
537
  }
550
538
 
551
- /**
552
- * Fetch the list of OcuClaw sessions from OpenClaw.
553
- * Filters to OcuClaw session key prefix, sorted by updatedAt descending.
554
- * @returns {Promise<Array<{key: string, updatedAt: number, preview: string, firstUserMessage: string, title: string|null, pinned: boolean, pinnedAtMs: number|null}>>}
555
- */
556
539
  async function getSessions() {
557
540
  if (cachedSessions && Date.now() - cachedSessionsFetchedAt < sessionCacheTtlMs) {
558
541
  return cachedSessions;
@@ -588,6 +571,7 @@ export function createSessionService(opts = {}) {
588
571
  row.messages,
589
572
  );
590
573
  const pinMeta = getSessionPin(key);
574
+ const agentFields = resolveSessionAgentFields(key, row.key);
591
575
  return {
592
576
  key,
593
577
  updatedAt,
@@ -600,6 +584,8 @@ export function createSessionService(opts = {}) {
600
584
  title: resolveRowTitle(key, row),
601
585
  pinned: pinMeta.pinned,
602
586
  pinnedAtMs: pinMeta.pinnedAtMs,
587
+ agentId: agentFields.agentId,
588
+ agentName: agentFields.agentName,
603
589
  };
604
590
  }),
605
591
  );
@@ -623,6 +609,10 @@ export function createSessionService(opts = {}) {
623
609
  [],
624
610
  );
625
611
  const pinMeta = getSessionPin(pendingSessionListKey);
612
+ const agentFields = resolveSessionAgentFields(
613
+ pendingSessionListKey,
614
+ pendingSessionListKey,
615
+ );
626
616
  sessions.unshift({
627
617
  key: pendingSessionListKey,
628
618
  updatedAt,
@@ -631,6 +621,8 @@ export function createSessionService(opts = {}) {
631
621
  title: resolveRowTitle(pendingSessionListKey, null),
632
622
  pinned: pinMeta.pinned,
633
623
  pinnedAtMs: pinMeta.pinnedAtMs,
624
+ agentId: agentFields.agentId,
625
+ agentName: agentFields.agentName,
634
626
  });
635
627
  }
636
628
  }
@@ -696,6 +688,7 @@ export function createSessionService(opts = {}) {
696
688
  fallbackMessages,
697
689
  );
698
690
  const pinMeta = getSessionPin(key);
691
+ const agentFields = resolveSessionAgentFields(key, row.key || sessionKey);
699
692
  sessions.push({
700
693
  key,
701
694
  updatedAt,
@@ -708,18 +701,22 @@ export function createSessionService(opts = {}) {
708
701
  title: resolveRowTitle(key, row),
709
702
  pinned: pinMeta.pinned,
710
703
  pinnedAtMs: pinMeta.pinnedAtMs,
704
+ agentId: agentFields.agentId,
705
+ agentName: agentFields.agentName,
711
706
  });
712
707
  }
713
708
 
714
709
  return sessions;
715
710
  }
716
711
 
717
- /**
718
- * Extract the short session key from a fully-qualified gateway key.
719
- * "agent:main:ocuclaw:1234567890" -> "ocuclaw:1234567890"
720
- * @param {string} fullKey
721
- * @returns {string}
722
- */
712
+ function resolveSessionAgentFields(shortKey, fullKey) {
713
+ const agentId = getSessionAgentId(shortKey, fullKey);
714
+ if (!agentId) {
715
+ return { agentId: null, agentName: null };
716
+ }
717
+ return { agentId, agentName: getAgentDisplayName(agentId) || agentId };
718
+ }
719
+
723
720
  function extractShortKey(fullKey) {
724
721
  if (typeof fullKey !== "string") return "";
725
722
  const fullKeyLower = fullKey.toLowerCase();
@@ -733,12 +730,6 @@ export function createSessionService(opts = {}) {
733
730
  return prefixIndex >= 0 ? fullKey.slice(prefixIndex) : fullKey;
734
731
  }
735
732
 
736
- /**
737
- * Check whether a full/canonical session key belongs to a supported
738
- * OcuClaw session namespace.
739
- * @param {string} key
740
- * @returns {boolean}
741
- */
742
733
  function hasSupportedSessionKeyPrefix(key) {
743
734
  if (typeof key !== "string" || key.length === 0) return false;
744
735
  const keyLower = key.toLowerCase();
@@ -759,12 +750,6 @@ export function createSessionService(opts = {}) {
759
750
  return left.toLowerCase() === right.toLowerCase();
760
751
  }
761
752
 
762
- /**
763
- * Best-effort timestamp extraction from session key suffix.
764
- * "ocuclaw:1739500000000" -> 1739500000000
765
- * @param {string} sessionKey
766
- * @returns {number}
767
- */
768
753
  function extractSessionTimestampFromKey(sessionKey) {
769
754
  if (typeof sessionKey !== "string") return 0;
770
755
  const idx = sessionKey.lastIndexOf(":");
@@ -773,11 +758,6 @@ export function createSessionService(opts = {}) {
773
758
  return Number.isFinite(maybeTs) && maybeTs > 0 ? maybeTs : 0;
774
759
  }
775
760
 
776
- /**
777
- * Extract a preview string from a session's messages array.
778
- * @param {Array} messages - Last N messages from sessions.list
779
- * @returns {string}
780
- */
781
761
  function extractPreview(messages) {
782
762
  if (!Array.isArray(messages) || messages.length === 0) return "";
783
763
  for (const msg of messages) {
@@ -791,9 +771,7 @@ export function createSessionService(opts = {}) {
791
771
  function resolveRowTitle(sessionKey, row) {
792
772
  const cached = getSessionTitle(sessionKey);
793
773
  if (cached !== null) return cached;
794
- // Upstream session-row label: `label` on 2026.6.x rows; older hosts
795
- // (≤5.27-era docs, session-management-compaction.md §169) used
796
- // `displayName` — accept both.
774
+
797
775
  const rawLabel =
798
776
  row && typeof row.label === "string"
799
777
  ? row.label
@@ -953,13 +931,85 @@ export function createSessionService(opts = {}) {
953
931
  return v ?? { pinned: false, pinnedAtMs: null };
954
932
  }
955
933
 
956
- /**
957
- * Set or clear the pin on a session.
958
- * @param {string} kind "ocuclaw" | "evenai"
959
- * @param {string} sessionKey
960
- * @param {boolean} pinned
961
- * @returns {{ ok: true } | { ok: false, reason: "cap" | "invalid" }}
962
- */
934
+ function loadSessionAgentCache() {
935
+ if (!sessionAgentCachePath) return new Map();
936
+ try {
937
+ if (!fs.existsSync(sessionAgentCachePath)) return new Map();
938
+ const raw = fs.readFileSync(sessionAgentCachePath, "utf8");
939
+ const parsed = JSON.parse(raw);
940
+ const out = new Map();
941
+ for (const [key, value] of Object.entries(parsed ?? {})) {
942
+ if (typeof value === "string" && value.trim()) {
943
+ out.set(key, value.trim());
944
+ }
945
+ }
946
+ return out;
947
+ } catch {
948
+ return new Map();
949
+ }
950
+ }
951
+
952
+ function persistSessionAgentCache() {
953
+ if (!sessionAgentCachePath) return;
954
+ try {
955
+ fs.mkdirSync(path.dirname(sessionAgentCachePath), { recursive: true });
956
+ const obj = {};
957
+ for (const [key, value] of sessionAgentByKey.entries()) {
958
+ obj[key] = value;
959
+ }
960
+ fs.writeFileSync(sessionAgentCachePath, JSON.stringify(obj), "utf8");
961
+ } catch (err) {
962
+ logger.error(
963
+ `[relay] Failed to persist session agent cache: ${err.message}`,
964
+ );
965
+ }
966
+ }
967
+
968
+ function explicitSessionAgentId(sessionKey, fullKey) {
969
+ const override = sessionAgentByKey.get(sessionKey);
970
+ if (typeof override === "string" && override.trim()) {
971
+ return override.trim();
972
+ }
973
+ return deriveAgentIdFromFullKey(fullKey || sessionKey) || "";
974
+ }
975
+
976
+ function sessionAgentOverrideId(sessionKey) {
977
+ const override = sessionAgentByKey.get(sessionKey);
978
+ return typeof override === "string" && override.trim()
979
+ ? override.trim()
980
+ : "";
981
+ }
982
+
983
+ function getSessionAgentId(sessionKey, fullKey) {
984
+ const explicit = explicitSessionAgentId(sessionKey, fullKey);
985
+ if (explicit) {
986
+ return explicit;
987
+ }
988
+ const fallback = getDefaultAgentId();
989
+ return typeof fallback === "string" && fallback.trim()
990
+ ? fallback.trim()
991
+ : "";
992
+ }
993
+
994
+ function hasExplicitSessionAgent(sessionKey, fullKey) {
995
+ return explicitSessionAgentId(sessionKey, fullKey) !== "";
996
+ }
997
+
998
+ function setSessionAgentId(sessionKey, agentId) {
999
+ if (typeof sessionKey !== "string" || !sessionKey.trim()) {
1000
+ return { ok: false, reason: "invalid" };
1001
+ }
1002
+ const normalized = typeof agentId === "string" ? agentId.trim() : "";
1003
+ if (normalized) {
1004
+ sessionAgentByKey.set(sessionKey, normalized);
1005
+ } else {
1006
+ sessionAgentByKey.delete(sessionKey);
1007
+ }
1008
+ persistSessionAgentCache();
1009
+ invalidateSessionsCache();
1010
+ return { ok: true };
1011
+ }
1012
+
963
1013
  function setSessionPinned(kind, sessionKey, pinned) {
964
1014
  if (!sessionKey || (kind !== "ocuclaw" && kind !== "evenai")) {
965
1015
  return { ok: false, reason: "invalid" };
@@ -979,13 +1029,6 @@ export function createSessionService(opts = {}) {
979
1029
  return { ok: true };
980
1030
  }
981
1031
 
982
- /**
983
- * Delete one or more sessions, sequentially.
984
- * Partial failures do not abort the batch.
985
- * @param {"ocuclaw"|"evenai"} kind
986
- * @param {string[]} sessionKeys
987
- * @returns {Promise<{ deleted: string[], failed: Array<{ key: string, reason: string }> }>}
988
- */
989
1032
  async function deleteSessions(kind, sessionKeys) {
990
1033
  const deleted = [];
991
1034
  const failed = [];
@@ -1007,14 +1050,6 @@ export function createSessionService(opts = {}) {
1007
1050
  return { deleted, failed };
1008
1051
  }
1009
1052
 
1010
- /**
1011
- * Deep transcript search across all OcuClaw sessions returned by getSessions().
1012
- * Returns at most `maxSnippets` snippets matching the query (case-insensitive),
1013
- * each with `before`/`match`/`after` slices so the UI can highlight the hit.
1014
- * @param {string} kind "ocuclaw" | "evenai"
1015
- * @param {string} query
1016
- * @returns {Promise<{snippets: Array<{sessionKey: string, role: string, updatedAtMs: number, before: string, match: string, after: string}>, truncated: boolean}>}
1017
- */
1018
1053
  async function searchTranscripts(kind, query) {
1019
1054
  const needle = (typeof query === "string" ? query.trim() : "").toLowerCase();
1020
1055
  if (!needle) return { snippets: [], truncated: false };
@@ -1083,9 +1118,7 @@ export function createSessionService(opts = {}) {
1083
1118
  }
1084
1119
 
1085
1120
  async function deleteSingleSession(kind, key) {
1086
- // OpenClaw gateway exposes `sessions.delete({key, deleteTranscript, emitLifecycleHooks})`.
1087
- // Resolve to a canonical key first so prefixes like "agent:main:" are applied
1088
- // when the gateway expects them.
1121
+
1089
1122
  const canonicalKey = await resolveSessionCanonicalKey(key);
1090
1123
  await gatewayBridge.request("sessions.delete", {
1091
1124
  key: canonicalKey,
@@ -1094,12 +1127,6 @@ export function createSessionService(opts = {}) {
1094
1127
  });
1095
1128
  }
1096
1129
 
1097
- /**
1098
- * Switch first if the active session is in the batch; then delete.
1099
- * Used when the UI sets `switchBeforeDelete=true`.
1100
- * @param {"ocuclaw"|"evenai"} kind
1101
- * @param {string[]} sessionKeys
1102
- */
1103
1130
  async function switchAndDeleteSessions(kind, sessionKeys) {
1104
1131
  if (
1105
1132
  kind === "ocuclaw" &&
@@ -1111,11 +1138,6 @@ export function createSessionService(opts = {}) {
1111
1138
  return deleteSessions(kind, sessionKeys);
1112
1139
  }
1113
1140
 
1114
- /**
1115
- * Re-fetch + broadcast the sessions snapshot for a given kind.
1116
- * Used by handlers after pin/delete writes.
1117
- * @param {"ocuclaw"|"evenai"} kind
1118
- */
1119
1141
  async function broadcastSessionsForKind(kind) {
1120
1142
  invalidateSessionsCache();
1121
1143
  if (kind === "ocuclaw" && typeof opts.broadcastSessions === "function") {
@@ -1185,17 +1207,6 @@ export function createSessionService(opts = {}) {
1185
1207
  }
1186
1208
  }
1187
1209
 
1188
- // Coalesced async persistence for the first-user-message cache. Mirrors the
1189
- // ocuclaw-settings-store writeInFlight/pendingWrite shape (commit 6285bc24)
1190
- // so the SYNC fs.mkdirSync + fs.writeFileSync no longer block the hot send
1191
- // path on every first user message per session.
1192
- //
1193
- // The write serializes the WHOLE in-memory map, so it is a full-snapshot
1194
- // replace (not a disk read-modify-write). Concurrent fire-and-forget writes
1195
- // could therefore lose updates (an older snapshot landing after a newer one).
1196
- // We coalesce: at most one write in flight; any schedule while a write is in
1197
- // flight marks the cache dirty, and the in-flight write re-runs once it
1198
- // settles — so the final on-disk file always reflects the latest map.
1199
1210
  let firstUserCacheWriteInFlight = false;
1200
1211
  let firstUserCacheDirty = false;
1201
1212
  let firstUserCacheFlushPromise = null;
@@ -1235,7 +1246,7 @@ export function createSessionService(opts = {}) {
1235
1246
  return;
1236
1247
  }
1237
1248
  if (!firstUserCacheDirty) {
1238
- // Nothing more to write — settle any awaiters and clear the flush gate.
1249
+
1239
1250
  if (firstUserCacheFlushResolve) {
1240
1251
  const resolve = firstUserCacheFlushResolve;
1241
1252
  firstUserCacheFlushResolve = null;
@@ -1248,8 +1259,7 @@ export function createSessionService(opts = {}) {
1248
1259
  firstUserCacheWriteInFlight = true;
1249
1260
  writeFirstSentUserMessageCacheToDisk().finally(() => {
1250
1261
  firstUserCacheWriteInFlight = false;
1251
- // Re-run drains any dirty mark accumulated during this write, then
1252
- // settles the flush gate once the map and disk agree.
1262
+
1253
1263
  runFirstSentUserMessageCacheWrite();
1254
1264
  });
1255
1265
  }
@@ -1260,9 +1270,6 @@ export function createSessionService(opts = {}) {
1260
1270
  runFirstSentUserMessageCacheWrite();
1261
1271
  }
1262
1272
 
1263
- // Awaitable flush: resolves once no write is in flight AND no dirty mark is
1264
- // pending (the on-disk file reflects the latest in-memory map). Used by tests
1265
- // after rapid sends and available for graceful shutdown.
1266
1273
  function flushFirstSentUserMessageCache() {
1267
1274
  if (!persistFirstUserMessages || !firstUserMessageCachePath) {
1268
1275
  return Promise.resolve();
@@ -1316,7 +1323,7 @@ export function createSessionService(opts = {}) {
1316
1323
  return { ok: false, code: "invalid_title" };
1317
1324
  }
1318
1325
  const trimmed = title.trim();
1319
- // Back-compat: callers passing { userSet:true } map to the user_tool origin.
1326
+
1320
1327
  const origin =
1321
1328
  opts && typeof opts.origin === "string" && opts.origin
1322
1329
  ? opts.origin
@@ -1347,7 +1354,7 @@ export function createSessionService(opts = {}) {
1347
1354
  { sessionKey },
1348
1355
  () => ({ sessionKey, title: trimmed, replaced, userSet: !!nextUserSet, origin }),
1349
1356
  );
1350
- // Fire-and-forget upstream mirror.
1357
+
1351
1358
  if (!isUpstreamConnected()) {
1352
1359
  emitDebug(
1353
1360
  "relay.session",
@@ -1360,8 +1367,7 @@ export function createSessionService(opts = {}) {
1360
1367
  if (isUpstreamConnected()) {
1361
1368
  resolveSessionCanonicalKey(sessionKey)
1362
1369
  .then((canonicalKey) =>
1363
- // 2026.6.x strict schema: the session title field is `label`
1364
- // (5.27-era `displayName` is rejected as an unexpected property).
1370
+
1365
1371
  gatewayBridge.request("sessions.patch", {
1366
1372
  key: canonicalKey,
1367
1373
  label: trimmed,
@@ -1428,12 +1434,7 @@ export function createSessionService(opts = {}) {
1428
1434
  distillerBudget.clear(sessionKey);
1429
1435
  }
1430
1436
  }
1431
- // Drop the stored title record for a session AND clear the upstream display
1432
- // name. setSessionTitle mirrors the title to the upstream session displayName,
1433
- // and session-list rendering falls back to that displayName when the local
1434
- // record is gone — so a local-only delete would let the old title reappear on
1435
- // the next sessions refresh. deleteSessions removes it for genuine deletes;
1436
- // this is for a reused-key logical reset (/new, /reset).
1437
+
1437
1438
  function clearSessionTitle(sessionKey) {
1438
1439
  if (typeof sessionKey !== "string" || !sessionKey.trim()) return;
1439
1440
  const hadTitle = sessionTitleByKey.delete(sessionKey);
@@ -1457,21 +1458,12 @@ export function createSessionService(opts = {}) {
1457
1458
  }
1458
1459
  }
1459
1460
 
1460
- // Clear ALL per-session state keyed to a conversation that must not bleed into
1461
- // a fresh conversation reusing the same session key (/new, /reset). Centralized
1462
- // so reset paths can't miss a piece (title + upstream name, toggle states,
1463
- // distiller budget, the first-user-message marker the distiller gate reads, and
1464
- // the per-session feature toggle).
1465
1461
  function clearLogicalSessionState(sessionKey) {
1466
1462
  if (typeof sessionKey !== "string" || !sessionKey.trim()) return;
1467
1463
  clearSessionTitle(sessionKey);
1468
1464
  displayToggleTracker.clear(sessionKey);
1469
1465
  distillerBudget.clear(sessionKey);
1470
- // Clear BOTH the first-user marker AND the derived preview cache, and persist
1471
- // the deletion — recordFirstSentUserMessage writes the marker to disk, so a
1472
- // local-only delete would let a relay/plugin restart reload a stale
1473
- // "user already spoke" marker for the reused key (the distiller gate reads
1474
- // this, and the session-list preview reads the derived cache).
1466
+
1475
1467
  const hadMarker = firstSentUserMessageBySession.delete(sessionKey);
1476
1468
  firstUserMessageCache.delete(sessionKey);
1477
1469
  if (hadMarker) persistFirstSentUserMessageCache();
@@ -1613,11 +1605,6 @@ export function createSessionService(opts = {}) {
1613
1605
  }
1614
1606
  }
1615
1607
 
1616
- /**
1617
- * Switch to a different session: load its history and return pages.
1618
- * @param {string} sessionKey
1619
- * @returns {Promise<Array>} Pages array
1620
- */
1621
1608
  async function switchToSession(sessionKey, opts = {}) {
1622
1609
  const markPendingSessionList =
1623
1610
  opts.markPendingSessionList === true &&
@@ -1673,10 +1660,6 @@ export function createSessionService(opts = {}) {
1673
1660
  return pages;
1674
1661
  }
1675
1662
 
1676
- /**
1677
- * Create a new session with a fresh key.
1678
- * @returns {Promise<{sessionKey: string, pages: Array}>}
1679
- */
1680
1663
  async function newSession(opts = {}) {
1681
1664
  const sendResetCommand = opts.sendResetCommand !== false;
1682
1665
  const sessionKey = generateSessionKey();
@@ -1723,10 +1706,6 @@ export function createSessionService(opts = {}) {
1723
1706
  return extractShortKey(trimmed).toLowerCase();
1724
1707
  }
1725
1708
 
1726
- /**
1727
- * Check if an event's session key matches the current glasses session.
1728
- * Events without a sessionKey default to "main".
1729
- */
1730
1709
  function isCurrentSession(eventSessionKey) {
1731
1710
  const eventKey = normalizeSessionKeyForCompare(eventSessionKey || "main");
1732
1711
  const currentKey = normalizeSessionKeyForCompare(ensureSessionKey());
@@ -1780,6 +1759,9 @@ export function createSessionService(opts = {}) {
1780
1759
  isCurrentSession,
1781
1760
  setSessionPinned,
1782
1761
  getSessionPin,
1762
+ getSessionAgentId,
1763
+ setSessionAgentId,
1764
+ hasExplicitSessionAgent,
1783
1765
  deleteSessions,
1784
1766
  switchAndDeleteSessions,
1785
1767
  broadcastSessionsForKind,
@@ -1,11 +1,7 @@
1
- /**
2
- * Per-session distiller budget. SKIPs are free; only consecutive errors and a
3
- * total untitled-turn ceiling bound attempts; an applied title ends attempts.
4
- */
5
1
  export function createDistillerBudget(opts = {}) {
6
2
  const maxErr = Number.isFinite(opts.maxConsecutiveErrors) ? opts.maxConsecutiveErrors : 3;
7
3
  const ceiling = Number.isFinite(opts.untitledTurnCeiling) ? opts.untitledTurnCeiling : 25;
8
- /** @type {Map<string,{consecErr:number, turns:number, done:boolean}>} */
4
+
9
5
  const byKey = new Map();
10
6
 
11
7
  function get(k) {