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
@@ -1,5 +1,10 @@
1
1
  import { createRuntimeConfig } from "../config/runtime-config.js";
2
2
  import { createPluginOpenclawClient } from "../gateway/openclaw-client.js";
3
+ import {
4
+ composeContainerLoopbackWarning,
5
+ isContainerEnvironment,
6
+ isLoopbackBindAddress,
7
+ } from "./container-env.js";
3
8
  import { createRelay as createPluginOwnedRelay } from "./relay-core.js";
4
9
 
5
10
  function normalizeLogger(logger) {
@@ -158,6 +163,13 @@ export function createOcuClawRelayService(opts = {}) {
158
163
  logger.info(
159
164
  `[ocuclaw] relay service started on ws://${config.wsBind}:${config.wsPort}`,
160
165
  );
166
+ const containerEnvProbe =
167
+ typeof opts.isContainerEnvironment === "function"
168
+ ? opts.isContainerEnvironment
169
+ : isContainerEnvironment;
170
+ if (isLoopbackBindAddress(config.wsBind) && containerEnvProbe()) {
171
+ logger.warn(composeContainerLoopbackWarning(config.wsBind, config.wsPort));
172
+ }
161
173
  return nextRelay;
162
174
  } catch (err) {
163
175
  clearSharedRelay(nextRelay);
@@ -291,34 +303,128 @@ export function createOcuClawRelayService(opts = {}) {
291
303
  trackedThrowawayKeys: [],
292
304
  };
293
305
  },
306
+ // These reads back the session-title tool, the distiller gates, and the
307
+ // Channel-2 before_prompt_build hook — all of which can run in a sibling
308
+ // plugin-register context whose own `relay` is null. Resolve the live
309
+ // (possibly shared) relay so they reach the running instance, mirroring
310
+ // hasConnectedAppClient(); otherwise the distiller skips every run and the
311
+ // Channel-2 stop-notices never fire.
294
312
  getSessionTitle(sessionKey) {
295
- if (relay && typeof relay.getSessionTitle === "function") {
296
- return relay.getSessionTitle(sessionKey);
313
+ const liveRelay = resolveLiveRelay();
314
+ if (liveRelay && typeof liveRelay.getSessionTitle === "function") {
315
+ return liveRelay.getSessionTitle(sessionKey);
297
316
  }
298
317
  return null;
299
318
  },
300
319
  hasRecordedUserMessage(sessionKey) {
301
- if (relay && typeof relay.hasRecordedUserMessage === "function") {
302
- return relay.hasRecordedUserMessage(sessionKey);
320
+ const liveRelay = resolveLiveRelay();
321
+ if (liveRelay && typeof liveRelay.hasRecordedUserMessage === "function") {
322
+ return liveRelay.hasRecordedUserMessage(sessionKey);
303
323
  }
304
- // Fail-closed when the relay isn't running: block titling.
324
+ // Fail-closed when no relay is running: block titling.
305
325
  return false;
306
326
  },
307
327
  isNeuralSessionNamesEnabled(sessionKey) {
308
- if (relay && typeof relay.isNeuralSessionNamesEnabled === "function") {
309
- return relay.isNeuralSessionNamesEnabled(sessionKey);
328
+ const liveRelay = resolveLiveRelay();
329
+ if (liveRelay && typeof liveRelay.isNeuralSessionNamesEnabled === "function") {
330
+ return liveRelay.isNeuralSessionNamesEnabled(sessionKey);
310
331
  }
311
332
  return true;
312
333
  },
313
334
  isSessionUserLocked(sessionKey) {
314
- if (relay && typeof relay.isSessionUserLocked === "function") {
315
- return relay.isSessionUserLocked(sessionKey);
335
+ const liveRelay = resolveLiveRelay();
336
+ if (liveRelay && typeof liveRelay.isSessionUserLocked === "function") {
337
+ return liveRelay.isSessionUserLocked(sessionKey);
338
+ }
339
+ return false;
340
+ },
341
+ getDisplayStartStates(sessionKey) {
342
+ const liveRelay = resolveLiveRelay();
343
+ if (liveRelay && typeof liveRelay.getDisplayStartStates === "function") {
344
+ return liveRelay.getDisplayStartStates(sessionKey);
345
+ }
346
+ return { emoji: false, pace: false };
347
+ },
348
+ getDisplayCurrentStates(sessionKey) {
349
+ const liveRelay = resolveLiveRelay();
350
+ if (liveRelay && typeof liveRelay.getDisplayCurrentStates === "function") {
351
+ return liveRelay.getDisplayCurrentStates(sessionKey);
352
+ }
353
+ return { emoji: false, pace: false };
354
+ },
355
+ // Distiller-sidecar passthroughs. Use the live relay (possibly a sibling
356
+ // register-context's shared relay) since the distiller may fire from a
357
+ // context whose own `relay` is null.
358
+ getSessionTitleRecord(sessionKey) {
359
+ const liveRelay = resolveLiveRelay();
360
+ if (liveRelay && typeof liveRelay.getSessionTitleRecord === "function") {
361
+ return liveRelay.getSessionTitleRecord(sessionKey);
362
+ }
363
+ return null;
364
+ },
365
+ isEvenAiSessionKey(sessionKey) {
366
+ const liveRelay = resolveLiveRelay();
367
+ if (liveRelay && typeof liveRelay.isEvenAiSessionKey === "function") {
368
+ return liveRelay.isEvenAiSessionKey(sessionKey);
316
369
  }
317
370
  return false;
318
371
  },
372
+ getRawMessages() {
373
+ const liveRelay = resolveLiveRelay();
374
+ if (liveRelay && typeof liveRelay.getRawMessages === "function") {
375
+ return liveRelay.getRawMessages();
376
+ }
377
+ return [];
378
+ },
379
+ getDistillerBudget() {
380
+ const liveRelay = resolveLiveRelay();
381
+ if (liveRelay && typeof liveRelay.getDistillerBudget === "function") {
382
+ return liveRelay.getDistillerBudget();
383
+ }
384
+ return null;
385
+ },
386
+ deleteDistillerSession(sessionKey) {
387
+ const liveRelay = resolveLiveRelay();
388
+ if (liveRelay && typeof liveRelay.deleteDistillerSession === "function") {
389
+ return liveRelay.deleteDistillerSession(sessionKey);
390
+ }
391
+ return Promise.resolve(null);
392
+ },
393
+ getStateDir() {
394
+ const liveRelay = resolveLiveRelay();
395
+ if (liveRelay && typeof liveRelay.getStateDir === "function") {
396
+ return liveRelay.getStateDir();
397
+ }
398
+ return opts.stateDir;
399
+ },
400
+ emitDebug(...args) {
401
+ const liveRelay = resolveLiveRelay();
402
+ if (liveRelay && typeof liveRelay.emitDebug === "function") {
403
+ return liveRelay.emitDebug(...args);
404
+ }
405
+ return undefined;
406
+ },
407
+ gatewayRequest(method, params, requestOpts) {
408
+ const liveRelay = resolveLiveRelay();
409
+ if (liveRelay && typeof liveRelay.gatewayRequest === "function") {
410
+ return liveRelay.gatewayRequest(method, params, requestOpts);
411
+ }
412
+ return Promise.reject(new Error("relay_not_running"));
413
+ },
414
+ onGatewayEvent(eventName, listener) {
415
+ const liveRelay = resolveLiveRelay();
416
+ if (liveRelay && typeof liveRelay.onGatewayEvent === "function") {
417
+ return liveRelay.onGatewayEvent(eventName, listener);
418
+ }
419
+ return () => {};
420
+ },
319
421
  peekSessionKey() {
320
- if (relay && typeof relay.peekSessionKey === "function") {
321
- return relay.peekSessionKey();
422
+ // The set_session_title tool reads this before writing; like the adjacent
423
+ // session-title accessors it must reach the live (possibly shared) relay,
424
+ // or an explicit rename from a sibling tool context fails no_active_session.
425
+ const liveRelay = resolveLiveRelay();
426
+ if (liveRelay && typeof liveRelay.peekSessionKey === "function") {
427
+ return liveRelay.peekSessionKey();
322
428
  }
323
429
  return null;
324
430
  },
@@ -328,8 +434,9 @@ export function createOcuClawRelayService(opts = {}) {
328
434
  }
329
435
  },
330
436
  setSessionTitle(sessionKey, title, opts) {
331
- if (relay && typeof relay.setSessionTitle === "function") {
332
- return relay.setSessionTitle(sessionKey, title, opts);
437
+ const liveRelay = resolveLiveRelay();
438
+ if (liveRelay && typeof liveRelay.setSessionTitle === "function") {
439
+ return liveRelay.setSessionTitle(sessionKey, title, opts);
333
440
  }
334
441
  return { ok: false, code: "relay_not_running" };
335
442
  },
@@ -5,10 +5,36 @@ if (!parentPort) {
5
5
  throw new Error("relay worker entry requires parentPort");
6
6
  }
7
7
 
8
+ function formatLogArgs(args) {
9
+ return args
10
+ .map((arg) => {
11
+ if (typeof arg === "string") return arg;
12
+ if (arg instanceof Error) return arg.message;
13
+ try {
14
+ return JSON.stringify(arg);
15
+ } catch {
16
+ return String(arg);
17
+ }
18
+ })
19
+ .join(" ");
20
+ }
21
+
22
+ function postWorkerLog(level, args) {
23
+ parentPort.postMessage({ kind: "worker.log", level, message: formatLogArgs(args) });
24
+ }
25
+
8
26
  const transport = createRelayWorkerTransport({
9
27
  postToMain(message) {
10
28
  parentPort.postMessage(message);
11
29
  },
30
+ // Worker-thread console output never reaches the gateway's log file;
31
+ // forward log lines to the supervisor, which owns the real plugin logger.
32
+ logger: {
33
+ info(...args) { postWorkerLog("info", args); },
34
+ warn(...args) { postWorkerLog("warn", args); },
35
+ error(...args) { postWorkerLog("error", args); },
36
+ debug(...args) { postWorkerLog("debug", args); },
37
+ },
12
38
  });
13
39
 
14
40
  parentPort.on("message", async (message) => {
@@ -519,6 +519,16 @@ export function createRelayWorkerSupervisor(options = {}) {
519
519
  logger.warn(`[relay-worker] ${message.message || "worker error"}`);
520
520
  return;
521
521
  }
522
+ if (message.kind === "worker.log") {
523
+ // Worker-thread console output never reaches the gateway's structured
524
+ // log file, so the worker forwards its log lines here instead.
525
+ const level =
526
+ message.level === "warn" || message.level === "error" || message.level === "debug"
527
+ ? message.level
528
+ : "info";
529
+ logger[level](typeof message.message === "string" ? message.message : String(message.message));
530
+ return;
531
+ }
522
532
  if (message.kind === "app.message") {
523
533
  if (!handler || typeof handler.handleMessage !== "function") return;
524
534
  const processOptions = {};
@@ -581,7 +591,7 @@ export function createRelayWorkerSupervisor(options = {}) {
581
591
  clientName: message.clientName || null,
582
592
  clientVersion: message.clientVersion || null,
583
593
  sessionKey: message.sessionKey || null,
584
- readinessSnapshot: message.readinessSnapshot || null,
594
+ readinessSnapshot: normalizeIngestedReadinessSnapshot(message.readinessSnapshot),
585
595
  connectedAtMs: Number.isFinite(message.connectedAtMs)
586
596
  ? message.connectedAtMs
587
597
  : Date.now(),
@@ -681,7 +691,7 @@ export function createRelayWorkerSupervisor(options = {}) {
681
691
  if (message.kind === "client.readinessSnapshot") {
682
692
  const entry = clients.get(message.clientId);
683
693
  if (entry) {
684
- entry.readinessSnapshot = message.readinessSnapshot || null;
694
+ entry.readinessSnapshot = normalizeIngestedReadinessSnapshot(message.readinessSnapshot);
685
695
  entry.updatedAtMs = Number.isFinite(message.updatedAtMs)
686
696
  ? message.updatedAtMs
687
697
  : Date.now();
@@ -695,6 +705,21 @@ export function createRelayWorkerSupervisor(options = {}) {
695
705
  const pending = requestId ? pendingReadinessProbeRequests.get(requestId) : null;
696
706
  if (!pending || pending.targetClientId !== message.clientId) return;
697
707
  pendingReadinessProbeRequests.delete(requestId);
708
+ // Probe acks carry the app's CURRENT activeSessionKey — ground truth.
709
+ // Refresh the registry entry with it: the app's standalone snapshot
710
+ // republication can race a reconnecting socket and get lost, leaving the
711
+ // hello-frozen key here until the next app boot (quirk 8's second layer).
712
+ if (ack && ack.ok !== false && typeof ack.activeSessionKey === "string" && ack.activeSessionKey) {
713
+ const ackEntry = clients.get(message.clientId);
714
+ if (ackEntry && ackEntry.readinessSnapshot) {
715
+ ackEntry.readinessSnapshot = {
716
+ ...ackEntry.readinessSnapshot,
717
+ activeSessionKey: ack.activeSessionKey,
718
+ emittedAtMs: Number.isFinite(ack.emittedAtMs) ? ack.emittedAtMs : Date.now(),
719
+ };
720
+ ackEntry.updatedAtMs = Date.now();
721
+ }
722
+ }
698
723
  const protocol = clients.get(message.clientId) || {};
699
724
  const frame =
700
725
  handler && typeof handler.formatReadinessProbeAck === "function"
@@ -911,6 +936,22 @@ export function createRelayWorkerSupervisor(options = {}) {
911
936
  });
912
937
  }
913
938
 
939
+ // The WebUI's reconnect hello embeds its readiness snapshot WITHOUT
940
+ // emittedAtMs (toProtocolHelloReadinessSnapshotJson omits it), but the
941
+ // automation-state gate requires a finite emittedAtMs to count the client
942
+ // as published. Stamp ingest time so a reconnect hello counts as a
943
+ // publication — otherwise every relay restart leaves `inspect state`
944
+ // returning snapshot_unavailable until the sim/app client is cycled.
945
+ function normalizeIngestedReadinessSnapshot(snapshot) {
946
+ if (!snapshot || typeof snapshot !== "object") {
947
+ return null;
948
+ }
949
+ if (Number.isFinite(snapshot.emittedAtMs)) {
950
+ return snapshot;
951
+ }
952
+ return { ...snapshot, emittedAtMs: Date.now() };
953
+ }
954
+
914
955
  function getReadinessSnapshot() {
915
956
  const appClients = getConnectedAppEntries();
916
957
  const updatedAtMs = appClients.reduce((latest, entry) => {
@@ -56,6 +56,36 @@ export function createRelayWorkerTransport(options = {}) {
56
56
  let httpServer = null;
57
57
  let wss = null;
58
58
  let nextClientId = 1;
59
+ // Invalid-token rejects are the one connection log an internet-facing
60
+ // misconfiguration could flood, so they are rate-limited per remote address.
61
+ const TOKEN_REJECT_LOG_WINDOW_MS = 60000;
62
+ const TOKEN_REJECT_LOG_MAX_ADDRESSES = 100;
63
+ const tokenRejectLogState = new Map();
64
+
65
+ function logTokenReject(remoteAddress) {
66
+ const at = now();
67
+ let state = tokenRejectLogState.get(remoteAddress);
68
+ if (!state) {
69
+ if (tokenRejectLogState.size >= TOKEN_REJECT_LOG_MAX_ADDRESSES) {
70
+ tokenRejectLogState.clear();
71
+ }
72
+ state = { lastLogAtMs: null, suppressedCount: 0 };
73
+ tokenRejectLogState.set(remoteAddress, state);
74
+ }
75
+ if (state.lastLogAtMs !== null && at - state.lastLogAtMs < TOKEN_REJECT_LOG_WINDOW_MS) {
76
+ state.suppressedCount += 1;
77
+ return;
78
+ }
79
+ const suffix =
80
+ state.suppressedCount > 0
81
+ ? ` (+${state.suppressedCount} more rejected from this address since last log)`
82
+ : "";
83
+ state.lastLogAtMs = at;
84
+ state.suppressedCount = 0;
85
+ logger.warn(
86
+ `[ocuclaw] relay rejected connection: invalid token remote=${remoteAddress}${suffix}`,
87
+ );
88
+ }
59
89
  let expireTimer = null;
60
90
  let healthTimer = null;
61
91
  let loopDelayMonitor = null;
@@ -894,11 +924,17 @@ export function createRelayWorkerTransport(options = {}) {
894
924
  });
895
925
  wss.on("connection", (ws, req) => {
896
926
  const requestUrl = new URL(req.url || "/", `http://${req.headers.host || "127.0.0.1"}`);
927
+ const remoteAddress = (req.socket && req.socket.remoteAddress) || "unknown";
897
928
  if (requestUrl.searchParams.get("token") !== manifest.relayToken) {
929
+ logTokenReject(remoteAddress);
898
930
  ws.close(4001, "invalid_token");
899
931
  return;
900
932
  }
901
933
  const clientId = `worker-client-${nextClientId++}`;
934
+ const connectedAtMs = now();
935
+ logger.info(
936
+ `[ocuclaw] relay client connected clientId=${clientId} remote=${remoteAddress}`,
937
+ );
902
938
  clients.set(clientId, ws);
903
939
  protocolState.set(clientId, {
904
940
  protocolVersion: null,
@@ -925,6 +961,11 @@ export function createRelayWorkerTransport(options = {}) {
925
961
  : Buffer.isBuffer(reason)
926
962
  ? reason.toString("utf8")
927
963
  : String(reason);
964
+ logger.info(
965
+ `[ocuclaw] relay client disconnected clientId=${clientId} remote=${remoteAddress} code=${
966
+ Number.isFinite(code) ? code : "none"
967
+ } lifetimeMs=${Math.max(0, now() - connectedAtMs)}`,
968
+ );
928
969
  postToMain({
929
970
  kind: "client.disconnected",
930
971
  clientId,
@@ -1,6 +1,9 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { stripAllTaggedSpans } from "../domain/tagged-span-strip.js";
4
+ import { createDisplayToggleTracker } from "./display-toggle-states.js";
5
+ import { decideTitleWrite, isUserOrigin } from "./session-title-record.js";
6
+ import { createDistillerBudget } from "./session-title-distiller-budget.js";
4
7
 
5
8
  const SESSION_FIRST_USER_CACHE_FILE = "session-first-user-cache.json";
6
9
  const SESSION_TITLE_CACHE_FILE = "session-title-cache.json";
@@ -168,6 +171,14 @@ export function createSessionService(opts = {}) {
168
171
  const sessionTitleByKey = loadSessionTitleCache();
169
172
  /** @type {Map<string, boolean>} Per-session Neural Session Names toggle state. */
170
173
  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). */
177
+ 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. */
181
+ const distillerBudget = createDistillerBudget({});
171
182
 
172
183
  /** Path for session pin metadata cache file. */
173
184
  const sessionPinCachePath = resolveSessionPinCachePath(opts.stateDir);
@@ -780,10 +791,17 @@ export function createSessionService(opts = {}) {
780
791
  function resolveRowTitle(sessionKey, row) {
781
792
  const cached = getSessionTitle(sessionKey);
782
793
  if (cached !== null) return cached;
783
- // row.displayName: upstream session-row label per
784
- // openclaw/docs/reference/session-management-compaction.md §169.
785
- if (row && typeof row.displayName === "string") {
786
- const trimmed = row.displayName.trim();
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.
797
+ const rawLabel =
798
+ row && typeof row.label === "string"
799
+ ? row.label
800
+ : row && typeof row.displayName === "string"
801
+ ? row.displayName
802
+ : "";
803
+ {
804
+ const trimmed = rawLabel.trim();
787
805
  if (trimmed) return trimmed;
788
806
  }
789
807
  return null;
@@ -977,6 +995,7 @@ export function createSessionService(opts = {}) {
977
995
  sessionPinByKey.delete(key);
978
996
  sessionTitleByKey.delete(key);
979
997
  firstSentUserMessageBySession.delete(key);
998
+ distillerBudget.clear(key);
980
999
  deleted.push(key);
981
1000
  } catch (err) {
982
1001
  failed.push({ key, reason: err?.message ?? "unknown" });
@@ -1284,6 +1303,11 @@ export function createSessionService(opts = {}) {
1284
1303
  return entry ? entry.title : null;
1285
1304
  }
1286
1305
 
1306
+ function getSessionTitleRecord(sessionKey) {
1307
+ const entry = sessionTitleByKey.get(sessionKey);
1308
+ return entry ? { ...entry } : null;
1309
+ }
1310
+
1287
1311
  function setSessionTitle(sessionKey, title, opts) {
1288
1312
  if (typeof sessionKey !== "string" || !sessionKey.trim()) {
1289
1313
  return { ok: false, code: "invalid_session_key" };
@@ -1292,19 +1316,26 @@ export function createSessionService(opts = {}) {
1292
1316
  return { ok: false, code: "invalid_title" };
1293
1317
  }
1294
1318
  const trimmed = title.trim();
1295
- const setByUser = !!(opts && opts.userSet === true);
1319
+ // Back-compat: callers passing { userSet:true } map to the user_tool origin.
1320
+ const origin =
1321
+ opts && typeof opts.origin === "string" && opts.origin
1322
+ ? opts.origin
1323
+ : opts && opts.userSet === true
1324
+ ? "user_tool"
1325
+ : "topic_distiller";
1296
1326
  const previous = sessionTitleByKey.get(sessionKey);
1297
-
1298
- if (!setByUser && previous && previous.userSet === true) {
1299
- return { ok: false, code: "session_user_locked" };
1327
+ const decision = decideTitleWrite(previous, origin);
1328
+ if (!decision.allowed) {
1329
+ return { ok: false, code: decision.code };
1300
1330
  }
1301
-
1302
1331
  const replaced = !!previous;
1303
- const nextUserSet = setByUser || (previous && previous.userSet === true);
1332
+ const nextUserSet = decision.nextUserSet;
1333
+ const setByUser = isUserOrigin(origin);
1304
1334
  sessionTitleByKey.set(sessionKey, {
1305
1335
  title: trimmed,
1306
1336
  setAtMs: Date.now(),
1307
1337
  userSet: !!nextUserSet,
1338
+ origin,
1308
1339
  });
1309
1340
  pruneSessionTitleEntries(sessionTitleByKey);
1310
1341
  persistSessionTitleCache();
@@ -1314,15 +1345,26 @@ export function createSessionService(opts = {}) {
1314
1345
  setByUser ? "session_title_set_by_user" : "session_title_set",
1315
1346
  "info",
1316
1347
  { sessionKey },
1317
- () => ({ sessionKey, title: trimmed, replaced, userSet: !!nextUserSet }),
1348
+ () => ({ sessionKey, title: trimmed, replaced, userSet: !!nextUserSet, origin }),
1318
1349
  );
1319
1350
  // Fire-and-forget upstream mirror.
1351
+ if (!isUpstreamConnected()) {
1352
+ emitDebug(
1353
+ "relay.session",
1354
+ "session_title_upstream_mirror_skipped",
1355
+ "debug",
1356
+ { sessionKey },
1357
+ () => ({ reason: "upstream_disconnected", origin }),
1358
+ );
1359
+ }
1320
1360
  if (isUpstreamConnected()) {
1321
1361
  resolveSessionCanonicalKey(sessionKey)
1322
1362
  .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).
1323
1365
  gatewayBridge.request("sessions.patch", {
1324
1366
  key: canonicalKey,
1325
- displayName: trimmed,
1367
+ label: trimmed,
1326
1368
  }),
1327
1369
  )
1328
1370
  .catch((err) => {
@@ -1364,6 +1406,78 @@ export function createSessionService(opts = {}) {
1364
1406
  return cached === undefined ? true : cached;
1365
1407
  }
1366
1408
 
1409
+ function recordDisplayToggleStates(sessionKey, states) {
1410
+ if (typeof sessionKey !== "string" || !sessionKey.trim()) return;
1411
+ displayToggleTracker.record(sessionKey, states);
1412
+ }
1413
+ function getDisplayStartStates(sessionKey) {
1414
+ return displayToggleTracker.getStart(sessionKey);
1415
+ }
1416
+ function getDisplayCurrentStates(sessionKey) {
1417
+ return displayToggleTracker.getCurrent(sessionKey);
1418
+ }
1419
+ function clearDisplayToggleStates(sessionKey) {
1420
+ displayToggleTracker.clear(sessionKey);
1421
+ }
1422
+
1423
+ function getDistillerBudget() {
1424
+ return distillerBudget;
1425
+ }
1426
+ function clearDistillerBudget(sessionKey) {
1427
+ if (typeof sessionKey === "string" && sessionKey.trim()) {
1428
+ distillerBudget.clear(sessionKey);
1429
+ }
1430
+ }
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
+ function clearSessionTitle(sessionKey) {
1438
+ if (typeof sessionKey !== "string" || !sessionKey.trim()) return;
1439
+ const hadTitle = sessionTitleByKey.delete(sessionKey);
1440
+ if (!hadTitle) return;
1441
+ persistSessionTitleCache();
1442
+ invalidateSessionsCache();
1443
+ if (isUpstreamConnected()) {
1444
+ resolveSessionCanonicalKey(sessionKey)
1445
+ .then((canonicalKey) =>
1446
+ gatewayBridge.request("sessions.patch", { key: canonicalKey, label: null }),
1447
+ )
1448
+ .catch((err) => {
1449
+ emitDebug(
1450
+ "relay.session",
1451
+ "session_title_upstream_clear_failed",
1452
+ "debug",
1453
+ { sessionKey },
1454
+ () => ({ message: err && err.message ? err.message : String(err) }),
1455
+ );
1456
+ });
1457
+ }
1458
+ }
1459
+
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
+ function clearLogicalSessionState(sessionKey) {
1466
+ if (typeof sessionKey !== "string" || !sessionKey.trim()) return;
1467
+ clearSessionTitle(sessionKey);
1468
+ displayToggleTracker.clear(sessionKey);
1469
+ 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).
1475
+ const hadMarker = firstSentUserMessageBySession.delete(sessionKey);
1476
+ firstUserMessageCache.delete(sessionKey);
1477
+ if (hadMarker) persistFirstSentUserMessageCache();
1478
+ neuralSessionNamesEnabledByKey.delete(sessionKey);
1479
+ }
1480
+
1367
1481
  function isSyntheticSessionStarter(text) {
1368
1482
  if (!text) return false;
1369
1483
  if (
@@ -1645,11 +1759,21 @@ export function createSessionService(opts = {}) {
1645
1759
  clearPendingInitialConfig,
1646
1760
  getSessions,
1647
1761
  getSessionTitle,
1762
+ getSessionTitleRecord,
1648
1763
  getSessionsByExactKeys,
1649
1764
  hasRecordedFirstUserMessage,
1650
1765
  isNeuralSessionNamesEnabled,
1766
+ isEvenAiSessionKey,
1651
1767
  isSessionUserLocked,
1652
1768
  recordNeuralSessionNamesEnabled,
1769
+ recordDisplayToggleStates,
1770
+ getDisplayStartStates,
1771
+ getDisplayCurrentStates,
1772
+ clearDisplayToggleStates,
1773
+ getDistillerBudget,
1774
+ clearDistillerBudget,
1775
+ clearSessionTitle,
1776
+ clearLogicalSessionState,
1653
1777
  setSessionTitle,
1654
1778
  switchToSession,
1655
1779
  newSession,
@@ -0,0 +1,36 @@
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
+ export function createDistillerBudget(opts = {}) {
6
+ const maxErr = Number.isFinite(opts.maxConsecutiveErrors) ? opts.maxConsecutiveErrors : 3;
7
+ const ceiling = Number.isFinite(opts.untitledTurnCeiling) ? opts.untitledTurnCeiling : 25;
8
+ /** @type {Map<string,{consecErr:number, turns:number, done:boolean}>} */
9
+ const byKey = new Map();
10
+
11
+ function get(k) {
12
+ let s = byKey.get(k);
13
+ if (!s) { s = { consecErr: 0, turns: 0, done: false }; byKey.set(k, s); }
14
+ return s;
15
+ }
16
+
17
+ return {
18
+ recordTurn(sessionKey) { get(sessionKey).turns += 1; },
19
+ canRun(sessionKey) {
20
+ const s = get(sessionKey);
21
+ if (s.done) return false;
22
+ if (s.consecErr >= maxErr) return false;
23
+ if (s.turns >= ceiling) return false;
24
+ return true;
25
+ },
26
+ recordOutcome(sessionKey, outcome) {
27
+ const s = get(sessionKey);
28
+ if (outcome === "error") { s.consecErr += 1; return; }
29
+ s.consecErr = 0;
30
+ if (outcome === "applied") s.done = true;
31
+ },
32
+ clear(sessionKey) { byKey.delete(sessionKey); },
33
+ };
34
+ }
35
+
36
+ export default createDistillerBudget;