ocuclaw 0.1.0 → 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.
- package/README.md +63 -8
- package/dist/config/runtime-config.js +81 -3
- package/dist/domain/activity-status-adapter.js +138 -605
- package/dist/domain/activity-status-arbiter.js +109 -0
- package/dist/domain/activity-status-labels.js +906 -0
- package/dist/domain/code-span-regions.js +103 -0
- package/dist/domain/conversation-state.js +14 -1
- package/dist/domain/debug-store.js +41 -184
- package/dist/domain/glasses-ui-content-summary.js +62 -0
- package/dist/domain/glasses-ui-system-prompt.js +28 -0
- package/dist/domain/message-emoji-allowlist.js +16 -0
- package/dist/domain/message-emoji-filter.js +33 -55
- package/dist/domain/neural-emoji-reactor-system-prompt.js +43 -0
- package/dist/domain/neural-emoji-reactor-tag-config.js +56 -0
- package/dist/domain/neural-pace-modulator-system-prompt.js +32 -0
- package/dist/domain/neural-pace-modulator-tag-config.js +51 -0
- package/dist/domain/tagged-span-parser.js +121 -0
- package/dist/domain/tagged-span-strip.js +38 -0
- package/dist/even-ai/even-ai-endpoint.js +91 -0
- package/dist/even-ai/even-ai-run-waiter.js +14 -0
- package/dist/even-ai/even-ai-settings-store.js +14 -0
- package/dist/gateway/gateway-bridge.js +14 -2
- package/dist/gateway/gateway-timing-ledger.js +457 -0
- package/dist/gateway/openclaw-client.js +462 -38
- package/dist/index.js +28 -1
- package/dist/runtime/downstream-handler.js +909 -68
- package/dist/runtime/downstream-server.js +1004 -512
- package/dist/runtime/ocuclaw-settings-store.js +74 -31
- package/dist/runtime/plugin-update-service.js +216 -0
- package/dist/runtime/protocol-adapter.js +9 -0
- package/dist/runtime/provider-usage-select.js +168 -0
- package/dist/runtime/relay-client-nudge-controller.js +553 -0
- package/dist/runtime/relay-core.js +1357 -210
- package/dist/runtime/relay-health-monitor.js +172 -0
- package/dist/runtime/relay-operation-registry.js +263 -0
- package/dist/runtime/relay-service.js +201 -1
- package/dist/runtime/relay-worker-approval-replay-cache.js +68 -0
- package/dist/runtime/relay-worker-entry.js +32 -0
- package/dist/runtime/relay-worker-health.js +272 -0
- package/dist/runtime/relay-worker-protocol.js +285 -0
- package/dist/runtime/relay-worker-queue.js +202 -0
- package/dist/runtime/relay-worker-supervisor.js +1081 -0
- package/dist/runtime/relay-worker-transport.js +1051 -0
- package/dist/runtime/session-context-service.js +189 -0
- package/dist/runtime/session-service.js +656 -38
- package/dist/runtime/upstream-runtime.js +1167 -60
- package/dist/tools/device-info-tool.js +242 -0
- package/dist/tools/glasses-ui-cron.js +427 -0
- package/dist/tools/glasses-ui-descriptors.js +261 -0
- package/dist/tools/glasses-ui-limits.js +21 -0
- package/dist/tools/glasses-ui-paint-floor.js +99 -0
- package/dist/tools/glasses-ui-recipes.js +746 -0
- package/dist/tools/glasses-ui-surfaces.js +278 -0
- package/dist/tools/glasses-ui-template.js +182 -0
- package/dist/tools/glasses-ui-tool.js +1147 -0
- package/dist/tools/session-title-tool.js +209 -0
- package/dist/version.js +2 -0
- package/openclaw.plugin.json +163 -15
- 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;
|
|
@@ -53,6 +82,10 @@ export function createSessionService(opts = {}) {
|
|
|
53
82
|
typeof opts.onSessionModelConfig === "function"
|
|
54
83
|
? opts.onSessionModelConfig
|
|
55
84
|
: null;
|
|
85
|
+
const isPinnedFirstUserMessageKey =
|
|
86
|
+
typeof opts.isPinnedFirstUserMessageKey === "function"
|
|
87
|
+
? opts.isPinnedFirstUserMessageKey
|
|
88
|
+
: null;
|
|
56
89
|
|
|
57
90
|
/** Current session key. Generated on first use. */
|
|
58
91
|
let currentSessionKey = null;
|
|
@@ -74,7 +107,9 @@ export function createSessionService(opts = {}) {
|
|
|
74
107
|
);
|
|
75
108
|
|
|
76
109
|
/** Maximum number of sessions to fetch. */
|
|
77
|
-
|
|
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;
|
|
78
113
|
/** Whether to persist first real user message cache to disk. */
|
|
79
114
|
const persistFirstUserMessages = opts.persistFirstUserMessages !== false;
|
|
80
115
|
/**
|
|
@@ -92,11 +127,11 @@ export function createSessionService(opts = {}) {
|
|
|
92
127
|
Number.isFinite(opts.sessionCacheTtlMs) && opts.sessionCacheTtlMs > 0
|
|
93
128
|
? Math.floor(opts.sessionCacheTtlMs)
|
|
94
129
|
: 5000;
|
|
95
|
-
/** @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} */
|
|
96
131
|
let cachedSessions = null;
|
|
97
132
|
/** Epoch ms when cachedSessions was last refreshed. */
|
|
98
133
|
let cachedSessionsFetchedAt = 0;
|
|
99
|
-
/** @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} */
|
|
100
135
|
let inFlightSessionsFetch = null;
|
|
101
136
|
|
|
102
137
|
/** Last-known session model config per session key. */
|
|
@@ -109,6 +144,18 @@ export function createSessionService(opts = {}) {
|
|
|
109
144
|
/** @type {Map<string, string>} First real user text observed from downstream send events. */
|
|
110
145
|
const firstSentUserMessageBySession = loadFirstSentUserMessageCache();
|
|
111
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
|
+
|
|
112
159
|
/**
|
|
113
160
|
* Generate a new OcuClaw session key.
|
|
114
161
|
* @returns {string} e.g. "ocuclaw:1739500000000"
|
|
@@ -191,6 +238,17 @@ export function createSessionService(opts = {}) {
|
|
|
191
238
|
return "off";
|
|
192
239
|
}
|
|
193
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
|
+
|
|
194
252
|
function normalizeSessionModelRef(modelProviderRaw, modelRaw) {
|
|
195
253
|
let modelProvider =
|
|
196
254
|
typeof modelProviderRaw === "string" && modelProviderRaw.trim()
|
|
@@ -223,6 +281,8 @@ export function createSessionService(opts = {}) {
|
|
|
223
281
|
thinkingLevel: normalizeThinkingLevel(row && row.thinkingLevel),
|
|
224
282
|
reasoningLevel: normalizeReasoningLevel(row && row.reasoningLevel),
|
|
225
283
|
verboseLevel: normalizeVerboseLevel(row && row.verboseLevel),
|
|
284
|
+
fastMode: !!(row && row.fastMode === true),
|
|
285
|
+
elevatedLevel: normalizeElevatedLevel(row && row.elevatedLevel),
|
|
226
286
|
};
|
|
227
287
|
}
|
|
228
288
|
|
|
@@ -336,6 +396,14 @@ export function createSessionService(opts = {}) {
|
|
|
336
396
|
patch && Object.prototype.hasOwnProperty.call(patch, "verboseLevel")
|
|
337
397
|
? normalizeVerboseLevel(patch.verboseLevel)
|
|
338
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,
|
|
339
407
|
};
|
|
340
408
|
sessionModelConfigCache.set(sessionKey, config);
|
|
341
409
|
return config;
|
|
@@ -353,6 +421,13 @@ export function createSessionService(opts = {}) {
|
|
|
353
421
|
}
|
|
354
422
|
const config = buildSessionModelConfig(sessionKey, row);
|
|
355
423
|
sessionModelConfigCache.set(sessionKey, config);
|
|
424
|
+
if (
|
|
425
|
+
onSessionModelConfig &&
|
|
426
|
+
normalizeSessionKeyForCompare(sessionKey) ===
|
|
427
|
+
normalizeSessionKeyForCompare(ensureSessionKey())
|
|
428
|
+
) {
|
|
429
|
+
onSessionModelConfig(config);
|
|
430
|
+
}
|
|
356
431
|
return config;
|
|
357
432
|
} catch (err) {
|
|
358
433
|
emitDebug(
|
|
@@ -390,7 +465,7 @@ export function createSessionService(opts = {}) {
|
|
|
390
465
|
}
|
|
391
466
|
const request = { key: canonicalKey };
|
|
392
467
|
if (patch && typeof patch.modelRef === "string") {
|
|
393
|
-
request.model = patch.modelRef;
|
|
468
|
+
request.model = patch.modelRef.trim() ? patch.modelRef : null;
|
|
394
469
|
}
|
|
395
470
|
if (patch && Object.prototype.hasOwnProperty.call(patch, "thinkingLevel")) {
|
|
396
471
|
request.thinkingLevel =
|
|
@@ -404,15 +479,24 @@ export function createSessionService(opts = {}) {
|
|
|
404
479
|
if (patch && typeof patch.verboseLevel === "string") {
|
|
405
480
|
request.verboseLevel = patch.verboseLevel;
|
|
406
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
|
+
}
|
|
407
488
|
|
|
408
489
|
try {
|
|
409
490
|
await gatewayBridge.request("sessions.patch", request);
|
|
410
|
-
const config =
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
491
|
+
const config = primeSessionModelConfig(sessionKey, patch);
|
|
492
|
+
if (
|
|
493
|
+
onSessionModelConfig &&
|
|
494
|
+
normalizeSessionKeyForCompare(sessionKey) ===
|
|
495
|
+
normalizeSessionKeyForCompare(ensureSessionKey())
|
|
496
|
+
) {
|
|
414
497
|
onSessionModelConfig(config);
|
|
415
498
|
}
|
|
499
|
+
pendingInitialConfigSessionKeys.delete(sessionKey);
|
|
416
500
|
return { status: "accepted", config };
|
|
417
501
|
} catch (err) {
|
|
418
502
|
emitDebug(
|
|
@@ -438,7 +522,7 @@ export function createSessionService(opts = {}) {
|
|
|
438
522
|
/**
|
|
439
523
|
* Fetch the list of OcuClaw sessions from OpenClaw.
|
|
440
524
|
* Filters to OcuClaw session key prefix, sorted by updatedAt descending.
|
|
441
|
-
* @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}>>}
|
|
442
526
|
*/
|
|
443
527
|
async function getSessions() {
|
|
444
528
|
if (cachedSessions && Date.now() - cachedSessionsFetchedAt < sessionCacheTtlMs) {
|
|
@@ -474,6 +558,7 @@ export function createSessionService(opts = {}) {
|
|
|
474
558
|
updatedAt,
|
|
475
559
|
row.messages,
|
|
476
560
|
);
|
|
561
|
+
const pinMeta = getSessionPin(key);
|
|
477
562
|
return {
|
|
478
563
|
key,
|
|
479
564
|
updatedAt,
|
|
@@ -483,6 +568,9 @@ export function createSessionService(opts = {}) {
|
|
|
483
568
|
? ""
|
|
484
569
|
: extractPreview(row.messages),
|
|
485
570
|
firstUserMessage,
|
|
571
|
+
title: resolveRowTitle(key, row),
|
|
572
|
+
pinned: pinMeta.pinned,
|
|
573
|
+
pinnedAtMs: pinMeta.pinnedAtMs,
|
|
486
574
|
};
|
|
487
575
|
}),
|
|
488
576
|
);
|
|
@@ -505,11 +593,15 @@ export function createSessionService(opts = {}) {
|
|
|
505
593
|
updatedAt,
|
|
506
594
|
[],
|
|
507
595
|
);
|
|
596
|
+
const pinMeta = getSessionPin(pendingSessionListKey);
|
|
508
597
|
sessions.unshift({
|
|
509
598
|
key: pendingSessionListKey,
|
|
510
599
|
updatedAt,
|
|
511
600
|
preview: firstUserMessage ? firstUserMessage.slice(0, 80) : "",
|
|
512
601
|
firstUserMessage,
|
|
602
|
+
title: resolveRowTitle(pendingSessionListKey, null),
|
|
603
|
+
pinned: pinMeta.pinned,
|
|
604
|
+
pinnedAtMs: pinMeta.pinnedAtMs,
|
|
513
605
|
});
|
|
514
606
|
}
|
|
515
607
|
}
|
|
@@ -574,6 +666,7 @@ export function createSessionService(opts = {}) {
|
|
|
574
666
|
updatedAt,
|
|
575
667
|
fallbackMessages,
|
|
576
668
|
);
|
|
669
|
+
const pinMeta = getSessionPin(key);
|
|
577
670
|
sessions.push({
|
|
578
671
|
key,
|
|
579
672
|
updatedAt,
|
|
@@ -583,6 +676,9 @@ export function createSessionService(opts = {}) {
|
|
|
583
676
|
? ""
|
|
584
677
|
: extractPreview(fallbackMessages),
|
|
585
678
|
firstUserMessage,
|
|
679
|
+
title: resolveRowTitle(key, row),
|
|
680
|
+
pinned: pinMeta.pinned,
|
|
681
|
+
pinnedAtMs: pinMeta.pinnedAtMs,
|
|
586
682
|
});
|
|
587
683
|
}
|
|
588
684
|
|
|
@@ -663,6 +759,18 @@ export function createSessionService(opts = {}) {
|
|
|
663
759
|
return "";
|
|
664
760
|
}
|
|
665
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
|
+
|
|
666
774
|
function extractMessageText(content) {
|
|
667
775
|
if (typeof content === "string") {
|
|
668
776
|
return normalizeSessionText(content);
|
|
@@ -696,13 +804,13 @@ export function createSessionService(opts = {}) {
|
|
|
696
804
|
return text.replace(/\s+/g, " ").trim();
|
|
697
805
|
}
|
|
698
806
|
|
|
699
|
-
function
|
|
700
|
-
if (!
|
|
807
|
+
function loadSessionTitleCache() {
|
|
808
|
+
if (!sessionTitleCachePath) return new Map();
|
|
701
809
|
try {
|
|
702
|
-
if (!fs.existsSync(
|
|
810
|
+
if (!fs.existsSync(sessionTitleCachePath)) {
|
|
703
811
|
return new Map();
|
|
704
812
|
}
|
|
705
|
-
const raw = fs.readFileSync(
|
|
813
|
+
const raw = fs.readFileSync(sessionTitleCachePath, "utf8");
|
|
706
814
|
const parsed = JSON.parse(raw);
|
|
707
815
|
const sessions =
|
|
708
816
|
parsed &&
|
|
@@ -713,31 +821,34 @@ export function createSessionService(opts = {}) {
|
|
|
713
821
|
: {};
|
|
714
822
|
const out = new Map();
|
|
715
823
|
for (const [sessionKey, value] of Object.entries(sessions)) {
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
if (oldestKey === undefined) break;
|
|
723
|
-
out.delete(oldestKey);
|
|
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 });
|
|
724
830
|
}
|
|
831
|
+
pruneSessionTitleEntries(out);
|
|
725
832
|
return out;
|
|
726
833
|
} catch {
|
|
727
834
|
return new Map();
|
|
728
835
|
}
|
|
729
836
|
}
|
|
730
837
|
|
|
731
|
-
function
|
|
732
|
-
if (!
|
|
838
|
+
function persistSessionTitleCache() {
|
|
839
|
+
if (!sessionTitleCachePath) return;
|
|
733
840
|
try {
|
|
734
|
-
fs.mkdirSync(path.dirname(
|
|
841
|
+
fs.mkdirSync(path.dirname(sessionTitleCachePath), { recursive: true });
|
|
735
842
|
const sessions = {};
|
|
736
|
-
for (const [sessionKey,
|
|
737
|
-
sessions[sessionKey] =
|
|
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
|
-
|
|
851
|
+
sessionTitleCachePath,
|
|
741
852
|
JSON.stringify(
|
|
742
853
|
{
|
|
743
854
|
version: 1,
|
|
@@ -750,17 +861,388 @@ export function createSessionService(opts = {}) {
|
|
|
750
861
|
);
|
|
751
862
|
} catch (err) {
|
|
752
863
|
logger.error(
|
|
753
|
-
`[relay] Failed to persist session
|
|
864
|
+
`[relay] Failed to persist session title cache: ${err.message}`,
|
|
754
865
|
);
|
|
755
866
|
}
|
|
756
867
|
}
|
|
757
868
|
|
|
758
|
-
function
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
if (
|
|
762
|
-
|
|
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}`,
|
|
1192
|
+
);
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
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;
|
|
763
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
|
+
|
|
1244
|
+
function pruneFirstSentUserMessageCache() {
|
|
1245
|
+
pruneFirstUserMessageEntries(firstSentUserMessageBySession);
|
|
764
1246
|
}
|
|
765
1247
|
|
|
766
1248
|
function recordFirstSentUserMessage(sessionKey, text) {
|
|
@@ -779,6 +1261,91 @@ export function createSessionService(opts = {}) {
|
|
|
779
1261
|
pruneFirstUserMessageCache();
|
|
780
1262
|
}
|
|
781
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
|
+
|
|
782
1349
|
function isSyntheticSessionStarter(text) {
|
|
783
1350
|
if (!text) return false;
|
|
784
1351
|
if (
|
|
@@ -810,10 +1377,41 @@ export function createSessionService(opts = {}) {
|
|
|
810
1377
|
}
|
|
811
1378
|
|
|
812
1379
|
function pruneFirstUserMessageCache() {
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
1380
|
+
pruneFirstUserMessageEntries(firstUserMessageCache);
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
function shouldPinFirstUserMessageKey(sessionKey) {
|
|
1384
|
+
if (!isPinnedFirstUserMessageKey || typeof sessionKey !== "string") {
|
|
1385
|
+
return false;
|
|
1386
|
+
}
|
|
1387
|
+
const normalizedKey = sessionKey.trim();
|
|
1388
|
+
if (!normalizedKey) {
|
|
1389
|
+
return false;
|
|
1390
|
+
}
|
|
1391
|
+
try {
|
|
1392
|
+
return isPinnedFirstUserMessageKey(normalizedKey) === true;
|
|
1393
|
+
} catch (err) {
|
|
1394
|
+
logger.warn(
|
|
1395
|
+
`[relay] first-user cache pin callback failed for ${normalizedKey}: ${err && err.message ? err.message : err}`,
|
|
1396
|
+
);
|
|
1397
|
+
return false;
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
function pruneFirstUserMessageEntries(cache) {
|
|
1402
|
+
while (cache.size > firstUserMessageCacheLimit) {
|
|
1403
|
+
let evicted = false;
|
|
1404
|
+
for (const sessionKey of cache.keys()) {
|
|
1405
|
+
if (shouldPinFirstUserMessageKey(sessionKey)) {
|
|
1406
|
+
continue;
|
|
1407
|
+
}
|
|
1408
|
+
cache.delete(sessionKey);
|
|
1409
|
+
evicted = true;
|
|
1410
|
+
break;
|
|
1411
|
+
}
|
|
1412
|
+
if (!evicted) {
|
|
1413
|
+
break;
|
|
1414
|
+
}
|
|
817
1415
|
}
|
|
818
1416
|
}
|
|
819
1417
|
|
|
@@ -918,7 +1516,14 @@ export function createSessionService(opts = {}) {
|
|
|
918
1516
|
});
|
|
919
1517
|
const messages =
|
|
920
1518
|
result && Array.isArray(result.messages) ? result.messages : [];
|
|
921
|
-
|
|
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());
|
|
922
1527
|
} catch (err) {
|
|
923
1528
|
logger.error(
|
|
924
1529
|
`[relay] Failed to load session history: ${err.message}`,
|
|
@@ -1008,6 +1613,7 @@ export function createSessionService(opts = {}) {
|
|
|
1008
1613
|
peekSessionKey,
|
|
1009
1614
|
createDetachedSessionKey,
|
|
1010
1615
|
recordFirstSentUserMessage,
|
|
1616
|
+
flushFirstSentUserMessageCache,
|
|
1011
1617
|
invalidateSessionsCache,
|
|
1012
1618
|
handleUpstreamStatusChange,
|
|
1013
1619
|
getSessionModelConfig,
|
|
@@ -1018,9 +1624,21 @@ export function createSessionService(opts = {}) {
|
|
|
1018
1624
|
hasPendingInitialConfig,
|
|
1019
1625
|
clearPendingInitialConfig,
|
|
1020
1626
|
getSessions,
|
|
1627
|
+
getSessionTitle,
|
|
1021
1628
|
getSessionsByExactKeys,
|
|
1629
|
+
hasRecordedFirstUserMessage,
|
|
1630
|
+
isNeuralSessionNamesEnabled,
|
|
1631
|
+
isSessionUserLocked,
|
|
1632
|
+
recordNeuralSessionNamesEnabled,
|
|
1633
|
+
setSessionTitle,
|
|
1022
1634
|
switchToSession,
|
|
1023
1635
|
newSession,
|
|
1024
1636
|
isCurrentSession,
|
|
1637
|
+
setSessionPinned,
|
|
1638
|
+
getSessionPin,
|
|
1639
|
+
deleteSessions,
|
|
1640
|
+
switchAndDeleteSessions,
|
|
1641
|
+
broadcastSessionsForKind,
|
|
1642
|
+
searchTranscripts,
|
|
1025
1643
|
};
|
|
1026
1644
|
}
|