ocuclaw 1.3.0 → 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/README.md +3 -1
- package/dist/config/runtime-config-session-title-model.test.js +22 -0
- package/dist/config/runtime-config.js +24 -15
- package/dist/domain/debug-store.js +18 -0
- 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/plugin-version-service.js +23 -0
- package/dist/runtime/register-session-title-distiller.js +100 -0
- package/dist/runtime/relay-core.js +307 -68
- package/dist/runtime/relay-service.js +120 -13
- package/dist/runtime/relay-worker-entry.js +26 -0
- package/dist/runtime/relay-worker-protocol.js +0 -4
- package/dist/runtime/relay-worker-supervisor.js +43 -79
- package/dist/runtime/relay-worker-transport.js +41 -0
- package/dist/runtime/session-service.js +159 -15
- 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-recipes.js +13 -178
- 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 +98 -60
- 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 +6 -4
- package/skills/glasses-ui/SKILL.md +163 -0
- package/dist/runtime/downstream-server.js +0 -2057
- package/dist/runtime/plugin-update-service.js +0 -216
|
@@ -1,12 +1,33 @@
|
|
|
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";
|
|
7
10
|
const SESSION_PIN_CACHE_FILE = "ocuclaw-session-pins.json";
|
|
8
11
|
const PIN_CAP_PER_KIND = 20;
|
|
9
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Greeting-eliciting content appended to /new and /reset so OpenClaw runs an
|
|
15
|
+
* agent turn (the new-session "welcome"). OpenClaw 2026.6.x ("make bare reset
|
|
16
|
+
* commands fast", gateway commit 2c6a3f6b04) made a BARE /new or /reset return
|
|
17
|
+
* a synchronous ack and run NO agent turn — so a bare reset no longer produces
|
|
18
|
+
* a welcome and leaves the glasses stuck on the "starting new session"
|
|
19
|
+
* placeholder. Sending "/new <prompt>" / "/reset <prompt>" routes through the
|
|
20
|
+
* gateway's normal agent path (content = the prompt) and elicits the welcome.
|
|
21
|
+
* The wording matches the prompt OpenClaw used to inject itself, so the
|
|
22
|
+
* existing synthetic-session-starter filters (conversation-state display hide +
|
|
23
|
+
* isSyntheticSessionStarter title/preview filter) keep it hidden from the
|
|
24
|
+
* transcript and session titles. NOTE: newSession() does NOT call
|
|
25
|
+
* conversationState.addMessage("user", ...), so this content never renders as a
|
|
26
|
+
* user bubble on the glasses.
|
|
27
|
+
*/
|
|
28
|
+
export const NEW_SESSION_GREETING_PROMPT =
|
|
29
|
+
"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.";
|
|
30
|
+
|
|
10
31
|
|
|
11
32
|
function normalizeLogger(logger) {
|
|
12
33
|
if (!logger || typeof logger !== "object") {
|
|
@@ -150,6 +171,14 @@ export function createSessionService(opts = {}) {
|
|
|
150
171
|
const sessionTitleByKey = loadSessionTitleCache();
|
|
151
172
|
/** @type {Map<string, boolean>} Per-session Neural Session Names toggle state. */
|
|
152
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({});
|
|
153
182
|
|
|
154
183
|
/** Path for session pin metadata cache file. */
|
|
155
184
|
const sessionPinCachePath = resolveSessionPinCachePath(opts.stateDir);
|
|
@@ -762,10 +791,17 @@ export function createSessionService(opts = {}) {
|
|
|
762
791
|
function resolveRowTitle(sessionKey, row) {
|
|
763
792
|
const cached = getSessionTitle(sessionKey);
|
|
764
793
|
if (cached !== null) return cached;
|
|
765
|
-
//
|
|
766
|
-
//
|
|
767
|
-
|
|
768
|
-
|
|
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();
|
|
769
805
|
if (trimmed) return trimmed;
|
|
770
806
|
}
|
|
771
807
|
return null;
|
|
@@ -959,6 +995,7 @@ export function createSessionService(opts = {}) {
|
|
|
959
995
|
sessionPinByKey.delete(key);
|
|
960
996
|
sessionTitleByKey.delete(key);
|
|
961
997
|
firstSentUserMessageBySession.delete(key);
|
|
998
|
+
distillerBudget.clear(key);
|
|
962
999
|
deleted.push(key);
|
|
963
1000
|
} catch (err) {
|
|
964
1001
|
failed.push({ key, reason: err?.message ?? "unknown" });
|
|
@@ -1266,6 +1303,11 @@ export function createSessionService(opts = {}) {
|
|
|
1266
1303
|
return entry ? entry.title : null;
|
|
1267
1304
|
}
|
|
1268
1305
|
|
|
1306
|
+
function getSessionTitleRecord(sessionKey) {
|
|
1307
|
+
const entry = sessionTitleByKey.get(sessionKey);
|
|
1308
|
+
return entry ? { ...entry } : null;
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1269
1311
|
function setSessionTitle(sessionKey, title, opts) {
|
|
1270
1312
|
if (typeof sessionKey !== "string" || !sessionKey.trim()) {
|
|
1271
1313
|
return { ok: false, code: "invalid_session_key" };
|
|
@@ -1274,19 +1316,26 @@ export function createSessionService(opts = {}) {
|
|
|
1274
1316
|
return { ok: false, code: "invalid_title" };
|
|
1275
1317
|
}
|
|
1276
1318
|
const trimmed = title.trim();
|
|
1277
|
-
|
|
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";
|
|
1278
1326
|
const previous = sessionTitleByKey.get(sessionKey);
|
|
1279
|
-
|
|
1280
|
-
if (!
|
|
1281
|
-
return { ok: false, code:
|
|
1327
|
+
const decision = decideTitleWrite(previous, origin);
|
|
1328
|
+
if (!decision.allowed) {
|
|
1329
|
+
return { ok: false, code: decision.code };
|
|
1282
1330
|
}
|
|
1283
|
-
|
|
1284
1331
|
const replaced = !!previous;
|
|
1285
|
-
const nextUserSet =
|
|
1332
|
+
const nextUserSet = decision.nextUserSet;
|
|
1333
|
+
const setByUser = isUserOrigin(origin);
|
|
1286
1334
|
sessionTitleByKey.set(sessionKey, {
|
|
1287
1335
|
title: trimmed,
|
|
1288
1336
|
setAtMs: Date.now(),
|
|
1289
1337
|
userSet: !!nextUserSet,
|
|
1338
|
+
origin,
|
|
1290
1339
|
});
|
|
1291
1340
|
pruneSessionTitleEntries(sessionTitleByKey);
|
|
1292
1341
|
persistSessionTitleCache();
|
|
@@ -1296,15 +1345,26 @@ export function createSessionService(opts = {}) {
|
|
|
1296
1345
|
setByUser ? "session_title_set_by_user" : "session_title_set",
|
|
1297
1346
|
"info",
|
|
1298
1347
|
{ sessionKey },
|
|
1299
|
-
() => ({ sessionKey, title: trimmed, replaced, userSet: !!nextUserSet }),
|
|
1348
|
+
() => ({ sessionKey, title: trimmed, replaced, userSet: !!nextUserSet, origin }),
|
|
1300
1349
|
);
|
|
1301
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
|
+
}
|
|
1302
1360
|
if (isUpstreamConnected()) {
|
|
1303
1361
|
resolveSessionCanonicalKey(sessionKey)
|
|
1304
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).
|
|
1305
1365
|
gatewayBridge.request("sessions.patch", {
|
|
1306
1366
|
key: canonicalKey,
|
|
1307
|
-
|
|
1367
|
+
label: trimmed,
|
|
1308
1368
|
}),
|
|
1309
1369
|
)
|
|
1310
1370
|
.catch((err) => {
|
|
@@ -1346,6 +1406,78 @@ export function createSessionService(opts = {}) {
|
|
|
1346
1406
|
return cached === undefined ? true : cached;
|
|
1347
1407
|
}
|
|
1348
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
|
+
|
|
1349
1481
|
function isSyntheticSessionStarter(text) {
|
|
1350
1482
|
if (!text) return false;
|
|
1351
1483
|
if (
|
|
@@ -1575,9 +1707,11 @@ export function createSessionService(opts = {}) {
|
|
|
1575
1707
|
onStatusChanged();
|
|
1576
1708
|
}
|
|
1577
1709
|
if (sendResetCommand && isUpstreamConnected()) {
|
|
1578
|
-
gatewayBridge
|
|
1579
|
-
|
|
1580
|
-
|
|
1710
|
+
gatewayBridge
|
|
1711
|
+
.sendMessage(`/new ${NEW_SESSION_GREETING_PROMPT}`, sessionKey)
|
|
1712
|
+
.catch((err) => {
|
|
1713
|
+
logger.error(`[relay] Failed to send /new for new session: ${err.message}`);
|
|
1714
|
+
});
|
|
1581
1715
|
}
|
|
1582
1716
|
return { sessionKey, pages };
|
|
1583
1717
|
}
|
|
@@ -1625,11 +1759,21 @@ export function createSessionService(opts = {}) {
|
|
|
1625
1759
|
clearPendingInitialConfig,
|
|
1626
1760
|
getSessions,
|
|
1627
1761
|
getSessionTitle,
|
|
1762
|
+
getSessionTitleRecord,
|
|
1628
1763
|
getSessionsByExactKeys,
|
|
1629
1764
|
hasRecordedFirstUserMessage,
|
|
1630
1765
|
isNeuralSessionNamesEnabled,
|
|
1766
|
+
isEvenAiSessionKey,
|
|
1631
1767
|
isSessionUserLocked,
|
|
1632
1768
|
recordNeuralSessionNamesEnabled,
|
|
1769
|
+
recordDisplayToggleStates,
|
|
1770
|
+
getDisplayStartStates,
|
|
1771
|
+
getDisplayCurrentStates,
|
|
1772
|
+
clearDisplayToggleStates,
|
|
1773
|
+
getDistillerBudget,
|
|
1774
|
+
clearDistillerBudget,
|
|
1775
|
+
clearSessionTitle,
|
|
1776
|
+
clearLogicalSessionState,
|
|
1633
1777
|
setSessionTitle,
|
|
1634
1778
|
switchToSession,
|
|
1635
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;
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
export const DISTILLER_SESSION_PREFIX = "ocuclaw:title-distiller:";
|
|
2
|
+
export const TITLE_MAX = 55;
|
|
3
|
+
|
|
4
|
+
export function isDistillerSessionKey(sessionKey) {
|
|
5
|
+
return typeof sessionKey === "string" && sessionKey.startsWith(DISTILLER_SESSION_PREFIX);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// OpenClaw hooks (agent_end et al.) deliver the CANONICAL session key —
|
|
9
|
+
// `agent:<agentId>:<sessionKey>` on 2026.6.x — while relay-side state
|
|
10
|
+
// (first-user marker, title record, user lock) is keyed by the bare relay
|
|
11
|
+
// key the app sends with. Strip exactly one canonical prefix so gate
|
|
12
|
+
// lookups and the distiller recursion guard see the relay key.
|
|
13
|
+
export function stripAgentSessionPrefix(sessionKey) {
|
|
14
|
+
if (typeof sessionKey !== "string") return sessionKey;
|
|
15
|
+
const m = /^agent:[^:]+:(.+)$/.exec(sessionKey);
|
|
16
|
+
return m ? m[1] : sessionKey;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function sanitizeTitle(raw) {
|
|
20
|
+
if (typeof raw !== "string") return null;
|
|
21
|
+
let s = raw.split("\n")[0].trim();
|
|
22
|
+
if (!s) return null;
|
|
23
|
+
// Strip surrounding quotes (straight or curly) and trailing sentence
|
|
24
|
+
// punctuation, repeatedly, so a combination like '"Trip Planning".' fully
|
|
25
|
+
// reduces (a single pass leaves the quote shielded behind the period).
|
|
26
|
+
let prev;
|
|
27
|
+
do {
|
|
28
|
+
prev = s;
|
|
29
|
+
s = s
|
|
30
|
+
.replace(/^["'“”‘’]+/, "")
|
|
31
|
+
.replace(/["'“”‘’]+$/, "")
|
|
32
|
+
.replace(/[.!?,;:]+$/, "")
|
|
33
|
+
.trim();
|
|
34
|
+
} while (s !== prev && s.length > 0);
|
|
35
|
+
if (!s) return null;
|
|
36
|
+
if (s.toUpperCase() === "SKIP") return null;
|
|
37
|
+
if (s.length > TITLE_MAX) s = s.slice(0, TITLE_MAX).trim();
|
|
38
|
+
return s || null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function internalTranscriptFilename(runId) {
|
|
42
|
+
const safe = String(runId == null ? "" : runId)
|
|
43
|
+
.replace(/[^a-zA-Z0-9._-]/g, "_")
|
|
44
|
+
.slice(0, 120) || "run";
|
|
45
|
+
return `${safe}.jsonl`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function extractText(content) {
|
|
49
|
+
if (typeof content === "string") return content;
|
|
50
|
+
if (!Array.isArray(content)) return "";
|
|
51
|
+
return content
|
|
52
|
+
.filter((b) => b && b.type === "text" && typeof b.text === "string")
|
|
53
|
+
.map((b) => b.text)
|
|
54
|
+
.join("");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Pull the title text out of a subagent `getSessionMessages` result: the last
|
|
59
|
+
* assistant message's text. Defensive about message shape (string or text-block
|
|
60
|
+
* array content) and about non-assistant trailing entries.
|
|
61
|
+
*/
|
|
62
|
+
export function extractAssistantTitleFromMessages(messages) {
|
|
63
|
+
if (!Array.isArray(messages)) return "";
|
|
64
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
65
|
+
const m = messages[i];
|
|
66
|
+
if (!m || typeof m !== "object") continue;
|
|
67
|
+
if (m.role !== "assistant") continue;
|
|
68
|
+
const text = extractText(m.content);
|
|
69
|
+
if (text && text.trim()) return text;
|
|
70
|
+
}
|
|
71
|
+
return "";
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function buildExcerpt(messages, opts = {}) {
|
|
75
|
+
const maxMessages = Number.isFinite(opts.maxMessages) ? opts.maxMessages : 6;
|
|
76
|
+
const per = Number.isFinite(opts.perMessageChars) ? opts.perMessageChars : 280;
|
|
77
|
+
const recent = (Array.isArray(messages) ? messages : []).slice(-maxMessages);
|
|
78
|
+
return recent
|
|
79
|
+
.map((m) => {
|
|
80
|
+
const role = m && m.role === "assistant" ? "assistant" : "user";
|
|
81
|
+
let text = extractText(m && m.content).replace(/\s+/g, " ").trim();
|
|
82
|
+
if (text.length > per) text = text.slice(0, per);
|
|
83
|
+
return `${role}: ${text}`;
|
|
84
|
+
})
|
|
85
|
+
.join("\n");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Split a "provider/model" ref (the sessionTitleModel config format) into the
|
|
90
|
+
* separate provider/model fields the gateway agent RPC expects. Splits on the
|
|
91
|
+
* FIRST "/"; a bare id (or one with an empty half) stays a model-only override.
|
|
92
|
+
* Mirrors the Even-AI model hook's parseModelRef.
|
|
93
|
+
* @returns {{provider?: string, model: string} | null}
|
|
94
|
+
*/
|
|
95
|
+
export function splitModelRef(ref) {
|
|
96
|
+
if (typeof ref !== "string") return null;
|
|
97
|
+
const normalized = ref.trim();
|
|
98
|
+
if (!normalized) return null;
|
|
99
|
+
const slash = normalized.indexOf("/");
|
|
100
|
+
if (slash <= 0 || slash >= normalized.length - 1) {
|
|
101
|
+
return { model: normalized };
|
|
102
|
+
}
|
|
103
|
+
const provider = normalized.slice(0, slash).trim();
|
|
104
|
+
const model = normalized.slice(slash + 1).trim();
|
|
105
|
+
if (!provider || !model) return { model: normalized };
|
|
106
|
+
return { provider, model };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function buildDistillerAgentParams(opts) {
|
|
110
|
+
const params = {
|
|
111
|
+
message: opts.message,
|
|
112
|
+
sessionKey: opts.sessionKey,
|
|
113
|
+
idempotencyKey: opts.idempotencyKey,
|
|
114
|
+
modelRun: true,
|
|
115
|
+
promptMode: "none",
|
|
116
|
+
sessionEffects: "internal",
|
|
117
|
+
suppressPromptPersistence: true,
|
|
118
|
+
disableMessageTool: true,
|
|
119
|
+
deliver: false,
|
|
120
|
+
lane: "background",
|
|
121
|
+
};
|
|
122
|
+
// The gateway agent RPC takes provider + model as SEPARATE fields; the config
|
|
123
|
+
// value is "provider/model", so split it rather than sending the slash string.
|
|
124
|
+
const ref = splitModelRef(opts.model);
|
|
125
|
+
if (ref) {
|
|
126
|
+
if (ref.provider) params.provider = ref.provider;
|
|
127
|
+
params.model = ref.model;
|
|
128
|
+
}
|
|
129
|
+
return params;
|
|
130
|
+
}
|