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