ocuclaw 1.2.4 → 1.3.0

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 (59) hide show
  1. package/README.md +18 -5
  2. package/dist/config/runtime-config.js +81 -3
  3. package/dist/domain/activity-status-adapter.js +138 -605
  4. package/dist/domain/activity-status-arbiter.js +109 -0
  5. package/dist/domain/activity-status-labels.js +906 -0
  6. package/dist/domain/code-span-regions.js +103 -0
  7. package/dist/domain/conversation-state.js +14 -1
  8. package/dist/domain/debug-store.js +38 -182
  9. package/dist/domain/glasses-ui-content-summary.js +62 -0
  10. package/dist/domain/glasses-ui-system-prompt.js +28 -0
  11. package/dist/domain/message-emoji-allowlist.js +16 -0
  12. package/dist/domain/message-emoji-filter.js +33 -55
  13. package/dist/domain/neural-emoji-reactor-system-prompt.js +43 -0
  14. package/dist/domain/neural-emoji-reactor-tag-config.js +56 -0
  15. package/dist/domain/neural-pace-modulator-system-prompt.js +32 -0
  16. package/dist/domain/neural-pace-modulator-tag-config.js +51 -0
  17. package/dist/domain/tagged-span-parser.js +121 -0
  18. package/dist/domain/tagged-span-strip.js +38 -0
  19. package/dist/even-ai/even-ai-endpoint.js +91 -0
  20. package/dist/even-ai/even-ai-run-waiter.js +14 -0
  21. package/dist/even-ai/even-ai-settings-store.js +14 -0
  22. package/dist/gateway/gateway-bridge.js +14 -2
  23. package/dist/gateway/gateway-timing-ledger.js +457 -0
  24. package/dist/gateway/openclaw-client.js +462 -38
  25. package/dist/index.js +28 -1
  26. package/dist/runtime/downstream-handler.js +754 -83
  27. package/dist/runtime/downstream-server.js +700 -534
  28. package/dist/runtime/ocuclaw-settings-store.js +74 -31
  29. package/dist/runtime/plugin-update-service.js +216 -0
  30. package/dist/runtime/protocol-adapter.js +9 -0
  31. package/dist/runtime/provider-usage-select.js +168 -0
  32. package/dist/runtime/relay-client-nudge-controller.js +553 -0
  33. package/dist/runtime/relay-core.js +1209 -204
  34. package/dist/runtime/relay-health-monitor.js +172 -0
  35. package/dist/runtime/relay-operation-registry.js +263 -0
  36. package/dist/runtime/relay-service.js +201 -1
  37. package/dist/runtime/relay-worker-approval-replay-cache.js +68 -0
  38. package/dist/runtime/relay-worker-entry.js +32 -0
  39. package/dist/runtime/relay-worker-health.js +272 -0
  40. package/dist/runtime/relay-worker-protocol.js +285 -0
  41. package/dist/runtime/relay-worker-queue.js +202 -0
  42. package/dist/runtime/relay-worker-supervisor.js +1081 -0
  43. package/dist/runtime/relay-worker-transport.js +1051 -0
  44. package/dist/runtime/session-context-service.js +189 -0
  45. package/dist/runtime/session-service.js +615 -24
  46. package/dist/runtime/upstream-runtime.js +1167 -60
  47. package/dist/tools/device-info-tool.js +242 -0
  48. package/dist/tools/glasses-ui-cron.js +427 -0
  49. package/dist/tools/glasses-ui-descriptors.js +261 -0
  50. package/dist/tools/glasses-ui-limits.js +21 -0
  51. package/dist/tools/glasses-ui-paint-floor.js +99 -0
  52. package/dist/tools/glasses-ui-recipes.js +746 -0
  53. package/dist/tools/glasses-ui-surfaces.js +278 -0
  54. package/dist/tools/glasses-ui-template.js +182 -0
  55. package/dist/tools/glasses-ui-tool.js +1147 -0
  56. package/dist/tools/session-title-tool.js +209 -0
  57. package/dist/version.js +2 -0
  58. package/openclaw.plugin.json +163 -15
  59. package/package.json +12 -4
@@ -1,7 +1,12 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
+ import { stripAllTaggedSpans } from "../domain/tagged-span-strip.js";
3
4
 
4
5
  const SESSION_FIRST_USER_CACHE_FILE = "session-first-user-cache.json";
6
+ const SESSION_TITLE_CACHE_FILE = "session-title-cache.json";
7
+ const SESSION_PIN_CACHE_FILE = "ocuclaw-session-pins.json";
8
+ const PIN_CAP_PER_KIND = 20;
9
+
5
10
 
6
11
  function normalizeLogger(logger) {
7
12
  if (!logger || typeof logger !== "object") {
@@ -28,6 +33,30 @@ function resolveSessionFirstUserMessageCachePath(stateDir) {
28
33
  return path.join(resolvedStateDir, SESSION_FIRST_USER_CACHE_FILE);
29
34
  }
30
35
 
36
+ function resolveSessionTitleCachePath(stateDir) {
37
+ const resolvedStateDir = normalizeStateDir(stateDir);
38
+ if (!resolvedStateDir) return null;
39
+ return path.join(resolvedStateDir, SESSION_TITLE_CACHE_FILE);
40
+ }
41
+
42
+ function resolveSessionPinCachePath(stateDir) {
43
+ const resolvedStateDir = normalizeStateDir(stateDir);
44
+ if (!resolvedStateDir) return null;
45
+ return path.join(resolvedStateDir, SESSION_PIN_CACHE_FILE);
46
+ }
47
+
48
+ function sanitizeAssistantContentBlocks(content) {
49
+ if (typeof content === "string") {
50
+ return stripAllTaggedSpans(content);
51
+ }
52
+ if (!Array.isArray(content)) return content;
53
+ return content.map((block) =>
54
+ block && block.type === "text" && typeof block.text === "string"
55
+ ? { ...block, text: stripAllTaggedSpans(block.text) }
56
+ : block,
57
+ );
58
+ }
59
+
31
60
  export function createSessionService(opts = {}) {
32
61
  const logger = normalizeLogger(opts.logger);
33
62
  const gatewayBridge = opts.gatewayBridge;
@@ -78,7 +107,9 @@ export function createSessionService(opts = {}) {
78
107
  );
79
108
 
80
109
  /** Maximum number of sessions to fetch. */
81
- const sessionLimit = opts.sessionLimit || 10;
110
+ // Default raised to 100 so the WebUI Sessions panel can show a full
111
+ // scrollable history without being chopped to 10 most-recent.
112
+ const sessionLimit = opts.sessionLimit || 100;
82
113
  /** Whether to persist first real user message cache to disk. */
83
114
  const persistFirstUserMessages = opts.persistFirstUserMessages !== false;
84
115
  /**
@@ -96,11 +127,11 @@ export function createSessionService(opts = {}) {
96
127
  Number.isFinite(opts.sessionCacheTtlMs) && opts.sessionCacheTtlMs > 0
97
128
  ? Math.floor(opts.sessionCacheTtlMs)
98
129
  : 5000;
99
- /** @type {Array<{key: string, updatedAt: number, preview: string, firstUserMessage: string}>|null} */
130
+ /** @type {Array<{key: string, updatedAt: number, preview: string, firstUserMessage: string, title: string|null, pinned: boolean, pinnedAtMs: number|null}>|null} */
100
131
  let cachedSessions = null;
101
132
  /** Epoch ms when cachedSessions was last refreshed. */
102
133
  let cachedSessionsFetchedAt = 0;
103
- /** @type {Promise<Array<{key: string, updatedAt: number, preview: string, firstUserMessage: string}>>|null} */
134
+ /** @type {Promise<Array<{key: string, updatedAt: number, preview: string, firstUserMessage: string, title: string|null, pinned: boolean, pinnedAtMs: number|null}>>|null} */
104
135
  let inFlightSessionsFetch = null;
105
136
 
106
137
  /** Last-known session model config per session key. */
@@ -113,6 +144,18 @@ export function createSessionService(opts = {}) {
113
144
  /** @type {Map<string, string>} First real user text observed from downstream send events. */
114
145
  const firstSentUserMessageBySession = loadFirstSentUserMessageCache();
115
146
 
147
+ /** Path for session title cache file. */
148
+ const sessionTitleCachePath = resolveSessionTitleCachePath(opts.stateDir);
149
+ /** @type {Map<string, {title: string, setAtMs: number, userSet: boolean}>} Per-session agent- or user-set title. */
150
+ const sessionTitleByKey = loadSessionTitleCache();
151
+ /** @type {Map<string, boolean>} Per-session Neural Session Names toggle state. */
152
+ const neuralSessionNamesEnabledByKey = new Map();
153
+
154
+ /** Path for session pin metadata cache file. */
155
+ const sessionPinCachePath = resolveSessionPinCachePath(opts.stateDir);
156
+ /** @type {Map<string, {pinned: boolean, pinnedAtMs: number}>} Per-session pin metadata. */
157
+ const sessionPinByKey = loadSessionPinCache();
158
+
116
159
  /**
117
160
  * Generate a new OcuClaw session key.
118
161
  * @returns {string} e.g. "ocuclaw:1739500000000"
@@ -195,6 +238,17 @@ export function createSessionService(opts = {}) {
195
238
  return "off";
196
239
  }
197
240
 
241
+ // Preserves all four gateway values — "ask" is the alias of "on" and must
242
+ // not be collapsed, or snapshot read-back would lie about /elevated ask.
243
+ function normalizeElevatedLevel(raw) {
244
+ if (typeof raw !== "string") return "off";
245
+ const normalized = raw.trim().toLowerCase();
246
+ if (normalized === "on" || normalized === "ask" || normalized === "full") {
247
+ return normalized;
248
+ }
249
+ return "off";
250
+ }
251
+
198
252
  function normalizeSessionModelRef(modelProviderRaw, modelRaw) {
199
253
  let modelProvider =
200
254
  typeof modelProviderRaw === "string" && modelProviderRaw.trim()
@@ -227,6 +281,8 @@ export function createSessionService(opts = {}) {
227
281
  thinkingLevel: normalizeThinkingLevel(row && row.thinkingLevel),
228
282
  reasoningLevel: normalizeReasoningLevel(row && row.reasoningLevel),
229
283
  verboseLevel: normalizeVerboseLevel(row && row.verboseLevel),
284
+ fastMode: !!(row && row.fastMode === true),
285
+ elevatedLevel: normalizeElevatedLevel(row && row.elevatedLevel),
230
286
  };
231
287
  }
232
288
 
@@ -340,6 +396,14 @@ export function createSessionService(opts = {}) {
340
396
  patch && Object.prototype.hasOwnProperty.call(patch, "verboseLevel")
341
397
  ? normalizeVerboseLevel(patch.verboseLevel)
342
398
  : base.verboseLevel,
399
+ fastMode:
400
+ patch && Object.prototype.hasOwnProperty.call(patch, "fastMode")
401
+ ? patch.fastMode === true
402
+ : base.fastMode,
403
+ elevatedLevel:
404
+ patch && Object.prototype.hasOwnProperty.call(patch, "elevatedLevel")
405
+ ? normalizeElevatedLevel(patch.elevatedLevel)
406
+ : base.elevatedLevel,
343
407
  };
344
408
  sessionModelConfigCache.set(sessionKey, config);
345
409
  return config;
@@ -357,6 +421,13 @@ export function createSessionService(opts = {}) {
357
421
  }
358
422
  const config = buildSessionModelConfig(sessionKey, row);
359
423
  sessionModelConfigCache.set(sessionKey, config);
424
+ if (
425
+ onSessionModelConfig &&
426
+ normalizeSessionKeyForCompare(sessionKey) ===
427
+ normalizeSessionKeyForCompare(ensureSessionKey())
428
+ ) {
429
+ onSessionModelConfig(config);
430
+ }
360
431
  return config;
361
432
  } catch (err) {
362
433
  emitDebug(
@@ -408,15 +479,24 @@ export function createSessionService(opts = {}) {
408
479
  if (patch && typeof patch.verboseLevel === "string") {
409
480
  request.verboseLevel = patch.verboseLevel;
410
481
  }
482
+ if (patch && typeof patch.fastMode === "boolean") {
483
+ request.fastMode = patch.fastMode;
484
+ }
485
+ if (patch && typeof patch.elevatedLevel === "string") {
486
+ request.elevatedLevel = patch.elevatedLevel;
487
+ }
411
488
 
412
489
  try {
413
490
  await gatewayBridge.request("sessions.patch", request);
414
- const config = await getSessionModelConfig(sessionKey);
415
- sessionModelConfigCache.set(sessionKey, config);
416
- pendingInitialConfigSessionKeys.delete(sessionKey);
417
- if (onSessionModelConfig && normalizeSessionKeyForCompare(sessionKey) === normalizeSessionKeyForCompare(ensureSessionKey())) {
491
+ const config = primeSessionModelConfig(sessionKey, patch);
492
+ if (
493
+ onSessionModelConfig &&
494
+ normalizeSessionKeyForCompare(sessionKey) ===
495
+ normalizeSessionKeyForCompare(ensureSessionKey())
496
+ ) {
418
497
  onSessionModelConfig(config);
419
498
  }
499
+ pendingInitialConfigSessionKeys.delete(sessionKey);
420
500
  return { status: "accepted", config };
421
501
  } catch (err) {
422
502
  emitDebug(
@@ -442,7 +522,7 @@ export function createSessionService(opts = {}) {
442
522
  /**
443
523
  * Fetch the list of OcuClaw sessions from OpenClaw.
444
524
  * Filters to OcuClaw session key prefix, sorted by updatedAt descending.
445
- * @returns {Promise<Array<{key: string, updatedAt: number, preview: string, firstUserMessage: string}>>}
525
+ * @returns {Promise<Array<{key: string, updatedAt: number, preview: string, firstUserMessage: string, title: string|null, pinned: boolean, pinnedAtMs: number|null}>>}
446
526
  */
447
527
  async function getSessions() {
448
528
  if (cachedSessions && Date.now() - cachedSessionsFetchedAt < sessionCacheTtlMs) {
@@ -478,6 +558,7 @@ export function createSessionService(opts = {}) {
478
558
  updatedAt,
479
559
  row.messages,
480
560
  );
561
+ const pinMeta = getSessionPin(key);
481
562
  return {
482
563
  key,
483
564
  updatedAt,
@@ -487,6 +568,9 @@ export function createSessionService(opts = {}) {
487
568
  ? ""
488
569
  : extractPreview(row.messages),
489
570
  firstUserMessage,
571
+ title: resolveRowTitle(key, row),
572
+ pinned: pinMeta.pinned,
573
+ pinnedAtMs: pinMeta.pinnedAtMs,
490
574
  };
491
575
  }),
492
576
  );
@@ -509,11 +593,15 @@ export function createSessionService(opts = {}) {
509
593
  updatedAt,
510
594
  [],
511
595
  );
596
+ const pinMeta = getSessionPin(pendingSessionListKey);
512
597
  sessions.unshift({
513
598
  key: pendingSessionListKey,
514
599
  updatedAt,
515
600
  preview: firstUserMessage ? firstUserMessage.slice(0, 80) : "",
516
601
  firstUserMessage,
602
+ title: resolveRowTitle(pendingSessionListKey, null),
603
+ pinned: pinMeta.pinned,
604
+ pinnedAtMs: pinMeta.pinnedAtMs,
517
605
  });
518
606
  }
519
607
  }
@@ -578,6 +666,7 @@ export function createSessionService(opts = {}) {
578
666
  updatedAt,
579
667
  fallbackMessages,
580
668
  );
669
+ const pinMeta = getSessionPin(key);
581
670
  sessions.push({
582
671
  key,
583
672
  updatedAt,
@@ -587,6 +676,9 @@ export function createSessionService(opts = {}) {
587
676
  ? ""
588
677
  : extractPreview(fallbackMessages),
589
678
  firstUserMessage,
679
+ title: resolveRowTitle(key, row),
680
+ pinned: pinMeta.pinned,
681
+ pinnedAtMs: pinMeta.pinnedAtMs,
590
682
  });
591
683
  }
592
684
 
@@ -667,6 +759,18 @@ export function createSessionService(opts = {}) {
667
759
  return "";
668
760
  }
669
761
 
762
+ function resolveRowTitle(sessionKey, row) {
763
+ const cached = getSessionTitle(sessionKey);
764
+ if (cached !== null) return cached;
765
+ // row.displayName: upstream session-row label per
766
+ // openclaw/docs/reference/session-management-compaction.md §169.
767
+ if (row && typeof row.displayName === "string") {
768
+ const trimmed = row.displayName.trim();
769
+ if (trimmed) return trimmed;
770
+ }
771
+ return null;
772
+ }
773
+
670
774
  function extractMessageText(content) {
671
775
  if (typeof content === "string") {
672
776
  return normalizeSessionText(content);
@@ -700,13 +804,13 @@ export function createSessionService(opts = {}) {
700
804
  return text.replace(/\s+/g, " ").trim();
701
805
  }
702
806
 
703
- function loadFirstSentUserMessageCache() {
704
- if (!persistFirstUserMessages || !firstUserMessageCachePath) return new Map();
807
+ function loadSessionTitleCache() {
808
+ if (!sessionTitleCachePath) return new Map();
705
809
  try {
706
- if (!fs.existsSync(firstUserMessageCachePath)) {
810
+ if (!fs.existsSync(sessionTitleCachePath)) {
707
811
  return new Map();
708
812
  }
709
- const raw = fs.readFileSync(firstUserMessageCachePath, "utf8");
813
+ const raw = fs.readFileSync(sessionTitleCachePath, "utf8");
710
814
  const parsed = JSON.parse(raw);
711
815
  const sessions =
712
816
  parsed &&
@@ -717,27 +821,34 @@ export function createSessionService(opts = {}) {
717
821
  : {};
718
822
  const out = new Map();
719
823
  for (const [sessionKey, value] of Object.entries(sessions)) {
720
- const normalized = normalizeSessionText(value);
721
- if (!sessionKey || !normalized) continue;
722
- out.set(sessionKey, normalized);
824
+ if (!sessionKey || !value || typeof value !== "object") continue;
825
+ const title = typeof value.title === "string" ? value.title : "";
826
+ if (!title) continue;
827
+ const setAtMs = Number.isFinite(value.setAtMs) ? Math.floor(value.setAtMs) : 0;
828
+ const userSet = value.userSet === true;
829
+ out.set(sessionKey, { title, setAtMs, userSet });
723
830
  }
724
- pruneFirstUserMessageEntries(out);
831
+ pruneSessionTitleEntries(out);
725
832
  return out;
726
833
  } catch {
727
834
  return new Map();
728
835
  }
729
836
  }
730
837
 
731
- function persistFirstSentUserMessageCache() {
732
- if (!persistFirstUserMessages || !firstUserMessageCachePath) return;
838
+ function persistSessionTitleCache() {
839
+ if (!sessionTitleCachePath) return;
733
840
  try {
734
- fs.mkdirSync(path.dirname(firstUserMessageCachePath), { recursive: true });
841
+ fs.mkdirSync(path.dirname(sessionTitleCachePath), { recursive: true });
735
842
  const sessions = {};
736
- for (const [sessionKey, text] of firstSentUserMessageBySession) {
737
- sessions[sessionKey] = text;
843
+ for (const [sessionKey, value] of sessionTitleByKey) {
844
+ sessions[sessionKey] = {
845
+ title: value.title,
846
+ setAtMs: value.setAtMs,
847
+ userSet: value.userSet === true,
848
+ };
738
849
  }
739
850
  fs.writeFileSync(
740
- firstUserMessageCachePath,
851
+ sessionTitleCachePath,
741
852
  JSON.stringify(
742
853
  {
743
854
  version: 1,
@@ -750,11 +861,386 @@ export function createSessionService(opts = {}) {
750
861
  );
751
862
  } catch (err) {
752
863
  logger.error(
753
- `[relay] Failed to persist session first-user cache: ${err.message}`,
864
+ `[relay] Failed to persist session title cache: ${err.message}`,
865
+ );
866
+ }
867
+ }
868
+
869
+ function loadSessionPinCache() {
870
+ if (!sessionPinCachePath) return new Map();
871
+ try {
872
+ if (!fs.existsSync(sessionPinCachePath)) return new Map();
873
+ const raw = fs.readFileSync(sessionPinCachePath, "utf8");
874
+ const parsed = JSON.parse(raw);
875
+ const out = new Map();
876
+ for (const [key, value] of Object.entries(parsed ?? {})) {
877
+ if (
878
+ value &&
879
+ typeof value === "object" &&
880
+ typeof value.pinnedAtMs === "number"
881
+ ) {
882
+ out.set(key, { pinned: !!value.pinned, pinnedAtMs: value.pinnedAtMs });
883
+ }
884
+ }
885
+ return out;
886
+ } catch {
887
+ return new Map();
888
+ }
889
+ }
890
+
891
+ function persistSessionPinCache() {
892
+ if (!sessionPinCachePath) return;
893
+ try {
894
+ fs.mkdirSync(path.dirname(sessionPinCachePath), { recursive: true });
895
+ const obj = {};
896
+ for (const [key, value] of sessionPinByKey.entries()) {
897
+ obj[key] = value;
898
+ }
899
+ fs.writeFileSync(sessionPinCachePath, JSON.stringify(obj), "utf8");
900
+ } catch (err) {
901
+ logger.error(`[relay] Failed to persist session pin cache: ${err.message}`);
902
+ }
903
+ }
904
+
905
+ function countPinnedForKind(kind) {
906
+ let n = 0;
907
+ for (const [key, val] of sessionPinByKey.entries()) {
908
+ if (!val.pinned) continue;
909
+ if (kind === "ocuclaw" && key.startsWith("ocuclaw:")) n++;
910
+ else if (kind === "evenai" && key.startsWith("evenai:")) n++;
911
+ }
912
+ return n;
913
+ }
914
+
915
+ function getSessionPin(sessionKey) {
916
+ const v = sessionPinByKey.get(sessionKey);
917
+ return v ?? { pinned: false, pinnedAtMs: null };
918
+ }
919
+
920
+ /**
921
+ * Set or clear the pin on a session.
922
+ * @param {string} kind "ocuclaw" | "evenai"
923
+ * @param {string} sessionKey
924
+ * @param {boolean} pinned
925
+ * @returns {{ ok: true } | { ok: false, reason: "cap" | "invalid" }}
926
+ */
927
+ function setSessionPinned(kind, sessionKey, pinned) {
928
+ if (!sessionKey || (kind !== "ocuclaw" && kind !== "evenai")) {
929
+ return { ok: false, reason: "invalid" };
930
+ }
931
+ if (pinned) {
932
+ const countForKind = countPinnedForKind(kind);
933
+ const already = sessionPinByKey.get(sessionKey)?.pinned === true;
934
+ if (!already && countForKind >= PIN_CAP_PER_KIND) {
935
+ return { ok: false, reason: "cap" };
936
+ }
937
+ sessionPinByKey.set(sessionKey, { pinned: true, pinnedAtMs: Date.now() });
938
+ } else {
939
+ sessionPinByKey.delete(sessionKey);
940
+ }
941
+ persistSessionPinCache();
942
+ invalidateSessionsCache();
943
+ return { ok: true };
944
+ }
945
+
946
+ /**
947
+ * Delete one or more sessions, sequentially.
948
+ * Partial failures do not abort the batch.
949
+ * @param {"ocuclaw"|"evenai"} kind
950
+ * @param {string[]} sessionKeys
951
+ * @returns {Promise<{ deleted: string[], failed: Array<{ key: string, reason: string }> }>}
952
+ */
953
+ async function deleteSessions(kind, sessionKeys) {
954
+ const deleted = [];
955
+ const failed = [];
956
+ for (const key of sessionKeys) {
957
+ try {
958
+ await deleteSingleSession(kind, key);
959
+ sessionPinByKey.delete(key);
960
+ sessionTitleByKey.delete(key);
961
+ firstSentUserMessageBySession.delete(key);
962
+ deleted.push(key);
963
+ } catch (err) {
964
+ failed.push({ key, reason: err?.message ?? "unknown" });
965
+ }
966
+ }
967
+ persistSessionPinCache();
968
+ persistSessionTitleCache();
969
+ invalidateSessionsCache();
970
+ return { deleted, failed };
971
+ }
972
+
973
+ /**
974
+ * Deep transcript search across all OcuClaw sessions returned by getSessions().
975
+ * Returns at most `maxSnippets` snippets matching the query (case-insensitive),
976
+ * each with `before`/`match`/`after` slices so the UI can highlight the hit.
977
+ * @param {string} kind "ocuclaw" | "evenai"
978
+ * @param {string} query
979
+ * @returns {Promise<{snippets: Array<{sessionKey: string, role: string, updatedAtMs: number, before: string, match: string, after: string}>, truncated: boolean}>}
980
+ */
981
+ async function searchTranscripts(kind, query) {
982
+ const needle = (typeof query === "string" ? query.trim() : "").toLowerCase();
983
+ if (!needle) return { snippets: [], truncated: false };
984
+ const maxSnippets = 50;
985
+ const contextChars = 60;
986
+ const sessions = await getSessions().catch(() => []);
987
+ const snippets = [];
988
+ let truncated = false;
989
+ for (const session of sessions) {
990
+ if (snippets.length >= maxSnippets) {
991
+ truncated = true;
992
+ break;
993
+ }
994
+ if (kind === "ocuclaw" && !session.key.startsWith("ocuclaw:")) continue;
995
+ if (kind === "evenai" && !session.key.startsWith("evenai:")) continue;
996
+ let history;
997
+ try {
998
+ history = await gatewayBridge.request("chat.history", {
999
+ sessionKey: session.key,
1000
+ limit: 200,
1001
+ });
1002
+ } catch {
1003
+ continue;
1004
+ }
1005
+ const messages = (history && Array.isArray(history.messages)) ? history.messages : [];
1006
+ for (const msg of messages) {
1007
+ if (snippets.length >= maxSnippets) {
1008
+ truncated = true;
1009
+ break;
1010
+ }
1011
+ const text = extractRawMessageText(msg);
1012
+ if (!text) continue;
1013
+ const lower = text.toLowerCase();
1014
+ const idx = lower.indexOf(needle);
1015
+ if (idx < 0) continue;
1016
+ const matchEnd = idx + needle.length;
1017
+ const before = text.slice(Math.max(0, idx - contextChars), idx);
1018
+ const match = text.slice(idx, matchEnd);
1019
+ const after = text.slice(matchEnd, Math.min(text.length, matchEnd + contextChars));
1020
+ snippets.push({
1021
+ sessionKey: session.key,
1022
+ role: typeof msg.role === "string" ? msg.role : "",
1023
+ updatedAtMs: session.updatedAt || 0,
1024
+ before,
1025
+ match,
1026
+ after,
1027
+ });
1028
+ }
1029
+ }
1030
+ return { snippets, truncated };
1031
+ }
1032
+
1033
+ function extractRawMessageText(msg) {
1034
+ if (!msg) return "";
1035
+ if (typeof msg.content === "string") return msg.content;
1036
+ if (Array.isArray(msg.content)) {
1037
+ let acc = "";
1038
+ for (const block of msg.content) {
1039
+ if (block && block.type === "text" && typeof block.text === "string") {
1040
+ acc += (acc ? "\n" : "") + block.text;
1041
+ }
1042
+ }
1043
+ return acc;
1044
+ }
1045
+ return "";
1046
+ }
1047
+
1048
+ async function deleteSingleSession(kind, key) {
1049
+ // OpenClaw gateway exposes `sessions.delete({key, deleteTranscript, emitLifecycleHooks})`.
1050
+ // Resolve to a canonical key first so prefixes like "agent:main:" are applied
1051
+ // when the gateway expects them.
1052
+ const canonicalKey = await resolveSessionCanonicalKey(key);
1053
+ await gatewayBridge.request("sessions.delete", {
1054
+ key: canonicalKey,
1055
+ deleteTranscript: true,
1056
+ emitLifecycleHooks: false,
1057
+ });
1058
+ }
1059
+
1060
+ /**
1061
+ * Switch first if the active session is in the batch; then delete.
1062
+ * Used when the UI sets `switchBeforeDelete=true`.
1063
+ * @param {"ocuclaw"|"evenai"} kind
1064
+ * @param {string[]} sessionKeys
1065
+ */
1066
+ async function switchAndDeleteSessions(kind, sessionKeys) {
1067
+ if (
1068
+ kind === "ocuclaw" &&
1069
+ currentSessionKey &&
1070
+ sessionKeys.includes(currentSessionKey)
1071
+ ) {
1072
+ await newSession();
1073
+ }
1074
+ return deleteSessions(kind, sessionKeys);
1075
+ }
1076
+
1077
+ /**
1078
+ * Re-fetch + broadcast the sessions snapshot for a given kind.
1079
+ * Used by handlers after pin/delete writes.
1080
+ * @param {"ocuclaw"|"evenai"} kind
1081
+ */
1082
+ async function broadcastSessionsForKind(kind) {
1083
+ invalidateSessionsCache();
1084
+ if (kind === "ocuclaw" && typeof opts.broadcastSessions === "function") {
1085
+ try {
1086
+ await opts.broadcastSessions();
1087
+ } catch (err) {
1088
+ logger.error(
1089
+ `[relay] broadcastSessions failed: ${err?.message ?? err}`,
1090
+ );
1091
+ }
1092
+ } else if (
1093
+ kind === "evenai" &&
1094
+ typeof opts.broadcastEvenAiSessions === "function"
1095
+ ) {
1096
+ try {
1097
+ await opts.broadcastEvenAiSessions();
1098
+ } catch (err) {
1099
+ logger.error(
1100
+ `[relay] broadcastEvenAiSessions failed: ${err?.message ?? err}`,
1101
+ );
1102
+ }
1103
+ }
1104
+ }
1105
+
1106
+ function pruneSessionTitleEntries(cache) {
1107
+ while (cache.size > firstUserMessageCacheLimit) {
1108
+ let evicted = false;
1109
+ for (const sessionKey of cache.keys()) {
1110
+ if (shouldPinFirstUserMessageKey(sessionKey)) {
1111
+ continue;
1112
+ }
1113
+ cache.delete(sessionKey);
1114
+ evicted = true;
1115
+ break;
1116
+ }
1117
+ if (!evicted) {
1118
+ break;
1119
+ }
1120
+ }
1121
+ }
1122
+
1123
+ function loadFirstSentUserMessageCache() {
1124
+ if (!persistFirstUserMessages || !firstUserMessageCachePath) return new Map();
1125
+ try {
1126
+ if (!fs.existsSync(firstUserMessageCachePath)) {
1127
+ return new Map();
1128
+ }
1129
+ const raw = fs.readFileSync(firstUserMessageCachePath, "utf8");
1130
+ const parsed = JSON.parse(raw);
1131
+ const sessions =
1132
+ parsed &&
1133
+ parsed.version === 1 &&
1134
+ parsed.sessions &&
1135
+ typeof parsed.sessions === "object"
1136
+ ? parsed.sessions
1137
+ : {};
1138
+ const out = new Map();
1139
+ for (const [sessionKey, value] of Object.entries(sessions)) {
1140
+ const normalized = normalizeSessionText(value);
1141
+ if (!sessionKey || !normalized) continue;
1142
+ out.set(sessionKey, normalized);
1143
+ }
1144
+ pruneFirstUserMessageEntries(out);
1145
+ return out;
1146
+ } catch {
1147
+ return new Map();
1148
+ }
1149
+ }
1150
+
1151
+ // Coalesced async persistence for the first-user-message cache. Mirrors the
1152
+ // ocuclaw-settings-store writeInFlight/pendingWrite shape (commit 6285bc24)
1153
+ // so the SYNC fs.mkdirSync + fs.writeFileSync no longer block the hot send
1154
+ // path on every first user message per session.
1155
+ //
1156
+ // The write serializes the WHOLE in-memory map, so it is a full-snapshot
1157
+ // replace (not a disk read-modify-write). Concurrent fire-and-forget writes
1158
+ // could therefore lose updates (an older snapshot landing after a newer one).
1159
+ // We coalesce: at most one write in flight; any schedule while a write is in
1160
+ // flight marks the cache dirty, and the in-flight write re-runs once it
1161
+ // settles — so the final on-disk file always reflects the latest map.
1162
+ let firstUserCacheWriteInFlight = false;
1163
+ let firstUserCacheDirty = false;
1164
+ let firstUserCacheFlushPromise = null;
1165
+ let firstUserCacheFlushResolve = null;
1166
+
1167
+ async function writeFirstSentUserMessageCacheToDisk() {
1168
+ const sessions = {};
1169
+ for (const [sessionKey, text] of firstSentUserMessageBySession) {
1170
+ sessions[sessionKey] = text;
1171
+ }
1172
+ const payload =
1173
+ JSON.stringify(
1174
+ {
1175
+ version: 1,
1176
+ updatedAtMs: Date.now(),
1177
+ sessions,
1178
+ },
1179
+ null,
1180
+ 2,
1181
+ ) + "\n";
1182
+ const tmpPath = `${firstUserMessageCachePath}.tmp`;
1183
+ try {
1184
+ await fs.promises.mkdir(path.dirname(firstUserMessageCachePath), {
1185
+ recursive: true,
1186
+ });
1187
+ await fs.promises.writeFile(tmpPath, payload);
1188
+ await fs.promises.rename(tmpPath, firstUserMessageCachePath);
1189
+ } catch (err) {
1190
+ logger.error(
1191
+ `[relay] Failed to persist session first-user cache: ${err && err.message ? err.message : err}`,
754
1192
  );
755
1193
  }
756
1194
  }
757
1195
 
1196
+ function runFirstSentUserMessageCacheWrite() {
1197
+ if (firstUserCacheWriteInFlight) {
1198
+ return;
1199
+ }
1200
+ if (!firstUserCacheDirty) {
1201
+ // Nothing more to write — settle any awaiters and clear the flush gate.
1202
+ if (firstUserCacheFlushResolve) {
1203
+ const resolve = firstUserCacheFlushResolve;
1204
+ firstUserCacheFlushResolve = null;
1205
+ firstUserCacheFlushPromise = null;
1206
+ resolve();
1207
+ }
1208
+ return;
1209
+ }
1210
+ firstUserCacheDirty = false;
1211
+ firstUserCacheWriteInFlight = true;
1212
+ writeFirstSentUserMessageCacheToDisk().finally(() => {
1213
+ firstUserCacheWriteInFlight = false;
1214
+ // Re-run drains any dirty mark accumulated during this write, then
1215
+ // settles the flush gate once the map and disk agree.
1216
+ runFirstSentUserMessageCacheWrite();
1217
+ });
1218
+ }
1219
+
1220
+ function persistFirstSentUserMessageCache() {
1221
+ if (!persistFirstUserMessages || !firstUserMessageCachePath) return;
1222
+ firstUserCacheDirty = true;
1223
+ runFirstSentUserMessageCacheWrite();
1224
+ }
1225
+
1226
+ // Awaitable flush: resolves once no write is in flight AND no dirty mark is
1227
+ // pending (the on-disk file reflects the latest in-memory map). Used by tests
1228
+ // after rapid sends and available for graceful shutdown.
1229
+ function flushFirstSentUserMessageCache() {
1230
+ if (!persistFirstUserMessages || !firstUserMessageCachePath) {
1231
+ return Promise.resolve();
1232
+ }
1233
+ if (!firstUserCacheWriteInFlight && !firstUserCacheDirty) {
1234
+ return Promise.resolve();
1235
+ }
1236
+ if (!firstUserCacheFlushPromise) {
1237
+ firstUserCacheFlushPromise = new Promise((resolve) => {
1238
+ firstUserCacheFlushResolve = resolve;
1239
+ });
1240
+ }
1241
+ return firstUserCacheFlushPromise;
1242
+ }
1243
+
758
1244
  function pruneFirstSentUserMessageCache() {
759
1245
  pruneFirstUserMessageEntries(firstSentUserMessageBySession);
760
1246
  }
@@ -775,6 +1261,91 @@ export function createSessionService(opts = {}) {
775
1261
  pruneFirstUserMessageCache();
776
1262
  }
777
1263
 
1264
+ function getSessionTitle(sessionKey) {
1265
+ const entry = sessionTitleByKey.get(sessionKey);
1266
+ return entry ? entry.title : null;
1267
+ }
1268
+
1269
+ function setSessionTitle(sessionKey, title, opts) {
1270
+ if (typeof sessionKey !== "string" || !sessionKey.trim()) {
1271
+ return { ok: false, code: "invalid_session_key" };
1272
+ }
1273
+ if (typeof title !== "string" || !title.trim()) {
1274
+ return { ok: false, code: "invalid_title" };
1275
+ }
1276
+ const trimmed = title.trim();
1277
+ const setByUser = !!(opts && opts.userSet === true);
1278
+ const previous = sessionTitleByKey.get(sessionKey);
1279
+
1280
+ if (!setByUser && previous && previous.userSet === true) {
1281
+ return { ok: false, code: "session_user_locked" };
1282
+ }
1283
+
1284
+ const replaced = !!previous;
1285
+ const nextUserSet = setByUser || (previous && previous.userSet === true);
1286
+ sessionTitleByKey.set(sessionKey, {
1287
+ title: trimmed,
1288
+ setAtMs: Date.now(),
1289
+ userSet: !!nextUserSet,
1290
+ });
1291
+ pruneSessionTitleEntries(sessionTitleByKey);
1292
+ persistSessionTitleCache();
1293
+ invalidateSessionsCache();
1294
+ emitDebug(
1295
+ "relay.session",
1296
+ setByUser ? "session_title_set_by_user" : "session_title_set",
1297
+ "info",
1298
+ { sessionKey },
1299
+ () => ({ sessionKey, title: trimmed, replaced, userSet: !!nextUserSet }),
1300
+ );
1301
+ // Fire-and-forget upstream mirror.
1302
+ if (isUpstreamConnected()) {
1303
+ resolveSessionCanonicalKey(sessionKey)
1304
+ .then((canonicalKey) =>
1305
+ gatewayBridge.request("sessions.patch", {
1306
+ key: canonicalKey,
1307
+ displayName: trimmed,
1308
+ }),
1309
+ )
1310
+ .catch((err) => {
1311
+ emitDebug(
1312
+ "relay.session",
1313
+ "session_title_upstream_patch_failed",
1314
+ "debug",
1315
+ { sessionKey },
1316
+ () => ({ message: err && err.message ? err.message : String(err) }),
1317
+ );
1318
+ });
1319
+ }
1320
+ return { ok: true, replaced, userSet: !!nextUserSet };
1321
+ }
1322
+
1323
+ function isSessionUserLocked(sessionKey) {
1324
+ const entry = sessionTitleByKey.get(sessionKey);
1325
+ return entry ? entry.userSet === true : false;
1326
+ }
1327
+
1328
+ function hasRecordedFirstUserMessage(sessionKey) {
1329
+ if (typeof sessionKey !== "string" || !sessionKey.trim()) return false;
1330
+ return firstSentUserMessageBySession.has(sessionKey);
1331
+ }
1332
+
1333
+ function recordNeuralSessionNamesEnabled(sessionKey, enabled) {
1334
+ if (typeof sessionKey !== "string" || !sessionKey.trim()) return;
1335
+ neuralSessionNamesEnabledByKey.set(sessionKey, enabled === true);
1336
+ while (neuralSessionNamesEnabledByKey.size > firstUserMessageCacheLimit) {
1337
+ const oldest = neuralSessionNamesEnabledByKey.keys().next().value;
1338
+ if (oldest === undefined) break;
1339
+ neuralSessionNamesEnabledByKey.delete(oldest);
1340
+ }
1341
+ }
1342
+
1343
+ function isNeuralSessionNamesEnabled(sessionKey) {
1344
+ if (typeof sessionKey !== "string" || !sessionKey.trim()) return true;
1345
+ const cached = neuralSessionNamesEnabledByKey.get(sessionKey);
1346
+ return cached === undefined ? true : cached;
1347
+ }
1348
+
778
1349
  function isSyntheticSessionStarter(text) {
779
1350
  if (!text) return false;
780
1351
  if (
@@ -945,7 +1516,14 @@ export function createSessionService(opts = {}) {
945
1516
  });
946
1517
  const messages =
947
1518
  result && Array.isArray(result.messages) ? result.messages : [];
948
- conversationState.hydrate(messages, getAgentName());
1519
+ const sanitized = Array.isArray(messages)
1520
+ ? messages.map((msg) =>
1521
+ msg && msg.role === "assistant"
1522
+ ? { ...msg, content: sanitizeAssistantContentBlocks(msg.content) }
1523
+ : msg,
1524
+ )
1525
+ : messages;
1526
+ conversationState.hydrate(sanitized, getAgentName());
949
1527
  } catch (err) {
950
1528
  logger.error(
951
1529
  `[relay] Failed to load session history: ${err.message}`,
@@ -1035,6 +1613,7 @@ export function createSessionService(opts = {}) {
1035
1613
  peekSessionKey,
1036
1614
  createDetachedSessionKey,
1037
1615
  recordFirstSentUserMessage,
1616
+ flushFirstSentUserMessageCache,
1038
1617
  invalidateSessionsCache,
1039
1618
  handleUpstreamStatusChange,
1040
1619
  getSessionModelConfig,
@@ -1045,9 +1624,21 @@ export function createSessionService(opts = {}) {
1045
1624
  hasPendingInitialConfig,
1046
1625
  clearPendingInitialConfig,
1047
1626
  getSessions,
1627
+ getSessionTitle,
1048
1628
  getSessionsByExactKeys,
1629
+ hasRecordedFirstUserMessage,
1630
+ isNeuralSessionNamesEnabled,
1631
+ isSessionUserLocked,
1632
+ recordNeuralSessionNamesEnabled,
1633
+ setSessionTitle,
1049
1634
  switchToSession,
1050
1635
  newSession,
1051
1636
  isCurrentSession,
1637
+ setSessionPinned,
1638
+ getSessionPin,
1639
+ deleteSessions,
1640
+ switchAndDeleteSessions,
1641
+ broadcastSessionsForKind,
1642
+ searchTranscripts,
1052
1643
  };
1053
1644
  }