ocuclaw 1.3.1 → 1.3.3
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/glasses-backpressure-latch.js +115 -0
- package/dist/runtime/register-session-title-distiller.js +100 -0
- package/dist/runtime/relay-core.js +284 -33
- package/dist/runtime/relay-service.js +152 -13
- package/dist/runtime/relay-worker-entry.js +26 -0
- package/dist/runtime/relay-worker-supervisor.js +51 -2
- package/dist/runtime/relay-worker-transport.js +51 -1
- 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 +59 -3
- package/dist/tools/glasses-ui-paint-floor.js +33 -4
- package/dist/tools/glasses-ui-surfaces.js +369 -35
- package/dist/tools/glasses-ui-tool-description.test.js +16 -0
- package/dist/tools/glasses-ui-tool.js +662 -80
- package/dist/tools/glasses-ui-voicemail.js +299 -0
- package/dist/tools/glasses-ui-wake.js +262 -0
- 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 +26 -3
|
@@ -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);
|
|
316
338
|
}
|
|
317
339
|
return false;
|
|
318
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);
|
|
369
|
+
}
|
|
370
|
+
return false;
|
|
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
|
},
|
|
@@ -340,6 +447,38 @@ export function createOcuClawRelayService(opts = {}) {
|
|
|
340
447
|
}
|
|
341
448
|
return false;
|
|
342
449
|
},
|
|
450
|
+
isGlassesSendBufferOverHighWater() {
|
|
451
|
+
// Roadmap 4a: glasses-ui paint-floor shed signal (glasses-ui-tool.ts
|
|
452
|
+
// already consults this; false = no shedding, the safe default).
|
|
453
|
+
const liveRelay = resolveLiveRelay();
|
|
454
|
+
if (liveRelay && typeof liveRelay.isGlassesSendBufferOverHighWater === "function") {
|
|
455
|
+
return liveRelay.isGlassesSendBufferOverHighWater();
|
|
456
|
+
}
|
|
457
|
+
return false;
|
|
458
|
+
},
|
|
459
|
+
// Tap-to-wake lane + busy signal (roadmap 6f). registerGlassesUiTool
|
|
460
|
+
// probes these at plugin register() time — BEFORE start() — and wires
|
|
461
|
+
// dispatchWake to null when the probe fails, so they must exist on the
|
|
462
|
+
// facade unconditionally and resolve the live (possibly shared) relay at
|
|
463
|
+
// call time, like every other passthrough above.
|
|
464
|
+
dispatchGlassesWake(params) {
|
|
465
|
+
const liveRelay = resolveLiveRelay();
|
|
466
|
+
if (!liveRelay || typeof liveRelay.dispatchGlassesWake !== "function") {
|
|
467
|
+
// The wake controller retries once, then parks the wake in its
|
|
468
|
+
// durable outbox with a warn lifecycle — never silent after the ✓-ack.
|
|
469
|
+
return Promise.reject(new Error("ocuclaw relay not started"));
|
|
470
|
+
}
|
|
471
|
+
return liveRelay.dispatchGlassesWake(params);
|
|
472
|
+
},
|
|
473
|
+
isAgentTurnBusy(sessionKey) {
|
|
474
|
+
const liveRelay = resolveLiveRelay();
|
|
475
|
+
if (liveRelay && typeof liveRelay.isAgentTurnBusy === "function") {
|
|
476
|
+
return liveRelay.isAgentTurnBusy(sessionKey);
|
|
477
|
+
}
|
|
478
|
+
// Idle, not busy: a missing relay must surface through the dispatch
|
|
479
|
+
// path's failure handling, never suppress wakes as phantom-busy.
|
|
480
|
+
return false;
|
|
481
|
+
},
|
|
343
482
|
start,
|
|
344
483
|
stop,
|
|
345
484
|
};
|
|
@@ -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"
|
|
@@ -752,6 +777,14 @@ export function createRelayWorkerSupervisor(options = {}) {
|
|
|
752
777
|
postMainFrame("unicast", frame, pending.requesterClientId);
|
|
753
778
|
return;
|
|
754
779
|
}
|
|
780
|
+
if (message.kind === "worker.backpressure") {
|
|
781
|
+
// Roadmap 4a: heartbeat-cadence send-buffer pressure counts from the
|
|
782
|
+
// worker transport; relay-core latches them for the glasses paint shed.
|
|
783
|
+
if (typeof options.onWorkerBackpressure === "function") {
|
|
784
|
+
options.onWorkerBackpressure(message);
|
|
785
|
+
}
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
755
788
|
if (message.kind === "debug" && typeof options.emitDebug === "function") {
|
|
756
789
|
options.emitDebug(
|
|
757
790
|
message.category || "relay.worker.health",
|
|
@@ -911,6 +944,22 @@ export function createRelayWorkerSupervisor(options = {}) {
|
|
|
911
944
|
});
|
|
912
945
|
}
|
|
913
946
|
|
|
947
|
+
// The WebUI's reconnect hello embeds its readiness snapshot WITHOUT
|
|
948
|
+
// emittedAtMs (toProtocolHelloReadinessSnapshotJson omits it), but the
|
|
949
|
+
// automation-state gate requires a finite emittedAtMs to count the client
|
|
950
|
+
// as published. Stamp ingest time so a reconnect hello counts as a
|
|
951
|
+
// publication — otherwise every relay restart leaves `inspect state`
|
|
952
|
+
// returning snapshot_unavailable until the sim/app client is cycled.
|
|
953
|
+
function normalizeIngestedReadinessSnapshot(snapshot) {
|
|
954
|
+
if (!snapshot || typeof snapshot !== "object") {
|
|
955
|
+
return null;
|
|
956
|
+
}
|
|
957
|
+
if (Number.isFinite(snapshot.emittedAtMs)) {
|
|
958
|
+
return snapshot;
|
|
959
|
+
}
|
|
960
|
+
return { ...snapshot, emittedAtMs: Date.now() };
|
|
961
|
+
}
|
|
962
|
+
|
|
914
963
|
function getReadinessSnapshot() {
|
|
915
964
|
const appClients = getConnectedAppEntries();
|
|
916
965
|
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,
|
|
@@ -956,8 +997,17 @@ export function createRelayWorkerTransport(options = {}) {
|
|
|
956
997
|
}, Math.max(10, Math.min(1000, manifest.queue.messageSendTtlMs)));
|
|
957
998
|
healthTimer = setInterval(() => {
|
|
958
999
|
health.updateLoopLagP95Ms(sampleLoopLagP95Ms());
|
|
959
|
-
|
|
1000
|
+
const sendBufferHighWaterClients = countSendBufferHighWaterClients();
|
|
1001
|
+
health.updateSendBufferHighWaterClients(sendBufferHighWaterClients);
|
|
960
1002
|
health.sample();
|
|
1003
|
+
// Roadmap 4a: main holds the glasses paint-floor shed latch; the ws
|
|
1004
|
+
// sockets (and their bufferedAmount) live here in the worker, so the
|
|
1005
|
+
// count crosses on every heartbeat (the latch fail-opens on staleness).
|
|
1006
|
+
postToMain({
|
|
1007
|
+
kind: "worker.backpressure",
|
|
1008
|
+
workerEpoch: manifest.workerEpoch,
|
|
1009
|
+
sendBufferHighWaterClients,
|
|
1010
|
+
});
|
|
961
1011
|
}, manifest.health.heartbeatIntervalMs);
|
|
962
1012
|
postToMain({ kind: "worker.ready", workerEpoch: manifest.workerEpoch, address: address() });
|
|
963
1013
|
}
|
|
@@ -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,
|