ocuclaw 1.2.4 → 1.3.1
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 +21 -6
- package/dist/config/runtime-config.js +84 -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 +56 -182
- 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 +754 -83
- package/dist/runtime/ocuclaw-settings-store.js +74 -31
- package/dist/runtime/plugin-version-service.js +23 -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 +1293 -225
- 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 +281 -0
- package/dist/runtime/relay-worker-queue.js +202 -0
- package/dist/runtime/relay-worker-supervisor.js +1004 -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 +638 -27
- 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 +581 -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 +1111 -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 +14 -5
- package/skills/glasses-ui/SKILL.md +156 -0
- package/dist/runtime/downstream-server.js +0 -1891
|
@@ -1,7 +1,30 @@
|
|
|
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
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Greeting-eliciting content appended to /new and /reset so OpenClaw runs an
|
|
12
|
+
* agent turn (the new-session "welcome"). OpenClaw 2026.6.x ("make bare reset
|
|
13
|
+
* commands fast", gateway commit 2c6a3f6b04) made a BARE /new or /reset return
|
|
14
|
+
* a synchronous ack and run NO agent turn — so a bare reset no longer produces
|
|
15
|
+
* a welcome and leaves the glasses stuck on the "starting new session"
|
|
16
|
+
* placeholder. Sending "/new <prompt>" / "/reset <prompt>" routes through the
|
|
17
|
+
* gateway's normal agent path (content = the prompt) and elicits the welcome.
|
|
18
|
+
* The wording matches the prompt OpenClaw used to inject itself, so the
|
|
19
|
+
* existing synthetic-session-starter filters (conversation-state display hide +
|
|
20
|
+
* isSyntheticSessionStarter title/preview filter) keep it hidden from the
|
|
21
|
+
* transcript and session titles. NOTE: newSession() does NOT call
|
|
22
|
+
* conversationState.addMessage("user", ...), so this content never renders as a
|
|
23
|
+
* user bubble on the glasses.
|
|
24
|
+
*/
|
|
25
|
+
export const NEW_SESSION_GREETING_PROMPT =
|
|
26
|
+
"A new session was started via /new or /reset. Execute your Session Startup sequence now - read the required files before responding to the user. If BOOTSTRAP.md exists in the provided Project Context, read it and follow its instructions first. Then greet the user in your configured persona, if one is provided. Be yourself - use your defined voice, mannerisms, and mood. Keep it to 1-3 sentences and ask what they want to do. If the runtime model differs from default_model in the system prompt, mention the default model. Do not mention internal steps, files, tools, or reasoning.";
|
|
27
|
+
|
|
5
28
|
|
|
6
29
|
function normalizeLogger(logger) {
|
|
7
30
|
if (!logger || typeof logger !== "object") {
|
|
@@ -28,6 +51,30 @@ function resolveSessionFirstUserMessageCachePath(stateDir) {
|
|
|
28
51
|
return path.join(resolvedStateDir, SESSION_FIRST_USER_CACHE_FILE);
|
|
29
52
|
}
|
|
30
53
|
|
|
54
|
+
function resolveSessionTitleCachePath(stateDir) {
|
|
55
|
+
const resolvedStateDir = normalizeStateDir(stateDir);
|
|
56
|
+
if (!resolvedStateDir) return null;
|
|
57
|
+
return path.join(resolvedStateDir, SESSION_TITLE_CACHE_FILE);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function resolveSessionPinCachePath(stateDir) {
|
|
61
|
+
const resolvedStateDir = normalizeStateDir(stateDir);
|
|
62
|
+
if (!resolvedStateDir) return null;
|
|
63
|
+
return path.join(resolvedStateDir, SESSION_PIN_CACHE_FILE);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function sanitizeAssistantContentBlocks(content) {
|
|
67
|
+
if (typeof content === "string") {
|
|
68
|
+
return stripAllTaggedSpans(content);
|
|
69
|
+
}
|
|
70
|
+
if (!Array.isArray(content)) return content;
|
|
71
|
+
return content.map((block) =>
|
|
72
|
+
block && block.type === "text" && typeof block.text === "string"
|
|
73
|
+
? { ...block, text: stripAllTaggedSpans(block.text) }
|
|
74
|
+
: block,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
31
78
|
export function createSessionService(opts = {}) {
|
|
32
79
|
const logger = normalizeLogger(opts.logger);
|
|
33
80
|
const gatewayBridge = opts.gatewayBridge;
|
|
@@ -78,7 +125,9 @@ export function createSessionService(opts = {}) {
|
|
|
78
125
|
);
|
|
79
126
|
|
|
80
127
|
/** Maximum number of sessions to fetch. */
|
|
81
|
-
|
|
128
|
+
// Default raised to 100 so the WebUI Sessions panel can show a full
|
|
129
|
+
// scrollable history without being chopped to 10 most-recent.
|
|
130
|
+
const sessionLimit = opts.sessionLimit || 100;
|
|
82
131
|
/** Whether to persist first real user message cache to disk. */
|
|
83
132
|
const persistFirstUserMessages = opts.persistFirstUserMessages !== false;
|
|
84
133
|
/**
|
|
@@ -96,11 +145,11 @@ export function createSessionService(opts = {}) {
|
|
|
96
145
|
Number.isFinite(opts.sessionCacheTtlMs) && opts.sessionCacheTtlMs > 0
|
|
97
146
|
? Math.floor(opts.sessionCacheTtlMs)
|
|
98
147
|
: 5000;
|
|
99
|
-
/** @type {Array<{key: string, updatedAt: number, preview: string, firstUserMessage: string}>|null} */
|
|
148
|
+
/** @type {Array<{key: string, updatedAt: number, preview: string, firstUserMessage: string, title: string|null, pinned: boolean, pinnedAtMs: number|null}>|null} */
|
|
100
149
|
let cachedSessions = null;
|
|
101
150
|
/** Epoch ms when cachedSessions was last refreshed. */
|
|
102
151
|
let cachedSessionsFetchedAt = 0;
|
|
103
|
-
/** @type {Promise<Array<{key: string, updatedAt: number, preview: string, firstUserMessage: string}>>|null} */
|
|
152
|
+
/** @type {Promise<Array<{key: string, updatedAt: number, preview: string, firstUserMessage: string, title: string|null, pinned: boolean, pinnedAtMs: number|null}>>|null} */
|
|
104
153
|
let inFlightSessionsFetch = null;
|
|
105
154
|
|
|
106
155
|
/** Last-known session model config per session key. */
|
|
@@ -113,6 +162,18 @@ export function createSessionService(opts = {}) {
|
|
|
113
162
|
/** @type {Map<string, string>} First real user text observed from downstream send events. */
|
|
114
163
|
const firstSentUserMessageBySession = loadFirstSentUserMessageCache();
|
|
115
164
|
|
|
165
|
+
/** Path for session title cache file. */
|
|
166
|
+
const sessionTitleCachePath = resolveSessionTitleCachePath(opts.stateDir);
|
|
167
|
+
/** @type {Map<string, {title: string, setAtMs: number, userSet: boolean}>} Per-session agent- or user-set title. */
|
|
168
|
+
const sessionTitleByKey = loadSessionTitleCache();
|
|
169
|
+
/** @type {Map<string, boolean>} Per-session Neural Session Names toggle state. */
|
|
170
|
+
const neuralSessionNamesEnabledByKey = new Map();
|
|
171
|
+
|
|
172
|
+
/** Path for session pin metadata cache file. */
|
|
173
|
+
const sessionPinCachePath = resolveSessionPinCachePath(opts.stateDir);
|
|
174
|
+
/** @type {Map<string, {pinned: boolean, pinnedAtMs: number}>} Per-session pin metadata. */
|
|
175
|
+
const sessionPinByKey = loadSessionPinCache();
|
|
176
|
+
|
|
116
177
|
/**
|
|
117
178
|
* Generate a new OcuClaw session key.
|
|
118
179
|
* @returns {string} e.g. "ocuclaw:1739500000000"
|
|
@@ -195,6 +256,17 @@ export function createSessionService(opts = {}) {
|
|
|
195
256
|
return "off";
|
|
196
257
|
}
|
|
197
258
|
|
|
259
|
+
// Preserves all four gateway values — "ask" is the alias of "on" and must
|
|
260
|
+
// not be collapsed, or snapshot read-back would lie about /elevated ask.
|
|
261
|
+
function normalizeElevatedLevel(raw) {
|
|
262
|
+
if (typeof raw !== "string") return "off";
|
|
263
|
+
const normalized = raw.trim().toLowerCase();
|
|
264
|
+
if (normalized === "on" || normalized === "ask" || normalized === "full") {
|
|
265
|
+
return normalized;
|
|
266
|
+
}
|
|
267
|
+
return "off";
|
|
268
|
+
}
|
|
269
|
+
|
|
198
270
|
function normalizeSessionModelRef(modelProviderRaw, modelRaw) {
|
|
199
271
|
let modelProvider =
|
|
200
272
|
typeof modelProviderRaw === "string" && modelProviderRaw.trim()
|
|
@@ -227,6 +299,8 @@ export function createSessionService(opts = {}) {
|
|
|
227
299
|
thinkingLevel: normalizeThinkingLevel(row && row.thinkingLevel),
|
|
228
300
|
reasoningLevel: normalizeReasoningLevel(row && row.reasoningLevel),
|
|
229
301
|
verboseLevel: normalizeVerboseLevel(row && row.verboseLevel),
|
|
302
|
+
fastMode: !!(row && row.fastMode === true),
|
|
303
|
+
elevatedLevel: normalizeElevatedLevel(row && row.elevatedLevel),
|
|
230
304
|
};
|
|
231
305
|
}
|
|
232
306
|
|
|
@@ -340,6 +414,14 @@ export function createSessionService(opts = {}) {
|
|
|
340
414
|
patch && Object.prototype.hasOwnProperty.call(patch, "verboseLevel")
|
|
341
415
|
? normalizeVerboseLevel(patch.verboseLevel)
|
|
342
416
|
: base.verboseLevel,
|
|
417
|
+
fastMode:
|
|
418
|
+
patch && Object.prototype.hasOwnProperty.call(patch, "fastMode")
|
|
419
|
+
? patch.fastMode === true
|
|
420
|
+
: base.fastMode,
|
|
421
|
+
elevatedLevel:
|
|
422
|
+
patch && Object.prototype.hasOwnProperty.call(patch, "elevatedLevel")
|
|
423
|
+
? normalizeElevatedLevel(patch.elevatedLevel)
|
|
424
|
+
: base.elevatedLevel,
|
|
343
425
|
};
|
|
344
426
|
sessionModelConfigCache.set(sessionKey, config);
|
|
345
427
|
return config;
|
|
@@ -357,6 +439,13 @@ export function createSessionService(opts = {}) {
|
|
|
357
439
|
}
|
|
358
440
|
const config = buildSessionModelConfig(sessionKey, row);
|
|
359
441
|
sessionModelConfigCache.set(sessionKey, config);
|
|
442
|
+
if (
|
|
443
|
+
onSessionModelConfig &&
|
|
444
|
+
normalizeSessionKeyForCompare(sessionKey) ===
|
|
445
|
+
normalizeSessionKeyForCompare(ensureSessionKey())
|
|
446
|
+
) {
|
|
447
|
+
onSessionModelConfig(config);
|
|
448
|
+
}
|
|
360
449
|
return config;
|
|
361
450
|
} catch (err) {
|
|
362
451
|
emitDebug(
|
|
@@ -408,15 +497,24 @@ export function createSessionService(opts = {}) {
|
|
|
408
497
|
if (patch && typeof patch.verboseLevel === "string") {
|
|
409
498
|
request.verboseLevel = patch.verboseLevel;
|
|
410
499
|
}
|
|
500
|
+
if (patch && typeof patch.fastMode === "boolean") {
|
|
501
|
+
request.fastMode = patch.fastMode;
|
|
502
|
+
}
|
|
503
|
+
if (patch && typeof patch.elevatedLevel === "string") {
|
|
504
|
+
request.elevatedLevel = patch.elevatedLevel;
|
|
505
|
+
}
|
|
411
506
|
|
|
412
507
|
try {
|
|
413
508
|
await gatewayBridge.request("sessions.patch", request);
|
|
414
|
-
const config =
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
509
|
+
const config = primeSessionModelConfig(sessionKey, patch);
|
|
510
|
+
if (
|
|
511
|
+
onSessionModelConfig &&
|
|
512
|
+
normalizeSessionKeyForCompare(sessionKey) ===
|
|
513
|
+
normalizeSessionKeyForCompare(ensureSessionKey())
|
|
514
|
+
) {
|
|
418
515
|
onSessionModelConfig(config);
|
|
419
516
|
}
|
|
517
|
+
pendingInitialConfigSessionKeys.delete(sessionKey);
|
|
420
518
|
return { status: "accepted", config };
|
|
421
519
|
} catch (err) {
|
|
422
520
|
emitDebug(
|
|
@@ -442,7 +540,7 @@ export function createSessionService(opts = {}) {
|
|
|
442
540
|
/**
|
|
443
541
|
* Fetch the list of OcuClaw sessions from OpenClaw.
|
|
444
542
|
* Filters to OcuClaw session key prefix, sorted by updatedAt descending.
|
|
445
|
-
* @returns {Promise<Array<{key: string, updatedAt: number, preview: string, firstUserMessage: string}>>}
|
|
543
|
+
* @returns {Promise<Array<{key: string, updatedAt: number, preview: string, firstUserMessage: string, title: string|null, pinned: boolean, pinnedAtMs: number|null}>>}
|
|
446
544
|
*/
|
|
447
545
|
async function getSessions() {
|
|
448
546
|
if (cachedSessions && Date.now() - cachedSessionsFetchedAt < sessionCacheTtlMs) {
|
|
@@ -478,6 +576,7 @@ export function createSessionService(opts = {}) {
|
|
|
478
576
|
updatedAt,
|
|
479
577
|
row.messages,
|
|
480
578
|
);
|
|
579
|
+
const pinMeta = getSessionPin(key);
|
|
481
580
|
return {
|
|
482
581
|
key,
|
|
483
582
|
updatedAt,
|
|
@@ -487,6 +586,9 @@ export function createSessionService(opts = {}) {
|
|
|
487
586
|
? ""
|
|
488
587
|
: extractPreview(row.messages),
|
|
489
588
|
firstUserMessage,
|
|
589
|
+
title: resolveRowTitle(key, row),
|
|
590
|
+
pinned: pinMeta.pinned,
|
|
591
|
+
pinnedAtMs: pinMeta.pinnedAtMs,
|
|
490
592
|
};
|
|
491
593
|
}),
|
|
492
594
|
);
|
|
@@ -509,11 +611,15 @@ export function createSessionService(opts = {}) {
|
|
|
509
611
|
updatedAt,
|
|
510
612
|
[],
|
|
511
613
|
);
|
|
614
|
+
const pinMeta = getSessionPin(pendingSessionListKey);
|
|
512
615
|
sessions.unshift({
|
|
513
616
|
key: pendingSessionListKey,
|
|
514
617
|
updatedAt,
|
|
515
618
|
preview: firstUserMessage ? firstUserMessage.slice(0, 80) : "",
|
|
516
619
|
firstUserMessage,
|
|
620
|
+
title: resolveRowTitle(pendingSessionListKey, null),
|
|
621
|
+
pinned: pinMeta.pinned,
|
|
622
|
+
pinnedAtMs: pinMeta.pinnedAtMs,
|
|
517
623
|
});
|
|
518
624
|
}
|
|
519
625
|
}
|
|
@@ -578,6 +684,7 @@ export function createSessionService(opts = {}) {
|
|
|
578
684
|
updatedAt,
|
|
579
685
|
fallbackMessages,
|
|
580
686
|
);
|
|
687
|
+
const pinMeta = getSessionPin(key);
|
|
581
688
|
sessions.push({
|
|
582
689
|
key,
|
|
583
690
|
updatedAt,
|
|
@@ -587,6 +694,9 @@ export function createSessionService(opts = {}) {
|
|
|
587
694
|
? ""
|
|
588
695
|
: extractPreview(fallbackMessages),
|
|
589
696
|
firstUserMessage,
|
|
697
|
+
title: resolveRowTitle(key, row),
|
|
698
|
+
pinned: pinMeta.pinned,
|
|
699
|
+
pinnedAtMs: pinMeta.pinnedAtMs,
|
|
590
700
|
});
|
|
591
701
|
}
|
|
592
702
|
|
|
@@ -667,6 +777,18 @@ export function createSessionService(opts = {}) {
|
|
|
667
777
|
return "";
|
|
668
778
|
}
|
|
669
779
|
|
|
780
|
+
function resolveRowTitle(sessionKey, row) {
|
|
781
|
+
const cached = getSessionTitle(sessionKey);
|
|
782
|
+
if (cached !== null) return cached;
|
|
783
|
+
// row.displayName: upstream session-row label per
|
|
784
|
+
// openclaw/docs/reference/session-management-compaction.md §169.
|
|
785
|
+
if (row && typeof row.displayName === "string") {
|
|
786
|
+
const trimmed = row.displayName.trim();
|
|
787
|
+
if (trimmed) return trimmed;
|
|
788
|
+
}
|
|
789
|
+
return null;
|
|
790
|
+
}
|
|
791
|
+
|
|
670
792
|
function extractMessageText(content) {
|
|
671
793
|
if (typeof content === "string") {
|
|
672
794
|
return normalizeSessionText(content);
|
|
@@ -700,13 +822,13 @@ export function createSessionService(opts = {}) {
|
|
|
700
822
|
return text.replace(/\s+/g, " ").trim();
|
|
701
823
|
}
|
|
702
824
|
|
|
703
|
-
function
|
|
704
|
-
if (!
|
|
825
|
+
function loadSessionTitleCache() {
|
|
826
|
+
if (!sessionTitleCachePath) return new Map();
|
|
705
827
|
try {
|
|
706
|
-
if (!fs.existsSync(
|
|
828
|
+
if (!fs.existsSync(sessionTitleCachePath)) {
|
|
707
829
|
return new Map();
|
|
708
830
|
}
|
|
709
|
-
const raw = fs.readFileSync(
|
|
831
|
+
const raw = fs.readFileSync(sessionTitleCachePath, "utf8");
|
|
710
832
|
const parsed = JSON.parse(raw);
|
|
711
833
|
const sessions =
|
|
712
834
|
parsed &&
|
|
@@ -717,27 +839,34 @@ export function createSessionService(opts = {}) {
|
|
|
717
839
|
: {};
|
|
718
840
|
const out = new Map();
|
|
719
841
|
for (const [sessionKey, value] of Object.entries(sessions)) {
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
842
|
+
if (!sessionKey || !value || typeof value !== "object") continue;
|
|
843
|
+
const title = typeof value.title === "string" ? value.title : "";
|
|
844
|
+
if (!title) continue;
|
|
845
|
+
const setAtMs = Number.isFinite(value.setAtMs) ? Math.floor(value.setAtMs) : 0;
|
|
846
|
+
const userSet = value.userSet === true;
|
|
847
|
+
out.set(sessionKey, { title, setAtMs, userSet });
|
|
723
848
|
}
|
|
724
|
-
|
|
849
|
+
pruneSessionTitleEntries(out);
|
|
725
850
|
return out;
|
|
726
851
|
} catch {
|
|
727
852
|
return new Map();
|
|
728
853
|
}
|
|
729
854
|
}
|
|
730
855
|
|
|
731
|
-
function
|
|
732
|
-
if (!
|
|
856
|
+
function persistSessionTitleCache() {
|
|
857
|
+
if (!sessionTitleCachePath) return;
|
|
733
858
|
try {
|
|
734
|
-
fs.mkdirSync(path.dirname(
|
|
859
|
+
fs.mkdirSync(path.dirname(sessionTitleCachePath), { recursive: true });
|
|
735
860
|
const sessions = {};
|
|
736
|
-
for (const [sessionKey,
|
|
737
|
-
sessions[sessionKey] =
|
|
861
|
+
for (const [sessionKey, value] of sessionTitleByKey) {
|
|
862
|
+
sessions[sessionKey] = {
|
|
863
|
+
title: value.title,
|
|
864
|
+
setAtMs: value.setAtMs,
|
|
865
|
+
userSet: value.userSet === true,
|
|
866
|
+
};
|
|
738
867
|
}
|
|
739
868
|
fs.writeFileSync(
|
|
740
|
-
|
|
869
|
+
sessionTitleCachePath,
|
|
741
870
|
JSON.stringify(
|
|
742
871
|
{
|
|
743
872
|
version: 1,
|
|
@@ -750,11 +879,386 @@ export function createSessionService(opts = {}) {
|
|
|
750
879
|
);
|
|
751
880
|
} catch (err) {
|
|
752
881
|
logger.error(
|
|
753
|
-
`[relay] Failed to persist session
|
|
882
|
+
`[relay] Failed to persist session title cache: ${err.message}`,
|
|
883
|
+
);
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
function loadSessionPinCache() {
|
|
888
|
+
if (!sessionPinCachePath) return new Map();
|
|
889
|
+
try {
|
|
890
|
+
if (!fs.existsSync(sessionPinCachePath)) return new Map();
|
|
891
|
+
const raw = fs.readFileSync(sessionPinCachePath, "utf8");
|
|
892
|
+
const parsed = JSON.parse(raw);
|
|
893
|
+
const out = new Map();
|
|
894
|
+
for (const [key, value] of Object.entries(parsed ?? {})) {
|
|
895
|
+
if (
|
|
896
|
+
value &&
|
|
897
|
+
typeof value === "object" &&
|
|
898
|
+
typeof value.pinnedAtMs === "number"
|
|
899
|
+
) {
|
|
900
|
+
out.set(key, { pinned: !!value.pinned, pinnedAtMs: value.pinnedAtMs });
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
return out;
|
|
904
|
+
} catch {
|
|
905
|
+
return new Map();
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
function persistSessionPinCache() {
|
|
910
|
+
if (!sessionPinCachePath) return;
|
|
911
|
+
try {
|
|
912
|
+
fs.mkdirSync(path.dirname(sessionPinCachePath), { recursive: true });
|
|
913
|
+
const obj = {};
|
|
914
|
+
for (const [key, value] of sessionPinByKey.entries()) {
|
|
915
|
+
obj[key] = value;
|
|
916
|
+
}
|
|
917
|
+
fs.writeFileSync(sessionPinCachePath, JSON.stringify(obj), "utf8");
|
|
918
|
+
} catch (err) {
|
|
919
|
+
logger.error(`[relay] Failed to persist session pin cache: ${err.message}`);
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
function countPinnedForKind(kind) {
|
|
924
|
+
let n = 0;
|
|
925
|
+
for (const [key, val] of sessionPinByKey.entries()) {
|
|
926
|
+
if (!val.pinned) continue;
|
|
927
|
+
if (kind === "ocuclaw" && key.startsWith("ocuclaw:")) n++;
|
|
928
|
+
else if (kind === "evenai" && key.startsWith("evenai:")) n++;
|
|
929
|
+
}
|
|
930
|
+
return n;
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
function getSessionPin(sessionKey) {
|
|
934
|
+
const v = sessionPinByKey.get(sessionKey);
|
|
935
|
+
return v ?? { pinned: false, pinnedAtMs: null };
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
/**
|
|
939
|
+
* Set or clear the pin on a session.
|
|
940
|
+
* @param {string} kind "ocuclaw" | "evenai"
|
|
941
|
+
* @param {string} sessionKey
|
|
942
|
+
* @param {boolean} pinned
|
|
943
|
+
* @returns {{ ok: true } | { ok: false, reason: "cap" | "invalid" }}
|
|
944
|
+
*/
|
|
945
|
+
function setSessionPinned(kind, sessionKey, pinned) {
|
|
946
|
+
if (!sessionKey || (kind !== "ocuclaw" && kind !== "evenai")) {
|
|
947
|
+
return { ok: false, reason: "invalid" };
|
|
948
|
+
}
|
|
949
|
+
if (pinned) {
|
|
950
|
+
const countForKind = countPinnedForKind(kind);
|
|
951
|
+
const already = sessionPinByKey.get(sessionKey)?.pinned === true;
|
|
952
|
+
if (!already && countForKind >= PIN_CAP_PER_KIND) {
|
|
953
|
+
return { ok: false, reason: "cap" };
|
|
954
|
+
}
|
|
955
|
+
sessionPinByKey.set(sessionKey, { pinned: true, pinnedAtMs: Date.now() });
|
|
956
|
+
} else {
|
|
957
|
+
sessionPinByKey.delete(sessionKey);
|
|
958
|
+
}
|
|
959
|
+
persistSessionPinCache();
|
|
960
|
+
invalidateSessionsCache();
|
|
961
|
+
return { ok: true };
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
/**
|
|
965
|
+
* Delete one or more sessions, sequentially.
|
|
966
|
+
* Partial failures do not abort the batch.
|
|
967
|
+
* @param {"ocuclaw"|"evenai"} kind
|
|
968
|
+
* @param {string[]} sessionKeys
|
|
969
|
+
* @returns {Promise<{ deleted: string[], failed: Array<{ key: string, reason: string }> }>}
|
|
970
|
+
*/
|
|
971
|
+
async function deleteSessions(kind, sessionKeys) {
|
|
972
|
+
const deleted = [];
|
|
973
|
+
const failed = [];
|
|
974
|
+
for (const key of sessionKeys) {
|
|
975
|
+
try {
|
|
976
|
+
await deleteSingleSession(kind, key);
|
|
977
|
+
sessionPinByKey.delete(key);
|
|
978
|
+
sessionTitleByKey.delete(key);
|
|
979
|
+
firstSentUserMessageBySession.delete(key);
|
|
980
|
+
deleted.push(key);
|
|
981
|
+
} catch (err) {
|
|
982
|
+
failed.push({ key, reason: err?.message ?? "unknown" });
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
persistSessionPinCache();
|
|
986
|
+
persistSessionTitleCache();
|
|
987
|
+
invalidateSessionsCache();
|
|
988
|
+
return { deleted, failed };
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
/**
|
|
992
|
+
* Deep transcript search across all OcuClaw sessions returned by getSessions().
|
|
993
|
+
* Returns at most `maxSnippets` snippets matching the query (case-insensitive),
|
|
994
|
+
* each with `before`/`match`/`after` slices so the UI can highlight the hit.
|
|
995
|
+
* @param {string} kind "ocuclaw" | "evenai"
|
|
996
|
+
* @param {string} query
|
|
997
|
+
* @returns {Promise<{snippets: Array<{sessionKey: string, role: string, updatedAtMs: number, before: string, match: string, after: string}>, truncated: boolean}>}
|
|
998
|
+
*/
|
|
999
|
+
async function searchTranscripts(kind, query) {
|
|
1000
|
+
const needle = (typeof query === "string" ? query.trim() : "").toLowerCase();
|
|
1001
|
+
if (!needle) return { snippets: [], truncated: false };
|
|
1002
|
+
const maxSnippets = 50;
|
|
1003
|
+
const contextChars = 60;
|
|
1004
|
+
const sessions = await getSessions().catch(() => []);
|
|
1005
|
+
const snippets = [];
|
|
1006
|
+
let truncated = false;
|
|
1007
|
+
for (const session of sessions) {
|
|
1008
|
+
if (snippets.length >= maxSnippets) {
|
|
1009
|
+
truncated = true;
|
|
1010
|
+
break;
|
|
1011
|
+
}
|
|
1012
|
+
if (kind === "ocuclaw" && !session.key.startsWith("ocuclaw:")) continue;
|
|
1013
|
+
if (kind === "evenai" && !session.key.startsWith("evenai:")) continue;
|
|
1014
|
+
let history;
|
|
1015
|
+
try {
|
|
1016
|
+
history = await gatewayBridge.request("chat.history", {
|
|
1017
|
+
sessionKey: session.key,
|
|
1018
|
+
limit: 200,
|
|
1019
|
+
});
|
|
1020
|
+
} catch {
|
|
1021
|
+
continue;
|
|
1022
|
+
}
|
|
1023
|
+
const messages = (history && Array.isArray(history.messages)) ? history.messages : [];
|
|
1024
|
+
for (const msg of messages) {
|
|
1025
|
+
if (snippets.length >= maxSnippets) {
|
|
1026
|
+
truncated = true;
|
|
1027
|
+
break;
|
|
1028
|
+
}
|
|
1029
|
+
const text = extractRawMessageText(msg);
|
|
1030
|
+
if (!text) continue;
|
|
1031
|
+
const lower = text.toLowerCase();
|
|
1032
|
+
const idx = lower.indexOf(needle);
|
|
1033
|
+
if (idx < 0) continue;
|
|
1034
|
+
const matchEnd = idx + needle.length;
|
|
1035
|
+
const before = text.slice(Math.max(0, idx - contextChars), idx);
|
|
1036
|
+
const match = text.slice(idx, matchEnd);
|
|
1037
|
+
const after = text.slice(matchEnd, Math.min(text.length, matchEnd + contextChars));
|
|
1038
|
+
snippets.push({
|
|
1039
|
+
sessionKey: session.key,
|
|
1040
|
+
role: typeof msg.role === "string" ? msg.role : "",
|
|
1041
|
+
updatedAtMs: session.updatedAt || 0,
|
|
1042
|
+
before,
|
|
1043
|
+
match,
|
|
1044
|
+
after,
|
|
1045
|
+
});
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
return { snippets, truncated };
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
function extractRawMessageText(msg) {
|
|
1052
|
+
if (!msg) return "";
|
|
1053
|
+
if (typeof msg.content === "string") return msg.content;
|
|
1054
|
+
if (Array.isArray(msg.content)) {
|
|
1055
|
+
let acc = "";
|
|
1056
|
+
for (const block of msg.content) {
|
|
1057
|
+
if (block && block.type === "text" && typeof block.text === "string") {
|
|
1058
|
+
acc += (acc ? "\n" : "") + block.text;
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
return acc;
|
|
1062
|
+
}
|
|
1063
|
+
return "";
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
async function deleteSingleSession(kind, key) {
|
|
1067
|
+
// OpenClaw gateway exposes `sessions.delete({key, deleteTranscript, emitLifecycleHooks})`.
|
|
1068
|
+
// Resolve to a canonical key first so prefixes like "agent:main:" are applied
|
|
1069
|
+
// when the gateway expects them.
|
|
1070
|
+
const canonicalKey = await resolveSessionCanonicalKey(key);
|
|
1071
|
+
await gatewayBridge.request("sessions.delete", {
|
|
1072
|
+
key: canonicalKey,
|
|
1073
|
+
deleteTranscript: true,
|
|
1074
|
+
emitLifecycleHooks: false,
|
|
1075
|
+
});
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
/**
|
|
1079
|
+
* Switch first if the active session is in the batch; then delete.
|
|
1080
|
+
* Used when the UI sets `switchBeforeDelete=true`.
|
|
1081
|
+
* @param {"ocuclaw"|"evenai"} kind
|
|
1082
|
+
* @param {string[]} sessionKeys
|
|
1083
|
+
*/
|
|
1084
|
+
async function switchAndDeleteSessions(kind, sessionKeys) {
|
|
1085
|
+
if (
|
|
1086
|
+
kind === "ocuclaw" &&
|
|
1087
|
+
currentSessionKey &&
|
|
1088
|
+
sessionKeys.includes(currentSessionKey)
|
|
1089
|
+
) {
|
|
1090
|
+
await newSession();
|
|
1091
|
+
}
|
|
1092
|
+
return deleteSessions(kind, sessionKeys);
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
/**
|
|
1096
|
+
* Re-fetch + broadcast the sessions snapshot for a given kind.
|
|
1097
|
+
* Used by handlers after pin/delete writes.
|
|
1098
|
+
* @param {"ocuclaw"|"evenai"} kind
|
|
1099
|
+
*/
|
|
1100
|
+
async function broadcastSessionsForKind(kind) {
|
|
1101
|
+
invalidateSessionsCache();
|
|
1102
|
+
if (kind === "ocuclaw" && typeof opts.broadcastSessions === "function") {
|
|
1103
|
+
try {
|
|
1104
|
+
await opts.broadcastSessions();
|
|
1105
|
+
} catch (err) {
|
|
1106
|
+
logger.error(
|
|
1107
|
+
`[relay] broadcastSessions failed: ${err?.message ?? err}`,
|
|
1108
|
+
);
|
|
1109
|
+
}
|
|
1110
|
+
} else if (
|
|
1111
|
+
kind === "evenai" &&
|
|
1112
|
+
typeof opts.broadcastEvenAiSessions === "function"
|
|
1113
|
+
) {
|
|
1114
|
+
try {
|
|
1115
|
+
await opts.broadcastEvenAiSessions();
|
|
1116
|
+
} catch (err) {
|
|
1117
|
+
logger.error(
|
|
1118
|
+
`[relay] broadcastEvenAiSessions failed: ${err?.message ?? err}`,
|
|
1119
|
+
);
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
function pruneSessionTitleEntries(cache) {
|
|
1125
|
+
while (cache.size > firstUserMessageCacheLimit) {
|
|
1126
|
+
let evicted = false;
|
|
1127
|
+
for (const sessionKey of cache.keys()) {
|
|
1128
|
+
if (shouldPinFirstUserMessageKey(sessionKey)) {
|
|
1129
|
+
continue;
|
|
1130
|
+
}
|
|
1131
|
+
cache.delete(sessionKey);
|
|
1132
|
+
evicted = true;
|
|
1133
|
+
break;
|
|
1134
|
+
}
|
|
1135
|
+
if (!evicted) {
|
|
1136
|
+
break;
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
function loadFirstSentUserMessageCache() {
|
|
1142
|
+
if (!persistFirstUserMessages || !firstUserMessageCachePath) return new Map();
|
|
1143
|
+
try {
|
|
1144
|
+
if (!fs.existsSync(firstUserMessageCachePath)) {
|
|
1145
|
+
return new Map();
|
|
1146
|
+
}
|
|
1147
|
+
const raw = fs.readFileSync(firstUserMessageCachePath, "utf8");
|
|
1148
|
+
const parsed = JSON.parse(raw);
|
|
1149
|
+
const sessions =
|
|
1150
|
+
parsed &&
|
|
1151
|
+
parsed.version === 1 &&
|
|
1152
|
+
parsed.sessions &&
|
|
1153
|
+
typeof parsed.sessions === "object"
|
|
1154
|
+
? parsed.sessions
|
|
1155
|
+
: {};
|
|
1156
|
+
const out = new Map();
|
|
1157
|
+
for (const [sessionKey, value] of Object.entries(sessions)) {
|
|
1158
|
+
const normalized = normalizeSessionText(value);
|
|
1159
|
+
if (!sessionKey || !normalized) continue;
|
|
1160
|
+
out.set(sessionKey, normalized);
|
|
1161
|
+
}
|
|
1162
|
+
pruneFirstUserMessageEntries(out);
|
|
1163
|
+
return out;
|
|
1164
|
+
} catch {
|
|
1165
|
+
return new Map();
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
// Coalesced async persistence for the first-user-message cache. Mirrors the
|
|
1170
|
+
// ocuclaw-settings-store writeInFlight/pendingWrite shape (commit 6285bc24)
|
|
1171
|
+
// so the SYNC fs.mkdirSync + fs.writeFileSync no longer block the hot send
|
|
1172
|
+
// path on every first user message per session.
|
|
1173
|
+
//
|
|
1174
|
+
// The write serializes the WHOLE in-memory map, so it is a full-snapshot
|
|
1175
|
+
// replace (not a disk read-modify-write). Concurrent fire-and-forget writes
|
|
1176
|
+
// could therefore lose updates (an older snapshot landing after a newer one).
|
|
1177
|
+
// We coalesce: at most one write in flight; any schedule while a write is in
|
|
1178
|
+
// flight marks the cache dirty, and the in-flight write re-runs once it
|
|
1179
|
+
// settles — so the final on-disk file always reflects the latest map.
|
|
1180
|
+
let firstUserCacheWriteInFlight = false;
|
|
1181
|
+
let firstUserCacheDirty = false;
|
|
1182
|
+
let firstUserCacheFlushPromise = null;
|
|
1183
|
+
let firstUserCacheFlushResolve = null;
|
|
1184
|
+
|
|
1185
|
+
async function writeFirstSentUserMessageCacheToDisk() {
|
|
1186
|
+
const sessions = {};
|
|
1187
|
+
for (const [sessionKey, text] of firstSentUserMessageBySession) {
|
|
1188
|
+
sessions[sessionKey] = text;
|
|
1189
|
+
}
|
|
1190
|
+
const payload =
|
|
1191
|
+
JSON.stringify(
|
|
1192
|
+
{
|
|
1193
|
+
version: 1,
|
|
1194
|
+
updatedAtMs: Date.now(),
|
|
1195
|
+
sessions,
|
|
1196
|
+
},
|
|
1197
|
+
null,
|
|
1198
|
+
2,
|
|
1199
|
+
) + "\n";
|
|
1200
|
+
const tmpPath = `${firstUserMessageCachePath}.tmp`;
|
|
1201
|
+
try {
|
|
1202
|
+
await fs.promises.mkdir(path.dirname(firstUserMessageCachePath), {
|
|
1203
|
+
recursive: true,
|
|
1204
|
+
});
|
|
1205
|
+
await fs.promises.writeFile(tmpPath, payload);
|
|
1206
|
+
await fs.promises.rename(tmpPath, firstUserMessageCachePath);
|
|
1207
|
+
} catch (err) {
|
|
1208
|
+
logger.error(
|
|
1209
|
+
`[relay] Failed to persist session first-user cache: ${err && err.message ? err.message : err}`,
|
|
754
1210
|
);
|
|
755
1211
|
}
|
|
756
1212
|
}
|
|
757
1213
|
|
|
1214
|
+
function runFirstSentUserMessageCacheWrite() {
|
|
1215
|
+
if (firstUserCacheWriteInFlight) {
|
|
1216
|
+
return;
|
|
1217
|
+
}
|
|
1218
|
+
if (!firstUserCacheDirty) {
|
|
1219
|
+
// Nothing more to write — settle any awaiters and clear the flush gate.
|
|
1220
|
+
if (firstUserCacheFlushResolve) {
|
|
1221
|
+
const resolve = firstUserCacheFlushResolve;
|
|
1222
|
+
firstUserCacheFlushResolve = null;
|
|
1223
|
+
firstUserCacheFlushPromise = null;
|
|
1224
|
+
resolve();
|
|
1225
|
+
}
|
|
1226
|
+
return;
|
|
1227
|
+
}
|
|
1228
|
+
firstUserCacheDirty = false;
|
|
1229
|
+
firstUserCacheWriteInFlight = true;
|
|
1230
|
+
writeFirstSentUserMessageCacheToDisk().finally(() => {
|
|
1231
|
+
firstUserCacheWriteInFlight = false;
|
|
1232
|
+
// Re-run drains any dirty mark accumulated during this write, then
|
|
1233
|
+
// settles the flush gate once the map and disk agree.
|
|
1234
|
+
runFirstSentUserMessageCacheWrite();
|
|
1235
|
+
});
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
function persistFirstSentUserMessageCache() {
|
|
1239
|
+
if (!persistFirstUserMessages || !firstUserMessageCachePath) return;
|
|
1240
|
+
firstUserCacheDirty = true;
|
|
1241
|
+
runFirstSentUserMessageCacheWrite();
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
// Awaitable flush: resolves once no write is in flight AND no dirty mark is
|
|
1245
|
+
// pending (the on-disk file reflects the latest in-memory map). Used by tests
|
|
1246
|
+
// after rapid sends and available for graceful shutdown.
|
|
1247
|
+
function flushFirstSentUserMessageCache() {
|
|
1248
|
+
if (!persistFirstUserMessages || !firstUserMessageCachePath) {
|
|
1249
|
+
return Promise.resolve();
|
|
1250
|
+
}
|
|
1251
|
+
if (!firstUserCacheWriteInFlight && !firstUserCacheDirty) {
|
|
1252
|
+
return Promise.resolve();
|
|
1253
|
+
}
|
|
1254
|
+
if (!firstUserCacheFlushPromise) {
|
|
1255
|
+
firstUserCacheFlushPromise = new Promise((resolve) => {
|
|
1256
|
+
firstUserCacheFlushResolve = resolve;
|
|
1257
|
+
});
|
|
1258
|
+
}
|
|
1259
|
+
return firstUserCacheFlushPromise;
|
|
1260
|
+
}
|
|
1261
|
+
|
|
758
1262
|
function pruneFirstSentUserMessageCache() {
|
|
759
1263
|
pruneFirstUserMessageEntries(firstSentUserMessageBySession);
|
|
760
1264
|
}
|
|
@@ -775,6 +1279,91 @@ export function createSessionService(opts = {}) {
|
|
|
775
1279
|
pruneFirstUserMessageCache();
|
|
776
1280
|
}
|
|
777
1281
|
|
|
1282
|
+
function getSessionTitle(sessionKey) {
|
|
1283
|
+
const entry = sessionTitleByKey.get(sessionKey);
|
|
1284
|
+
return entry ? entry.title : null;
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
function setSessionTitle(sessionKey, title, opts) {
|
|
1288
|
+
if (typeof sessionKey !== "string" || !sessionKey.trim()) {
|
|
1289
|
+
return { ok: false, code: "invalid_session_key" };
|
|
1290
|
+
}
|
|
1291
|
+
if (typeof title !== "string" || !title.trim()) {
|
|
1292
|
+
return { ok: false, code: "invalid_title" };
|
|
1293
|
+
}
|
|
1294
|
+
const trimmed = title.trim();
|
|
1295
|
+
const setByUser = !!(opts && opts.userSet === true);
|
|
1296
|
+
const previous = sessionTitleByKey.get(sessionKey);
|
|
1297
|
+
|
|
1298
|
+
if (!setByUser && previous && previous.userSet === true) {
|
|
1299
|
+
return { ok: false, code: "session_user_locked" };
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
const replaced = !!previous;
|
|
1303
|
+
const nextUserSet = setByUser || (previous && previous.userSet === true);
|
|
1304
|
+
sessionTitleByKey.set(sessionKey, {
|
|
1305
|
+
title: trimmed,
|
|
1306
|
+
setAtMs: Date.now(),
|
|
1307
|
+
userSet: !!nextUserSet,
|
|
1308
|
+
});
|
|
1309
|
+
pruneSessionTitleEntries(sessionTitleByKey);
|
|
1310
|
+
persistSessionTitleCache();
|
|
1311
|
+
invalidateSessionsCache();
|
|
1312
|
+
emitDebug(
|
|
1313
|
+
"relay.session",
|
|
1314
|
+
setByUser ? "session_title_set_by_user" : "session_title_set",
|
|
1315
|
+
"info",
|
|
1316
|
+
{ sessionKey },
|
|
1317
|
+
() => ({ sessionKey, title: trimmed, replaced, userSet: !!nextUserSet }),
|
|
1318
|
+
);
|
|
1319
|
+
// Fire-and-forget upstream mirror.
|
|
1320
|
+
if (isUpstreamConnected()) {
|
|
1321
|
+
resolveSessionCanonicalKey(sessionKey)
|
|
1322
|
+
.then((canonicalKey) =>
|
|
1323
|
+
gatewayBridge.request("sessions.patch", {
|
|
1324
|
+
key: canonicalKey,
|
|
1325
|
+
displayName: trimmed,
|
|
1326
|
+
}),
|
|
1327
|
+
)
|
|
1328
|
+
.catch((err) => {
|
|
1329
|
+
emitDebug(
|
|
1330
|
+
"relay.session",
|
|
1331
|
+
"session_title_upstream_patch_failed",
|
|
1332
|
+
"debug",
|
|
1333
|
+
{ sessionKey },
|
|
1334
|
+
() => ({ message: err && err.message ? err.message : String(err) }),
|
|
1335
|
+
);
|
|
1336
|
+
});
|
|
1337
|
+
}
|
|
1338
|
+
return { ok: true, replaced, userSet: !!nextUserSet };
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
function isSessionUserLocked(sessionKey) {
|
|
1342
|
+
const entry = sessionTitleByKey.get(sessionKey);
|
|
1343
|
+
return entry ? entry.userSet === true : false;
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
function hasRecordedFirstUserMessage(sessionKey) {
|
|
1347
|
+
if (typeof sessionKey !== "string" || !sessionKey.trim()) return false;
|
|
1348
|
+
return firstSentUserMessageBySession.has(sessionKey);
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
function recordNeuralSessionNamesEnabled(sessionKey, enabled) {
|
|
1352
|
+
if (typeof sessionKey !== "string" || !sessionKey.trim()) return;
|
|
1353
|
+
neuralSessionNamesEnabledByKey.set(sessionKey, enabled === true);
|
|
1354
|
+
while (neuralSessionNamesEnabledByKey.size > firstUserMessageCacheLimit) {
|
|
1355
|
+
const oldest = neuralSessionNamesEnabledByKey.keys().next().value;
|
|
1356
|
+
if (oldest === undefined) break;
|
|
1357
|
+
neuralSessionNamesEnabledByKey.delete(oldest);
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
function isNeuralSessionNamesEnabled(sessionKey) {
|
|
1362
|
+
if (typeof sessionKey !== "string" || !sessionKey.trim()) return true;
|
|
1363
|
+
const cached = neuralSessionNamesEnabledByKey.get(sessionKey);
|
|
1364
|
+
return cached === undefined ? true : cached;
|
|
1365
|
+
}
|
|
1366
|
+
|
|
778
1367
|
function isSyntheticSessionStarter(text) {
|
|
779
1368
|
if (!text) return false;
|
|
780
1369
|
if (
|
|
@@ -945,7 +1534,14 @@ export function createSessionService(opts = {}) {
|
|
|
945
1534
|
});
|
|
946
1535
|
const messages =
|
|
947
1536
|
result && Array.isArray(result.messages) ? result.messages : [];
|
|
948
|
-
|
|
1537
|
+
const sanitized = Array.isArray(messages)
|
|
1538
|
+
? messages.map((msg) =>
|
|
1539
|
+
msg && msg.role === "assistant"
|
|
1540
|
+
? { ...msg, content: sanitizeAssistantContentBlocks(msg.content) }
|
|
1541
|
+
: msg,
|
|
1542
|
+
)
|
|
1543
|
+
: messages;
|
|
1544
|
+
conversationState.hydrate(sanitized, getAgentName());
|
|
949
1545
|
} catch (err) {
|
|
950
1546
|
logger.error(
|
|
951
1547
|
`[relay] Failed to load session history: ${err.message}`,
|
|
@@ -997,9 +1593,11 @@ export function createSessionService(opts = {}) {
|
|
|
997
1593
|
onStatusChanged();
|
|
998
1594
|
}
|
|
999
1595
|
if (sendResetCommand && isUpstreamConnected()) {
|
|
1000
|
-
gatewayBridge
|
|
1001
|
-
|
|
1002
|
-
|
|
1596
|
+
gatewayBridge
|
|
1597
|
+
.sendMessage(`/new ${NEW_SESSION_GREETING_PROMPT}`, sessionKey)
|
|
1598
|
+
.catch((err) => {
|
|
1599
|
+
logger.error(`[relay] Failed to send /new for new session: ${err.message}`);
|
|
1600
|
+
});
|
|
1003
1601
|
}
|
|
1004
1602
|
return { sessionKey, pages };
|
|
1005
1603
|
}
|
|
@@ -1035,6 +1633,7 @@ export function createSessionService(opts = {}) {
|
|
|
1035
1633
|
peekSessionKey,
|
|
1036
1634
|
createDetachedSessionKey,
|
|
1037
1635
|
recordFirstSentUserMessage,
|
|
1636
|
+
flushFirstSentUserMessageCache,
|
|
1038
1637
|
invalidateSessionsCache,
|
|
1039
1638
|
handleUpstreamStatusChange,
|
|
1040
1639
|
getSessionModelConfig,
|
|
@@ -1045,9 +1644,21 @@ export function createSessionService(opts = {}) {
|
|
|
1045
1644
|
hasPendingInitialConfig,
|
|
1046
1645
|
clearPendingInitialConfig,
|
|
1047
1646
|
getSessions,
|
|
1647
|
+
getSessionTitle,
|
|
1048
1648
|
getSessionsByExactKeys,
|
|
1649
|
+
hasRecordedFirstUserMessage,
|
|
1650
|
+
isNeuralSessionNamesEnabled,
|
|
1651
|
+
isSessionUserLocked,
|
|
1652
|
+
recordNeuralSessionNamesEnabled,
|
|
1653
|
+
setSessionTitle,
|
|
1049
1654
|
switchToSession,
|
|
1050
1655
|
newSession,
|
|
1051
1656
|
isCurrentSession,
|
|
1657
|
+
setSessionPinned,
|
|
1658
|
+
getSessionPin,
|
|
1659
|
+
deleteSessions,
|
|
1660
|
+
switchAndDeleteSessions,
|
|
1661
|
+
broadcastSessionsForKind,
|
|
1662
|
+
searchTranscripts,
|
|
1052
1663
|
};
|
|
1053
1664
|
}
|