Files
clawdbot/src/gateway/session-utils.ts
CJ Winslow 1d9d5b30ce feat: add last message preview to session picker
Read the final user/assistant message from session transcripts and display
it in the picker alongside the session update time. Allows quick previews
of what's in each session without opening it.
2026-01-20 16:36:51 +00:00

541 lines
17 KiB
TypeScript

import fs from "node:fs";
import path from "node:path";
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
import { lookupContextTokens } from "../agents/context.js";
import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
import { resolveConfiguredModelRef } from "../agents/model-selection.js";
import { type ClawdbotConfig, loadConfig } from "../config/config.js";
import { resolveStateDir } from "../config/paths.js";
import {
buildGroupDisplayName,
canonicalizeMainSessionAlias,
loadSessionStore,
resolveMainSessionKey,
resolveStorePath,
type SessionEntry,
type SessionScope,
} from "../config/sessions.js";
import {
normalizeAgentId,
normalizeMainKey,
parseAgentSessionKey,
} from "../routing/session-key.js";
import { normalizeSessionDeliveryFields } from "../utils/delivery-context.js";
import {
readFirstUserMessageFromTranscript,
readLastMessagePreviewFromTranscript,
} from "./session-utils.fs.js";
import type {
GatewayAgentRow,
GatewaySessionRow,
GatewaySessionsDefaults,
SessionsListResult,
} from "./session-utils.types.js";
export {
archiveFileOnDisk,
capArrayByJsonBytes,
readFirstUserMessageFromTranscript,
readLastMessagePreviewFromTranscript,
readSessionMessages,
resolveSessionTranscriptCandidates,
} from "./session-utils.fs.js";
export type {
GatewayAgentRow,
GatewaySessionRow,
GatewaySessionsDefaults,
SessionsListResult,
SessionsPatchResult,
} from "./session-utils.types.js";
const DERIVED_TITLE_MAX_LEN = 60;
function formatSessionIdPrefix(sessionId: string, updatedAt?: number | null): string {
const prefix = sessionId.slice(0, 8);
if (updatedAt && updatedAt > 0) {
const d = new Date(updatedAt);
const date = d.toISOString().slice(0, 10);
return `${prefix} (${date})`;
}
return prefix;
}
function truncateTitle(text: string, maxLen: number): string {
if (text.length <= maxLen) return text;
const cut = text.slice(0, maxLen - 1);
const lastSpace = cut.lastIndexOf(" ");
if (lastSpace > maxLen * 0.6) return cut.slice(0, lastSpace) + "…";
return cut + "…";
}
export function deriveSessionTitle(
entry: SessionEntry | undefined,
firstUserMessage?: string | null,
): string | undefined {
if (!entry) return undefined;
if (entry.displayName?.trim()) {
return entry.displayName.trim();
}
if (entry.subject?.trim()) {
return entry.subject.trim();
}
if (firstUserMessage?.trim()) {
return truncateTitle(firstUserMessage.trim(), DERIVED_TITLE_MAX_LEN);
}
if (entry.sessionId) {
return formatSessionIdPrefix(entry.sessionId, entry.updatedAt);
}
return undefined;
}
export function loadSessionEntry(sessionKey: string) {
const cfg = loadConfig();
const sessionCfg = cfg.session;
const canonicalKey = resolveSessionStoreKey({ cfg, sessionKey });
const agentId = resolveSessionStoreAgentId(cfg, canonicalKey);
const storePath = resolveStorePath(sessionCfg?.store, { agentId });
const store = loadSessionStore(storePath);
const entry = store[canonicalKey];
return { cfg, storePath, store, entry, canonicalKey };
}
export function classifySessionKey(key: string, entry?: SessionEntry): GatewaySessionRow["kind"] {
if (key === "global") return "global";
if (key === "unknown") return "unknown";
if (entry?.chatType === "group" || entry?.chatType === "channel") {
return "group";
}
if (key.includes(":group:") || key.includes(":channel:")) {
return "group";
}
return "direct";
}
export function parseGroupKey(
key: string,
): { channel?: string; kind?: "group" | "channel"; id?: string } | null {
const agentParsed = parseAgentSessionKey(key);
const rawKey = agentParsed?.rest ?? key;
const parts = rawKey.split(":").filter(Boolean);
if (parts.length >= 3) {
const [channel, kind, ...rest] = parts;
if (kind === "group" || kind === "channel") {
const id = rest.join(":");
return { channel, kind, id };
}
}
return null;
}
function isStorePathTemplate(store?: string): boolean {
return typeof store === "string" && store.includes("{agentId}");
}
function listExistingAgentIdsFromDisk(): string[] {
const root = resolveStateDir();
const agentsDir = path.join(root, "agents");
try {
const entries = fs.readdirSync(agentsDir, { withFileTypes: true });
return entries
.filter((entry) => entry.isDirectory())
.map((entry) => normalizeAgentId(entry.name))
.filter(Boolean);
} catch {
return [];
}
}
function listConfiguredAgentIds(cfg: ClawdbotConfig): string[] {
const agents = cfg.agents?.list ?? [];
if (agents.length > 0) {
const ids = new Set<string>();
for (const entry of agents) {
if (entry?.id) ids.add(normalizeAgentId(entry.id));
}
const defaultId = normalizeAgentId(resolveDefaultAgentId(cfg));
ids.add(defaultId);
const sorted = Array.from(ids).filter(Boolean);
sorted.sort((a, b) => a.localeCompare(b));
return sorted.includes(defaultId)
? [defaultId, ...sorted.filter((id) => id !== defaultId)]
: sorted;
}
const ids = new Set<string>();
const defaultId = normalizeAgentId(resolveDefaultAgentId(cfg));
ids.add(defaultId);
for (const id of listExistingAgentIdsFromDisk()) ids.add(id);
const sorted = Array.from(ids).filter(Boolean);
sorted.sort((a, b) => a.localeCompare(b));
if (sorted.includes(defaultId)) {
return [defaultId, ...sorted.filter((id) => id !== defaultId)];
}
return sorted;
}
export function listAgentsForGateway(cfg: ClawdbotConfig): {
defaultId: string;
mainKey: string;
scope: SessionScope;
agents: GatewayAgentRow[];
} {
const defaultId = normalizeAgentId(resolveDefaultAgentId(cfg));
const mainKey = normalizeMainKey(cfg.session?.mainKey);
const scope = cfg.session?.scope ?? "per-sender";
const configuredById = new Map<string, { name?: string }>();
for (const entry of cfg.agents?.list ?? []) {
if (!entry?.id) continue;
configuredById.set(normalizeAgentId(entry.id), {
name: typeof entry.name === "string" && entry.name.trim() ? entry.name.trim() : undefined,
});
}
const explicitIds = new Set(
(cfg.agents?.list ?? [])
.map((entry) => (entry?.id ? normalizeAgentId(entry.id) : ""))
.filter(Boolean),
);
const allowedIds = explicitIds.size > 0 ? new Set([...explicitIds, defaultId]) : null;
let agentIds = listConfiguredAgentIds(cfg).filter((id) =>
allowedIds ? allowedIds.has(id) : true,
);
if (mainKey && !agentIds.includes(mainKey)) {
agentIds = [...agentIds, mainKey];
}
const agents = agentIds.map((id) => {
const meta = configuredById.get(id);
return {
id,
name: meta?.name,
};
});
return { defaultId, mainKey, scope, agents };
}
function canonicalizeSessionKeyForAgent(agentId: string, key: string): string {
if (key === "global" || key === "unknown") return key;
if (key.startsWith("agent:")) return key;
return `agent:${normalizeAgentId(agentId)}:${key}`;
}
function resolveDefaultStoreAgentId(cfg: ClawdbotConfig): string {
return normalizeAgentId(resolveDefaultAgentId(cfg));
}
export function resolveSessionStoreKey(params: {
cfg: ClawdbotConfig;
sessionKey: string;
}): string {
const raw = params.sessionKey.trim();
if (!raw) return raw;
if (raw === "global" || raw === "unknown") return raw;
const parsed = parseAgentSessionKey(raw);
if (parsed) {
const agentId = normalizeAgentId(parsed.agentId);
const canonical = canonicalizeMainSessionAlias({
cfg: params.cfg,
agentId,
sessionKey: raw,
});
if (canonical !== raw) return canonical;
return raw;
}
const rawMainKey = normalizeMainKey(params.cfg.session?.mainKey);
if (raw === "main" || raw === rawMainKey) {
return resolveMainSessionKey(params.cfg);
}
const agentId = resolveDefaultStoreAgentId(params.cfg);
return canonicalizeSessionKeyForAgent(agentId, raw);
}
function resolveSessionStoreAgentId(cfg: ClawdbotConfig, canonicalKey: string): string {
if (canonicalKey === "global" || canonicalKey === "unknown") {
return resolveDefaultStoreAgentId(cfg);
}
const parsed = parseAgentSessionKey(canonicalKey);
if (parsed?.agentId) return normalizeAgentId(parsed.agentId);
return resolveDefaultStoreAgentId(cfg);
}
function canonicalizeSpawnedByForAgent(agentId: string, spawnedBy?: string): string | undefined {
const raw = spawnedBy?.trim();
if (!raw) return undefined;
if (raw === "global" || raw === "unknown") return raw;
if (raw.startsWith("agent:")) return raw;
return `agent:${normalizeAgentId(agentId)}:${raw}`;
}
export function resolveGatewaySessionStoreTarget(params: { cfg: ClawdbotConfig; key: string }): {
agentId: string;
storePath: string;
canonicalKey: string;
storeKeys: string[];
} {
const key = params.key.trim();
const canonicalKey = resolveSessionStoreKey({
cfg: params.cfg,
sessionKey: key,
});
const agentId = resolveSessionStoreAgentId(params.cfg, canonicalKey);
const storeConfig = params.cfg.session?.store;
const storePath = resolveStorePath(storeConfig, { agentId });
if (canonicalKey === "global" || canonicalKey === "unknown") {
const storeKeys = key && key !== canonicalKey ? [canonicalKey, key] : [key];
return { agentId, storePath, canonicalKey, storeKeys };
}
const storeKeys = new Set<string>();
storeKeys.add(canonicalKey);
if (key && key !== canonicalKey) storeKeys.add(key);
return {
agentId,
storePath,
canonicalKey,
storeKeys: Array.from(storeKeys),
};
}
export function loadCombinedSessionStoreForGateway(cfg: ClawdbotConfig): {
storePath: string;
store: Record<string, SessionEntry>;
} {
const storeConfig = cfg.session?.store;
if (storeConfig && !isStorePathTemplate(storeConfig)) {
const storePath = resolveStorePath(storeConfig);
const defaultAgentId = normalizeAgentId(resolveDefaultAgentId(cfg));
const store = loadSessionStore(storePath);
const combined: Record<string, SessionEntry> = {};
for (const [key, entry] of Object.entries(store)) {
const canonicalKey = canonicalizeSessionKeyForAgent(defaultAgentId, key);
combined[canonicalKey] = {
...entry,
spawnedBy: canonicalizeSpawnedByForAgent(defaultAgentId, entry.spawnedBy),
};
}
return { storePath, store: combined };
}
const agentIds = listConfiguredAgentIds(cfg);
const combined: Record<string, SessionEntry> = {};
for (const agentId of agentIds) {
const storePath = resolveStorePath(storeConfig, { agentId });
const store = loadSessionStore(storePath);
for (const [key, entry] of Object.entries(store)) {
const canonicalKey = canonicalizeSessionKeyForAgent(agentId, key);
// Merge with existing entry if present (avoid overwriting with less complete data)
const existing = combined[canonicalKey];
combined[canonicalKey] = {
...existing,
...entry,
spawnedBy: canonicalizeSpawnedByForAgent(agentId, entry.spawnedBy ?? existing?.spawnedBy),
};
}
}
const storePath =
typeof storeConfig === "string" && storeConfig.trim() ? storeConfig.trim() : "(multiple)";
return { storePath, store: combined };
}
export function getSessionDefaults(cfg: ClawdbotConfig): GatewaySessionsDefaults {
const resolved = resolveConfiguredModelRef({
cfg,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
});
const contextTokens =
cfg.agents?.defaults?.contextTokens ??
lookupContextTokens(resolved.model) ??
DEFAULT_CONTEXT_TOKENS;
return {
modelProvider: resolved.provider ?? null,
model: resolved.model ?? null,
contextTokens: contextTokens ?? null,
};
}
export function resolveSessionModelRef(
cfg: ClawdbotConfig,
entry?: SessionEntry,
): { provider: string; model: string } {
const resolved = resolveConfiguredModelRef({
cfg,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
});
let provider = resolved.provider;
let model = resolved.model;
const storedModelOverride = entry?.modelOverride?.trim();
if (storedModelOverride) {
provider = entry?.providerOverride?.trim() || provider;
model = storedModelOverride;
}
return { provider, model };
}
export function listSessionsFromStore(params: {
cfg: ClawdbotConfig;
storePath: string;
store: Record<string, SessionEntry>;
opts: import("./protocol/index.js").SessionsListParams;
}): SessionsListResult {
const { cfg, storePath, store, opts } = params;
const now = Date.now();
const includeGlobal = opts.includeGlobal === true;
const includeUnknown = opts.includeUnknown === true;
const includeDerivedTitles = opts.includeDerivedTitles === true;
const includeLastMessage = opts.includeLastMessage === true;
const spawnedBy = typeof opts.spawnedBy === "string" ? opts.spawnedBy : "";
const label = typeof opts.label === "string" ? opts.label.trim() : "";
const agentId = typeof opts.agentId === "string" ? normalizeAgentId(opts.agentId) : "";
const search = typeof opts.search === "string" ? opts.search.trim().toLowerCase() : "";
const activeMinutes =
typeof opts.activeMinutes === "number" && Number.isFinite(opts.activeMinutes)
? Math.max(1, Math.floor(opts.activeMinutes))
: undefined;
let sessions = Object.entries(store)
.filter(([key]) => {
if (!includeGlobal && key === "global") return false;
if (!includeUnknown && key === "unknown") return false;
if (agentId) {
if (key === "global" || key === "unknown") return false;
const parsed = parseAgentSessionKey(key);
if (!parsed) return false;
return normalizeAgentId(parsed.agentId) === agentId;
}
return true;
})
.filter(([key, entry]) => {
if (!spawnedBy) return true;
if (key === "unknown" || key === "global") return false;
return entry?.spawnedBy === spawnedBy;
})
.filter(([, entry]) => {
if (!label) return true;
return entry?.label === label;
})
.map(([key, entry]) => {
const updatedAt = entry?.updatedAt ?? null;
const input = entry?.inputTokens ?? 0;
const output = entry?.outputTokens ?? 0;
const total = entry?.totalTokens ?? input + output;
const parsed = parseGroupKey(key);
const channel = entry?.channel ?? parsed?.channel;
const subject = entry?.subject;
const groupChannel = entry?.groupChannel;
const space = entry?.space;
const id = parsed?.id;
const origin = entry?.origin;
const originLabel = origin?.label;
const displayName =
entry?.displayName ??
(channel
? buildGroupDisplayName({
provider: channel,
subject,
groupChannel,
space,
id,
key,
})
: undefined) ??
entry?.label ??
originLabel;
const deliveryFields = normalizeSessionDeliveryFields(entry);
return {
key,
entry,
kind: classifySessionKey(key, entry),
label: entry?.label,
displayName,
channel,
subject,
groupChannel,
space,
chatType: entry?.chatType,
origin,
updatedAt,
sessionId: entry?.sessionId,
systemSent: entry?.systemSent,
abortedLastRun: entry?.abortedLastRun,
thinkingLevel: entry?.thinkingLevel,
verboseLevel: entry?.verboseLevel,
reasoningLevel: entry?.reasoningLevel,
elevatedLevel: entry?.elevatedLevel,
sendPolicy: entry?.sendPolicy,
inputTokens: entry?.inputTokens,
outputTokens: entry?.outputTokens,
totalTokens: total,
responseUsage: entry?.responseUsage,
modelProvider: entry?.modelProvider,
model: entry?.model,
contextTokens: entry?.contextTokens,
deliveryContext: deliveryFields.deliveryContext,
lastChannel: deliveryFields.lastChannel ?? entry?.lastChannel,
lastTo: deliveryFields.lastTo ?? entry?.lastTo,
lastAccountId: deliveryFields.lastAccountId ?? entry?.lastAccountId,
};
})
.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
if (search) {
sessions = sessions.filter((s) => {
const fields = [s.displayName, s.label, s.subject, s.sessionId, s.key];
return fields.some((f) => typeof f === "string" && f.toLowerCase().includes(search));
});
}
if (activeMinutes !== undefined) {
const cutoff = now - activeMinutes * 60_000;
sessions = sessions.filter((s) => (s.updatedAt ?? 0) >= cutoff);
}
if (typeof opts.limit === "number" && Number.isFinite(opts.limit)) {
const limit = Math.max(1, Math.floor(opts.limit));
sessions = sessions.slice(0, limit);
}
const finalSessions: GatewaySessionRow[] = sessions.map((s) => {
const { entry, ...rest } = s;
let derivedTitle: string | undefined;
let lastMessagePreview: string | undefined;
if (entry?.sessionId) {
if (includeDerivedTitles) {
const firstUserMsg = readFirstUserMessageFromTranscript(
entry.sessionId,
storePath,
entry.sessionFile,
);
derivedTitle = deriveSessionTitle(entry, firstUserMsg);
}
if (includeLastMessage) {
const lastMsg = readLastMessagePreviewFromTranscript(
entry.sessionId,
storePath,
entry.sessionFile,
);
if (lastMsg) lastMessagePreview = lastMsg;
}
}
return { ...rest, derivedTitle, lastMessagePreview } satisfies GatewaySessionRow;
});
return {
ts: now,
path: storePath,
count: finalSessions.length,
defaults: getSessionDefaults(cfg),
sessions: finalSessions,
};
}