ocuclaw 0.1.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.
@@ -0,0 +1,1026 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+
4
+ const SESSION_FIRST_USER_CACHE_FILE = "session-first-user-cache.json";
5
+
6
+ function normalizeLogger(logger) {
7
+ if (!logger || typeof logger !== "object") {
8
+ return console;
9
+ }
10
+ return {
11
+ info: typeof logger.info === "function" ? logger.info.bind(logger) : console.log,
12
+ warn: typeof logger.warn === "function" ? logger.warn.bind(logger) : console.warn,
13
+ error: typeof logger.error === "function" ? logger.error.bind(logger) : console.error,
14
+ debug:
15
+ typeof logger.debug === "function" ? logger.debug.bind(logger) : console.debug,
16
+ };
17
+ }
18
+
19
+ function normalizeStateDir(stateDir) {
20
+ if (typeof stateDir !== "string") return null;
21
+ const trimmed = stateDir.trim();
22
+ return trimmed ? trimmed : null;
23
+ }
24
+
25
+ function resolveSessionFirstUserMessageCachePath(stateDir) {
26
+ const resolvedStateDir = normalizeStateDir(stateDir);
27
+ if (!resolvedStateDir) return null;
28
+ return path.join(resolvedStateDir, SESSION_FIRST_USER_CACHE_FILE);
29
+ }
30
+
31
+ export function createSessionService(opts = {}) {
32
+ const logger = normalizeLogger(opts.logger);
33
+ const gatewayBridge = opts.gatewayBridge;
34
+ const conversationState = opts.conversationState;
35
+ const emitDebug = typeof opts.emitDebug === "function" ? opts.emitDebug : () => {};
36
+ const getAgentName =
37
+ typeof opts.getAgentName === "function" ? opts.getAgentName : () => null;
38
+ const isUpstreamConnected =
39
+ typeof opts.isUpstreamConnected === "function"
40
+ ? opts.isUpstreamConnected
41
+ : typeof opts.getOpenclawConnected === "function"
42
+ ? opts.getOpenclawConnected
43
+ : () => false;
44
+ const onSessionStateReset =
45
+ typeof opts.onSessionStateReset === "function"
46
+ ? opts.onSessionStateReset
47
+ : null;
48
+ const onPagesChanged =
49
+ typeof opts.onPagesChanged === "function" ? opts.onPagesChanged : null;
50
+ const onStatusChanged =
51
+ typeof opts.onStatusChanged === "function" ? opts.onStatusChanged : null;
52
+ const onSessionModelConfig =
53
+ typeof opts.onSessionModelConfig === "function"
54
+ ? opts.onSessionModelConfig
55
+ : null;
56
+
57
+ /** Current session key. Generated on first use. */
58
+ let currentSessionKey = null;
59
+ /** Locally-created session key pending visibility in upstream sessions.list. */
60
+ let pendingSessionListKey = null;
61
+ let lastGeneratedSessionTimestamp = 0;
62
+ const DEFAULT_SESSION_KEY_PREFIX =
63
+ typeof opts.defaultSessionKeyPrefix === "string" &&
64
+ opts.defaultSessionKeyPrefix.trim()
65
+ ? opts.defaultSessionKeyPrefix.trim()
66
+ : "ocuclaw:";
67
+ const SUPPORTED_SESSION_KEY_PREFIXES =
68
+ Array.isArray(opts.supportedSessionKeyPrefixes) &&
69
+ opts.supportedSessionKeyPrefixes.length > 0
70
+ ? opts.supportedSessionKeyPrefixes
71
+ : [DEFAULT_SESSION_KEY_PREFIX];
72
+ const SUPPORTED_SESSION_KEY_PREFIXES_LOWER = SUPPORTED_SESSION_KEY_PREFIXES.map(
73
+ (prefix) => String(prefix || "").toLowerCase(),
74
+ );
75
+
76
+ /** Maximum number of sessions to fetch. */
77
+ const sessionLimit = opts.sessionLimit || 10;
78
+ /** Whether to persist first real user message cache to disk. */
79
+ const persistFirstUserMessages = opts.persistFirstUserMessages !== false;
80
+ /**
81
+ * Strict mode by default: only use first real downstream sends for session preview labels.
82
+ * Set to false to re-enable legacy chat.history/fallback extraction.
83
+ */
84
+ const strictFirstUserMessage = opts.strictFirstUserMessage !== false;
85
+ /** Path for first real user message cache file. */
86
+ const firstUserMessageCachePath = resolveSessionFirstUserMessageCachePath(
87
+ opts.stateDir,
88
+ );
89
+
90
+ /** TTL for cached sessions.list payloads. */
91
+ const sessionCacheTtlMs =
92
+ Number.isFinite(opts.sessionCacheTtlMs) && opts.sessionCacheTtlMs > 0
93
+ ? Math.floor(opts.sessionCacheTtlMs)
94
+ : 5000;
95
+ /** @type {Array<{key: string, updatedAt: number, preview: string, firstUserMessage: string}>|null} */
96
+ let cachedSessions = null;
97
+ /** Epoch ms when cachedSessions was last refreshed. */
98
+ let cachedSessionsFetchedAt = 0;
99
+ /** @type {Promise<Array<{key: string, updatedAt: number, preview: string, firstUserMessage: string}>>|null} */
100
+ let inFlightSessionsFetch = null;
101
+
102
+ /** Last-known session model config per session key. */
103
+ const sessionModelConfigCache = new Map();
104
+ /** New OcuClaw sessions awaiting one-time default model/thinking seeding. */
105
+ const pendingInitialConfigSessionKeys = new Set();
106
+ /** @type {Map<string, {updatedAt: number, firstUserMessage: string}>} */
107
+ const firstUserMessageCache = new Map();
108
+ const firstUserMessageCacheLimit = Math.max(64, sessionLimit * 8);
109
+ /** @type {Map<string, string>} First real user text observed from downstream send events. */
110
+ const firstSentUserMessageBySession = loadFirstSentUserMessageCache();
111
+
112
+ /**
113
+ * Generate a new OcuClaw session key.
114
+ * @returns {string} e.g. "ocuclaw:1739500000000"
115
+ */
116
+ function generateSessionKey(rawPrefix = DEFAULT_SESSION_KEY_PREFIX) {
117
+ const effectivePrefix =
118
+ typeof rawPrefix === "string" && rawPrefix.trim()
119
+ ? rawPrefix.trim()
120
+ : DEFAULT_SESSION_KEY_PREFIX;
121
+ const nowMs = Date.now();
122
+ const nextTimestamp =
123
+ nowMs > lastGeneratedSessionTimestamp
124
+ ? nowMs
125
+ : lastGeneratedSessionTimestamp + 1;
126
+ lastGeneratedSessionTimestamp = nextTimestamp;
127
+ return `${effectivePrefix}${nextTimestamp}`;
128
+ }
129
+
130
+ /**
131
+ * Get or create the current session key.
132
+ * @returns {string}
133
+ */
134
+ function ensureSessionKey() {
135
+ if (!currentSessionKey) {
136
+ currentSessionKey = generateSessionKey();
137
+ }
138
+ return currentSessionKey;
139
+ }
140
+
141
+ function peekSessionKey() {
142
+ return currentSessionKey;
143
+ }
144
+
145
+ function createDetachedSessionKey(prefix) {
146
+ const sessionKey = generateSessionKey(prefix);
147
+ invalidateSessionsCache();
148
+ pendingSessionListKey = sessionKey;
149
+ emitDebug(
150
+ "relay.session",
151
+ "detached_session_prepared",
152
+ "info",
153
+ { sessionKey },
154
+ () => ({
155
+ sessionKey,
156
+ }),
157
+ );
158
+ return sessionKey;
159
+ }
160
+
161
+ function normalizeThinkingLevel(raw) {
162
+ if (typeof raw !== "string") return "";
163
+ const normalized = raw.trim().toLowerCase();
164
+ if (
165
+ normalized === "off" ||
166
+ normalized === "minimal" ||
167
+ normalized === "low" ||
168
+ normalized === "medium" ||
169
+ normalized === "high" ||
170
+ normalized === "xhigh"
171
+ ) {
172
+ return normalized;
173
+ }
174
+ return "";
175
+ }
176
+
177
+ function normalizeReasoningLevel(raw) {
178
+ if (typeof raw !== "string") return "off";
179
+ const normalized = raw.trim().toLowerCase();
180
+ if (normalized === "stream") return "stream";
181
+ if (normalized === "on") return "on";
182
+ return "off";
183
+ }
184
+
185
+ function normalizeVerboseLevel(raw) {
186
+ if (typeof raw !== "string") return "off";
187
+ const normalized = raw.trim().toLowerCase();
188
+ if (normalized === "off" || normalized === "on" || normalized === "full") {
189
+ return normalized;
190
+ }
191
+ return "off";
192
+ }
193
+
194
+ function normalizeSessionModelRef(modelProviderRaw, modelRaw) {
195
+ let modelProvider =
196
+ typeof modelProviderRaw === "string" && modelProviderRaw.trim()
197
+ ? modelProviderRaw.trim()
198
+ : null;
199
+ let model =
200
+ typeof modelRaw === "string" && modelRaw.trim() ? modelRaw.trim() : null;
201
+ if (model && model.includes("/")) {
202
+ const slashIdx = model.indexOf("/");
203
+ const splitProvider = model.slice(0, slashIdx).trim();
204
+ const splitModel = model.slice(slashIdx + 1).trim();
205
+ if (!modelProvider && splitProvider) modelProvider = splitProvider;
206
+ model = splitModel || model;
207
+ }
208
+ return {
209
+ modelProvider,
210
+ model,
211
+ };
212
+ }
213
+
214
+ function buildSessionModelConfig(sessionKey, row) {
215
+ const normalized = normalizeSessionModelRef(
216
+ row && row.modelProvider,
217
+ row && row.model,
218
+ );
219
+ return {
220
+ sessionKey,
221
+ modelProvider: normalized.modelProvider,
222
+ model: normalized.model,
223
+ thinkingLevel: normalizeThinkingLevel(row && row.thinkingLevel),
224
+ reasoningLevel: normalizeReasoningLevel(row && row.reasoningLevel),
225
+ verboseLevel: normalizeVerboseLevel(row && row.verboseLevel),
226
+ };
227
+ }
228
+
229
+ function listSessionsBySearch(search) {
230
+ return gatewayBridge.request("sessions.list", {
231
+ search,
232
+ includeGlobal: false,
233
+ includeUnknown: false,
234
+ limit: 10,
235
+ });
236
+ }
237
+
238
+ async function resolveSessionCanonicalKey(sessionKey) {
239
+ if (!isUpstreamConnected()) return sessionKey;
240
+ if (hasSupportedSessionKeyPrefix(sessionKey)) {
241
+ return sessionKey;
242
+ }
243
+ try {
244
+ const resolved = await gatewayBridge.request("sessions.resolve", {
245
+ key: sessionKey,
246
+ includeGlobal: false,
247
+ includeUnknown: false,
248
+ });
249
+ if (resolved && typeof resolved.key === "string" && resolved.key.trim()) {
250
+ return resolved.key.trim();
251
+ }
252
+ } catch {
253
+ // keep raw key fallback
254
+ }
255
+ return sessionKey;
256
+ }
257
+
258
+ function normalizeExactSessionKey(rawKey) {
259
+ if (typeof rawKey !== "string") return "";
260
+ const trimmed = rawKey.trim();
261
+ if (!trimmed) return "";
262
+ const shortKey = extractShortKey(trimmed);
263
+ return hasSupportedSessionKeyPrefix(shortKey) ? shortKey : "";
264
+ }
265
+
266
+ function findBestSessionRow(rows, targetKey, canonicalKey) {
267
+ if (!Array.isArray(rows) || rows.length === 0) return null;
268
+ const targetShort = extractShortKey(targetKey || "");
269
+ const canonicalShort = extractShortKey(canonicalKey || "");
270
+
271
+ const fullCanonicalMatch = rows.find((row) => {
272
+ return row && typeof row.key === "string" && canonicalKey && row.key === canonicalKey;
273
+ });
274
+ if (fullCanonicalMatch) return fullCanonicalMatch;
275
+
276
+ const shortCanonicalMatch = rows.find((row) => {
277
+ if (!row || typeof row.key !== "string") return false;
278
+ return canonicalShort && extractShortKey(row.key) === canonicalShort;
279
+ });
280
+ if (shortCanonicalMatch) return shortCanonicalMatch;
281
+
282
+ return (
283
+ rows.find((row) => {
284
+ if (!row || typeof row.key !== "string") return false;
285
+ return targetShort && extractShortKey(row.key) === targetShort;
286
+ }) || null
287
+ );
288
+ }
289
+
290
+ async function fetchCurrentSessionRow(sessionKey) {
291
+ const canonicalKey = await resolveSessionCanonicalKey(sessionKey);
292
+ const firstResult = await listSessionsBySearch(sessionKey);
293
+ const firstRows =
294
+ firstResult && Array.isArray(firstResult.sessions)
295
+ ? firstResult.sessions
296
+ : [];
297
+ const firstMatch = findBestSessionRow(firstRows, sessionKey, canonicalKey);
298
+ if (firstMatch) {
299
+ return { row: firstMatch, canonicalKey };
300
+ }
301
+
302
+ const secondResult = await listSessionsBySearch(canonicalKey);
303
+ const secondRows =
304
+ secondResult && Array.isArray(secondResult.sessions)
305
+ ? secondResult.sessions
306
+ : [];
307
+ const secondMatch = findBestSessionRow(secondRows, sessionKey, canonicalKey);
308
+ return { row: secondMatch, canonicalKey };
309
+ }
310
+
311
+ function cachedSessionModelConfig(sessionKey) {
312
+ return sessionModelConfigCache.get(sessionKey) || buildSessionModelConfig(sessionKey, null);
313
+ }
314
+
315
+ function primeSessionModelConfig(sessionKey, patch) {
316
+ const base = cachedSessionModelConfig(sessionKey);
317
+ const normalizedModel = Object.prototype.hasOwnProperty.call(patch || {}, "modelRef")
318
+ ? normalizeSessionModelRef(null, patch && patch.modelRef)
319
+ : {
320
+ modelProvider: base.modelProvider,
321
+ model: base.model,
322
+ };
323
+ const config = {
324
+ sessionKey,
325
+ modelProvider: normalizedModel.modelProvider,
326
+ model: normalizedModel.model,
327
+ thinkingLevel:
328
+ patch && Object.prototype.hasOwnProperty.call(patch, "thinkingLevel")
329
+ ? normalizeThinkingLevel(patch.thinkingLevel)
330
+ : base.thinkingLevel,
331
+ reasoningLevel:
332
+ patch && Object.prototype.hasOwnProperty.call(patch, "reasoningEnabled")
333
+ ? normalizeReasoningLevel(patch.reasoningEnabled ? "on" : "off")
334
+ : base.reasoningLevel,
335
+ verboseLevel:
336
+ patch && Object.prototype.hasOwnProperty.call(patch, "verboseLevel")
337
+ ? normalizeVerboseLevel(patch.verboseLevel)
338
+ : base.verboseLevel,
339
+ };
340
+ sessionModelConfigCache.set(sessionKey, config);
341
+ return config;
342
+ }
343
+
344
+ async function getSessionModelConfig(sessionKey = ensureSessionKey()) {
345
+ if (!isUpstreamConnected()) {
346
+ return cachedSessionModelConfig(sessionKey);
347
+ }
348
+ try {
349
+ const resolved = await fetchCurrentSessionRow(sessionKey);
350
+ const row = resolved && resolved.row ? resolved.row : null;
351
+ if (!row) {
352
+ return cachedSessionModelConfig(sessionKey);
353
+ }
354
+ const config = buildSessionModelConfig(sessionKey, row);
355
+ sessionModelConfigCache.set(sessionKey, config);
356
+ return config;
357
+ } catch (err) {
358
+ emitDebug(
359
+ "relay.session",
360
+ "session_model_config_fetch_failed",
361
+ "warn",
362
+ { sessionKey },
363
+ () => ({
364
+ message: err && err.message ? err.message : String(err),
365
+ }),
366
+ );
367
+ return cachedSessionModelConfig(sessionKey);
368
+ }
369
+ }
370
+
371
+ async function getCurrentSessionModelConfig() {
372
+ return getSessionModelConfig(ensureSessionKey());
373
+ }
374
+
375
+ async function setSessionModelConfig(sessionKey = ensureSessionKey(), patch) {
376
+ if (!isUpstreamConnected()) {
377
+ return {
378
+ status: "rejected",
379
+ error: "OpenClaw disconnected",
380
+ };
381
+ }
382
+
383
+ let canonicalKey = await resolveSessionCanonicalKey(sessionKey);
384
+ if (hasSupportedSessionKeyPrefix(sessionKey)) {
385
+ const resolved = await fetchCurrentSessionRow(sessionKey);
386
+ const row = resolved && resolved.row ? resolved.row : null;
387
+ if (row && typeof row.key === "string" && row.key.trim()) {
388
+ canonicalKey = row.key.trim();
389
+ }
390
+ }
391
+ const request = { key: canonicalKey };
392
+ if (patch && typeof patch.modelRef === "string") {
393
+ request.model = patch.modelRef;
394
+ }
395
+ if (patch && Object.prototype.hasOwnProperty.call(patch, "thinkingLevel")) {
396
+ request.thinkingLevel =
397
+ typeof patch.thinkingLevel === "string" && patch.thinkingLevel.trim()
398
+ ? normalizeThinkingLevel(patch.thinkingLevel)
399
+ : null;
400
+ }
401
+ if (patch && patch.reasoningEnabled !== undefined) {
402
+ request.reasoningLevel = patch.reasoningEnabled ? "on" : "off";
403
+ }
404
+ if (patch && typeof patch.verboseLevel === "string") {
405
+ request.verboseLevel = patch.verboseLevel;
406
+ }
407
+
408
+ try {
409
+ await gatewayBridge.request("sessions.patch", request);
410
+ const config = await getSessionModelConfig(sessionKey);
411
+ sessionModelConfigCache.set(sessionKey, config);
412
+ pendingInitialConfigSessionKeys.delete(sessionKey);
413
+ if (onSessionModelConfig && normalizeSessionKeyForCompare(sessionKey) === normalizeSessionKeyForCompare(ensureSessionKey())) {
414
+ onSessionModelConfig(config);
415
+ }
416
+ return { status: "accepted", config };
417
+ } catch (err) {
418
+ emitDebug(
419
+ "relay.session",
420
+ "session_model_config_set_failed",
421
+ "warn",
422
+ { sessionKey },
423
+ () => ({
424
+ message: err && err.message ? err.message : String(err),
425
+ }),
426
+ );
427
+ return {
428
+ status: "rejected",
429
+ error: err && err.message ? err.message : "sessions.patch failed",
430
+ };
431
+ }
432
+ }
433
+
434
+ async function setCurrentSessionModelConfig(patch) {
435
+ return setSessionModelConfig(ensureSessionKey(), patch);
436
+ }
437
+
438
+ /**
439
+ * Fetch the list of OcuClaw sessions from OpenClaw.
440
+ * Filters to OcuClaw session key prefix, sorted by updatedAt descending.
441
+ * @returns {Promise<Array<{key: string, updatedAt: number, preview: string, firstUserMessage: string}>>}
442
+ */
443
+ async function getSessions() {
444
+ if (cachedSessions && Date.now() - cachedSessionsFetchedAt < sessionCacheTtlMs) {
445
+ return cachedSessions;
446
+ }
447
+ if (inFlightSessionsFetch) {
448
+ return inFlightSessionsFetch;
449
+ }
450
+ if (!isUpstreamConnected()) {
451
+ return cachedSessions || [];
452
+ }
453
+
454
+ inFlightSessionsFetch = (async () => {
455
+ const result = await gatewayBridge.request("sessions.list", {
456
+ limit: sessionLimit,
457
+ });
458
+ const rows = (result && result.sessions) || [];
459
+ const sortedRows = rows
460
+ .filter((row) => {
461
+ const key = extractShortKey(row && row.key);
462
+ return hasSupportedSessionKeyPrefix(key) && !isEvenAiSessionKey(key);
463
+ })
464
+ .sort((left, right) => (right.updatedAt || 0) - (left.updatedAt || 0));
465
+
466
+ const sessions = await Promise.all(
467
+ sortedRows.map(async (row) => {
468
+ const key = extractShortKey(row.key);
469
+ const updatedAt = Number.isFinite(row.updatedAt)
470
+ ? Math.floor(row.updatedAt)
471
+ : 0;
472
+ const firstUserMessage = await resolveFirstUserMessage(
473
+ key,
474
+ updatedAt,
475
+ row.messages,
476
+ );
477
+ return {
478
+ key,
479
+ updatedAt,
480
+ preview: firstUserMessage
481
+ ? firstUserMessage.slice(0, 80)
482
+ : strictFirstUserMessage
483
+ ? ""
484
+ : extractPreview(row.messages),
485
+ firstUserMessage,
486
+ };
487
+ }),
488
+ );
489
+
490
+ if (
491
+ typeof pendingSessionListKey === "string" &&
492
+ hasSupportedSessionKeyPrefix(pendingSessionListKey) &&
493
+ !isEvenAiSessionKey(pendingSessionListKey)
494
+ ) {
495
+ const hasPendingSession = sessions.some((session) =>
496
+ sameSessionKey(session && session.key, pendingSessionListKey),
497
+ );
498
+ if (hasPendingSession) {
499
+ pendingSessionListKey = null;
500
+ } else {
501
+ const updatedAt =
502
+ extractSessionTimestampFromKey(pendingSessionListKey) || Date.now();
503
+ const firstUserMessage = await resolveFirstUserMessage(
504
+ pendingSessionListKey,
505
+ updatedAt,
506
+ [],
507
+ );
508
+ sessions.unshift({
509
+ key: pendingSessionListKey,
510
+ updatedAt,
511
+ preview: firstUserMessage ? firstUserMessage.slice(0, 80) : "",
512
+ firstUserMessage,
513
+ });
514
+ }
515
+ }
516
+
517
+ return cacheSessions(sessions);
518
+ })();
519
+
520
+ return inFlightSessionsFetch.finally(() => {
521
+ inFlightSessionsFetch = null;
522
+ });
523
+ }
524
+
525
+ async function getSessionsByExactKeys(sessionKeys) {
526
+ if (!Array.isArray(sessionKeys) || sessionKeys.length === 0) {
527
+ return [];
528
+ }
529
+ if (!isUpstreamConnected()) {
530
+ return [];
531
+ }
532
+
533
+ const orderedKeys = [];
534
+ const seen = new Set();
535
+ for (const rawKey of sessionKeys) {
536
+ const normalizedKey = normalizeExactSessionKey(rawKey);
537
+ if (!normalizedKey) continue;
538
+ const dedupeKey = normalizedKey.toLowerCase();
539
+ if (seen.has(dedupeKey)) continue;
540
+ seen.add(dedupeKey);
541
+ orderedKeys.push(normalizedKey);
542
+ }
543
+
544
+ const sessions = [];
545
+ for (const sessionKey of orderedKeys) {
546
+ let resolved;
547
+ try {
548
+ resolved = await fetchCurrentSessionRow(sessionKey);
549
+ } catch (err) {
550
+ emitDebug(
551
+ "relay.session",
552
+ "session_exact_lookup_failed",
553
+ "debug",
554
+ { sessionKey },
555
+ () => ({
556
+ message: err && err.message ? err.message : String(err),
557
+ }),
558
+ );
559
+ continue;
560
+ }
561
+
562
+ const row = resolved && resolved.row ? resolved.row : null;
563
+ if (!row) {
564
+ continue;
565
+ }
566
+
567
+ const key = extractShortKey(row.key || sessionKey);
568
+ const updatedAt = Number.isFinite(row.updatedAt)
569
+ ? Math.floor(row.updatedAt)
570
+ : 0;
571
+ const fallbackMessages = Array.isArray(row.messages) ? row.messages : [];
572
+ const firstUserMessage = await resolveFirstUserMessage(
573
+ key,
574
+ updatedAt,
575
+ fallbackMessages,
576
+ );
577
+ sessions.push({
578
+ key,
579
+ updatedAt,
580
+ preview: firstUserMessage
581
+ ? firstUserMessage.slice(0, 80)
582
+ : strictFirstUserMessage
583
+ ? ""
584
+ : extractPreview(fallbackMessages),
585
+ firstUserMessage,
586
+ });
587
+ }
588
+
589
+ return sessions;
590
+ }
591
+
592
+ /**
593
+ * Extract the short session key from a fully-qualified gateway key.
594
+ * "agent:main:ocuclaw:1234567890" -> "ocuclaw:1234567890"
595
+ * @param {string} fullKey
596
+ * @returns {string}
597
+ */
598
+ function extractShortKey(fullKey) {
599
+ if (typeof fullKey !== "string") return "";
600
+ const fullKeyLower = fullKey.toLowerCase();
601
+ let prefixIndex = -1;
602
+ for (const prefix of SUPPORTED_SESSION_KEY_PREFIXES_LOWER) {
603
+ const idx = fullKeyLower.indexOf(prefix);
604
+ if (idx >= 0 && (prefixIndex < 0 || idx < prefixIndex)) {
605
+ prefixIndex = idx;
606
+ }
607
+ }
608
+ return prefixIndex >= 0 ? fullKey.slice(prefixIndex) : fullKey;
609
+ }
610
+
611
+ /**
612
+ * Check whether a full/canonical session key belongs to a supported
613
+ * OcuClaw session namespace.
614
+ * @param {string} key
615
+ * @returns {boolean}
616
+ */
617
+ function hasSupportedSessionKeyPrefix(key) {
618
+ if (typeof key !== "string" || key.length === 0) return false;
619
+ const keyLower = key.toLowerCase();
620
+ return SUPPORTED_SESSION_KEY_PREFIXES_LOWER.some((prefix) => keyLower.includes(prefix));
621
+ }
622
+
623
+ function isEvenAiSessionKey(key) {
624
+ if (typeof key !== "string" || !key.trim()) return false;
625
+ const normalized = extractShortKey(key).toLowerCase();
626
+ return (
627
+ normalized === "ocuclaw:even-ai" ||
628
+ normalized.startsWith("ocuclaw:even-ai:")
629
+ );
630
+ }
631
+
632
+ function sameSessionKey(left, right) {
633
+ if (typeof left !== "string" || typeof right !== "string") return false;
634
+ return left.toLowerCase() === right.toLowerCase();
635
+ }
636
+
637
+ /**
638
+ * Best-effort timestamp extraction from session key suffix.
639
+ * "ocuclaw:1739500000000" -> 1739500000000
640
+ * @param {string} sessionKey
641
+ * @returns {number}
642
+ */
643
+ function extractSessionTimestampFromKey(sessionKey) {
644
+ if (typeof sessionKey !== "string") return 0;
645
+ const idx = sessionKey.lastIndexOf(":");
646
+ if (idx < 0 || idx >= sessionKey.length - 1) return 0;
647
+ const maybeTs = Number.parseInt(sessionKey.slice(idx + 1), 10);
648
+ return Number.isFinite(maybeTs) && maybeTs > 0 ? maybeTs : 0;
649
+ }
650
+
651
+ /**
652
+ * Extract a preview string from a session's messages array.
653
+ * @param {Array} messages - Last N messages from sessions.list
654
+ * @returns {string}
655
+ */
656
+ function extractPreview(messages) {
657
+ if (!Array.isArray(messages) || messages.length === 0) return "";
658
+ for (const msg of messages) {
659
+ const text = extractMessageText(msg && msg.content);
660
+ if (!text || isSyntheticSessionStarter(text)) continue;
661
+ return text.slice(0, 80);
662
+ }
663
+ return "";
664
+ }
665
+
666
+ function extractMessageText(content) {
667
+ if (typeof content === "string") {
668
+ return normalizeSessionText(content);
669
+ }
670
+ if (!Array.isArray(content)) return "";
671
+ const textParts = [];
672
+ for (const block of content) {
673
+ if (block && block.type === "text" && typeof block.text === "string") {
674
+ const text = normalizeSessionText(block.text);
675
+ if (text) textParts.push(text);
676
+ }
677
+ }
678
+ return normalizeSessionText(textParts.join(" "));
679
+ }
680
+
681
+ function extractFirstUserMessage(messages) {
682
+ if (!Array.isArray(messages) || messages.length === 0) return "";
683
+ for (const msg of messages) {
684
+ const role =
685
+ msg && typeof msg.role === "string" ? msg.role.toLowerCase() : "";
686
+ if (role !== "user") continue;
687
+ const text = extractMessageText(msg.content);
688
+ if (isSyntheticSessionStarter(text)) continue;
689
+ if (text) return text;
690
+ }
691
+ return "";
692
+ }
693
+
694
+ function normalizeSessionText(text) {
695
+ if (typeof text !== "string") return "";
696
+ return text.replace(/\s+/g, " ").trim();
697
+ }
698
+
699
+ function loadFirstSentUserMessageCache() {
700
+ if (!persistFirstUserMessages || !firstUserMessageCachePath) return new Map();
701
+ try {
702
+ if (!fs.existsSync(firstUserMessageCachePath)) {
703
+ return new Map();
704
+ }
705
+ const raw = fs.readFileSync(firstUserMessageCachePath, "utf8");
706
+ const parsed = JSON.parse(raw);
707
+ const sessions =
708
+ parsed &&
709
+ parsed.version === 1 &&
710
+ parsed.sessions &&
711
+ typeof parsed.sessions === "object"
712
+ ? parsed.sessions
713
+ : {};
714
+ const out = new Map();
715
+ for (const [sessionKey, value] of Object.entries(sessions)) {
716
+ const normalized = normalizeSessionText(value);
717
+ if (!sessionKey || !normalized) continue;
718
+ out.set(sessionKey, normalized);
719
+ }
720
+ while (out.size > firstUserMessageCacheLimit) {
721
+ const oldestKey = out.keys().next().value;
722
+ if (oldestKey === undefined) break;
723
+ out.delete(oldestKey);
724
+ }
725
+ return out;
726
+ } catch {
727
+ return new Map();
728
+ }
729
+ }
730
+
731
+ function persistFirstSentUserMessageCache() {
732
+ if (!persistFirstUserMessages || !firstUserMessageCachePath) return;
733
+ try {
734
+ fs.mkdirSync(path.dirname(firstUserMessageCachePath), { recursive: true });
735
+ const sessions = {};
736
+ for (const [sessionKey, text] of firstSentUserMessageBySession) {
737
+ sessions[sessionKey] = text;
738
+ }
739
+ fs.writeFileSync(
740
+ firstUserMessageCachePath,
741
+ JSON.stringify(
742
+ {
743
+ version: 1,
744
+ updatedAtMs: Date.now(),
745
+ sessions,
746
+ },
747
+ null,
748
+ 2,
749
+ ) + "\n",
750
+ );
751
+ } catch (err) {
752
+ logger.error(
753
+ `[relay] Failed to persist session first-user cache: ${err.message}`,
754
+ );
755
+ }
756
+ }
757
+
758
+ function pruneFirstSentUserMessageCache() {
759
+ while (firstSentUserMessageBySession.size > firstUserMessageCacheLimit) {
760
+ const oldestKey = firstSentUserMessageBySession.keys().next().value;
761
+ if (oldestKey === undefined) break;
762
+ firstSentUserMessageBySession.delete(oldestKey);
763
+ }
764
+ }
765
+
766
+ function recordFirstSentUserMessage(sessionKey, text) {
767
+ const normalized = normalizeSessionText(text);
768
+ if (!normalized || normalized.startsWith("/")) return;
769
+ if (firstSentUserMessageBySession.has(sessionKey)) return;
770
+
771
+ firstSentUserMessageBySession.set(sessionKey, normalized);
772
+ pruneFirstSentUserMessageCache();
773
+ persistFirstSentUserMessageCache();
774
+
775
+ firstUserMessageCache.set(sessionKey, {
776
+ updatedAt: Number.MAX_SAFE_INTEGER,
777
+ firstUserMessage: normalized,
778
+ });
779
+ pruneFirstUserMessageCache();
780
+ }
781
+
782
+ function isSyntheticSessionStarter(text) {
783
+ if (!text) return false;
784
+ if (
785
+ typeof conversationState._isLikelySyntheticSessionStarterPrompt === "function" &&
786
+ conversationState._isLikelySyntheticSessionStarterPrompt(text)
787
+ ) {
788
+ return true;
789
+ }
790
+ const normalized = normalizeSessionText(text).toLowerCase();
791
+ if (!normalized.includes("/new") || !normalized.includes("/reset")) return false;
792
+ if (/^a\s+new\s+session\s+was\s+started\b/.test(normalized)) return true;
793
+ if (normalized.length < 80) return false;
794
+ if (
795
+ !/\b(?:new|fresh)\s+session\b|\bsession\b.*\b(?:started|reset|created)\b/.test(normalized)
796
+ ) {
797
+ return false;
798
+ }
799
+
800
+ let signalCount = 0;
801
+ if (/\bgreet\b/.test(normalized)) signalCount += 1;
802
+ if (/\bconfigured\b.*\b(?:persona|style|voice)\b/.test(normalized)) signalCount += 1;
803
+ if (/\bbe yourself\b|\bmannerisms\b|\bmood\b/.test(normalized)) signalCount += 1;
804
+ if (/\b(?:1-3|1 to 3|one to three)\s+sentences?\b/.test(normalized)) signalCount += 1;
805
+ if (/\bask\b.*\bwhat\b.*\bwant\b.*\bdo\b/.test(normalized)) signalCount += 1;
806
+ if (/\bdefault(?:_| )model\b/.test(normalized)) signalCount += 1;
807
+ if (/\bdo not mention\b/.test(normalized)) signalCount += 1;
808
+ if (/\binternal\b.*\b(?:steps|files|tools|reasoning)\b/.test(normalized)) signalCount += 1;
809
+ return signalCount >= 2;
810
+ }
811
+
812
+ function pruneFirstUserMessageCache() {
813
+ while (firstUserMessageCache.size > firstUserMessageCacheLimit) {
814
+ const oldestKey = firstUserMessageCache.keys().next().value;
815
+ if (oldestKey === undefined) break;
816
+ firstUserMessageCache.delete(oldestKey);
817
+ }
818
+ }
819
+
820
+ async function resolveFirstUserMessage(sessionKey, updatedAt, fallbackMessages) {
821
+ const firstObservedUserMessage = firstSentUserMessageBySession.get(sessionKey);
822
+ if (firstObservedUserMessage) {
823
+ return firstObservedUserMessage;
824
+ }
825
+
826
+ const cached = firstUserMessageCache.get(sessionKey);
827
+ if (cached && cached.firstUserMessage) {
828
+ return cached.firstUserMessage;
829
+ }
830
+ if (cached && cached.updatedAt === updatedAt) {
831
+ return cached.firstUserMessage;
832
+ }
833
+
834
+ if (strictFirstUserMessage) {
835
+ firstUserMessageCache.set(sessionKey, { updatedAt, firstUserMessage: "" });
836
+ pruneFirstUserMessageCache();
837
+ return "";
838
+ }
839
+
840
+ let firstUserMessage = "";
841
+ if (isUpstreamConnected()) {
842
+ try {
843
+ const result = await gatewayBridge.request("chat.history", {
844
+ sessionKey,
845
+ limit: 200,
846
+ });
847
+ firstUserMessage = extractFirstUserMessage(
848
+ result && Array.isArray(result.messages) ? result.messages : [],
849
+ );
850
+ } catch (err) {
851
+ emitDebug(
852
+ "relay.session",
853
+ "session_first_message_lookup_failed",
854
+ "debug",
855
+ { sessionKey },
856
+ () => ({
857
+ message: err && err.message ? err.message : String(err),
858
+ }),
859
+ );
860
+ }
861
+ }
862
+ if (!firstUserMessage) {
863
+ firstUserMessage = extractFirstUserMessage(fallbackMessages);
864
+ }
865
+ firstUserMessageCache.set(sessionKey, { updatedAt, firstUserMessage });
866
+ pruneFirstUserMessageCache();
867
+ return firstUserMessage;
868
+ }
869
+
870
+ function cacheSessions(sessions) {
871
+ cachedSessions = Array.isArray(sessions) ? sessions : [];
872
+ cachedSessionsFetchedAt = Date.now();
873
+ return cachedSessions;
874
+ }
875
+
876
+ function invalidateSessionsCache() {
877
+ cachedSessionsFetchedAt = 0;
878
+ }
879
+
880
+ function handleUpstreamStatusChange(connected) {
881
+ if (!connected) {
882
+ inFlightSessionsFetch = null;
883
+ }
884
+ }
885
+
886
+ /**
887
+ * Switch to a different session: load its history and return pages.
888
+ * @param {string} sessionKey
889
+ * @returns {Promise<Array>} Pages array
890
+ */
891
+ async function switchToSession(sessionKey, opts = {}) {
892
+ const markPendingSessionList =
893
+ opts.markPendingSessionList === true &&
894
+ hasSupportedSessionKeyPrefix(sessionKey);
895
+ invalidateSessionsCache();
896
+ if (onSessionStateReset) {
897
+ onSessionStateReset();
898
+ }
899
+ pendingSessionListKey = markPendingSessionList ? sessionKey : null;
900
+ currentSessionKey = sessionKey;
901
+ emitDebug(
902
+ "relay.session",
903
+ "switch_session",
904
+ "info",
905
+ { sessionKey },
906
+ () => ({
907
+ sessionKey,
908
+ markPendingSessionList,
909
+ }),
910
+ );
911
+ conversationState.clear();
912
+
913
+ if (isUpstreamConnected()) {
914
+ try {
915
+ const result = await gatewayBridge.request("chat.history", {
916
+ sessionKey,
917
+ limit: 200,
918
+ });
919
+ const messages =
920
+ result && Array.isArray(result.messages) ? result.messages : [];
921
+ conversationState.hydrate(messages, getAgentName());
922
+ } catch (err) {
923
+ logger.error(
924
+ `[relay] Failed to load session history: ${err.message}`,
925
+ );
926
+ }
927
+ }
928
+
929
+ const pages = conversationState.getPages();
930
+ if (onPagesChanged) {
931
+ onPagesChanged(pages);
932
+ }
933
+ if (onStatusChanged) {
934
+ onStatusChanged();
935
+ }
936
+ return pages;
937
+ }
938
+
939
+ /**
940
+ * Create a new session with a fresh key.
941
+ * @returns {Promise<{sessionKey: string, pages: Array}>}
942
+ */
943
+ async function newSession(opts = {}) {
944
+ const sendResetCommand = opts.sendResetCommand !== false;
945
+ const sessionKey = generateSessionKey();
946
+ invalidateSessionsCache();
947
+ if (onSessionStateReset) {
948
+ onSessionStateReset();
949
+ }
950
+ currentSessionKey = sessionKey;
951
+ pendingSessionListKey = sessionKey;
952
+ pendingInitialConfigSessionKeys.add(sessionKey);
953
+ emitDebug(
954
+ "relay.session",
955
+ "new_session",
956
+ "info",
957
+ { sessionKey },
958
+ () => ({
959
+ sessionKey,
960
+ sendResetCommand,
961
+ }),
962
+ );
963
+ conversationState.clear();
964
+ conversationState.setAgentName(getAgentName() || "Agent");
965
+ const pages = conversationState.getPages();
966
+ if (onPagesChanged) {
967
+ onPagesChanged(pages);
968
+ }
969
+ if (onStatusChanged) {
970
+ onStatusChanged();
971
+ }
972
+ if (sendResetCommand && isUpstreamConnected()) {
973
+ gatewayBridge.sendMessage("/new", sessionKey).catch((err) => {
974
+ logger.error(`[relay] Failed to send /new for new session: ${err.message}`);
975
+ });
976
+ }
977
+ return { sessionKey, pages };
978
+ }
979
+
980
+ function normalizeSessionKeyForCompare(rawKey) {
981
+ if (typeof rawKey !== "string") return "";
982
+ const trimmed = rawKey.trim();
983
+ if (!trimmed) return "";
984
+ return extractShortKey(trimmed).toLowerCase();
985
+ }
986
+
987
+ /**
988
+ * Check if an event's session key matches the current glasses session.
989
+ * Events without a sessionKey default to "main".
990
+ */
991
+ function isCurrentSession(eventSessionKey) {
992
+ const eventKey = normalizeSessionKeyForCompare(eventSessionKey || "main");
993
+ const currentKey = normalizeSessionKeyForCompare(ensureSessionKey());
994
+ if (!eventKey || !currentKey) return false;
995
+ return eventKey === currentKey || eventKey.endsWith(`:${currentKey}`);
996
+ }
997
+
998
+ function hasPendingInitialConfig(sessionKey) {
999
+ return pendingInitialConfigSessionKeys.has(sessionKey);
1000
+ }
1001
+
1002
+ function clearPendingInitialConfig(sessionKey) {
1003
+ pendingInitialConfigSessionKeys.delete(sessionKey);
1004
+ }
1005
+
1006
+ return {
1007
+ ensureSessionKey,
1008
+ peekSessionKey,
1009
+ createDetachedSessionKey,
1010
+ recordFirstSentUserMessage,
1011
+ invalidateSessionsCache,
1012
+ handleUpstreamStatusChange,
1013
+ getSessionModelConfig,
1014
+ getCurrentSessionModelConfig,
1015
+ setSessionModelConfig,
1016
+ setCurrentSessionModelConfig,
1017
+ primeSessionModelConfig,
1018
+ hasPendingInitialConfig,
1019
+ clearPendingInitialConfig,
1020
+ getSessions,
1021
+ getSessionsByExactKeys,
1022
+ switchToSession,
1023
+ newSession,
1024
+ isCurrentSession,
1025
+ };
1026
+ }