import fs from "node:fs"; import path from "node:path"; import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; import { lookupContextTokens, resolveContextTokensForModel } from "../agents/context.js"; import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js"; import { inferUniqueProviderFromConfiguredModels, parseModelRef, resolveConfiguredModelRef, resolveDefaultModelForAgent, } from "../agents/model-selection.js"; import { getSubagentRunByChildSessionKey, getSubagentSessionRuntimeMs, getSubagentSessionStartedAt, listSubagentRunsForController, resolveSubagentSessionStatus, } from "../agents/subagent-registry.js"; import { type OpenClawConfig, loadConfig } from "../config/config.js"; import { resolveStateDir } from "../config/paths.js"; import { buildGroupDisplayName, canonicalizeMainSessionAlias, loadSessionStore, resolveAllAgentSessionStoreTargetsSync, resolveAgentMainSessionKey, resolveFreshSessionTotalTokens, resolveMainSessionKey, resolveStorePath, type SessionEntry, type SessionStoreTarget, type SessionScope, } from "../config/sessions.js"; import { openBoundaryFileSync } from "../infra/boundary-file-read.js"; import { normalizeAgentId, normalizeMainKey, parseAgentSessionKey, } from "../routing/session-key.js"; import { isCronRunSessionKey } from "../sessions/session-key-utils.js"; import { AVATAR_MAX_BYTES, isAvatarDataUrl, isAvatarHttpUrl, isPathWithinRoot, isWorkspaceRelativeAvatarPath, resolveAvatarMime, } from "../shared/avatar-policy.js"; import { normalizeSessionDeliveryFields } from "../utils/delivery-context.js"; import { runTasksWithConcurrency } from "../utils/run-with-concurrency.js"; import { estimateUsageCost, resolveModelCostConfig } from "../utils/usage-format.js"; import { readLatestSessionUsageFromTranscript, readLatestSessionUsageFromTranscriptAsync, readSessionTitleFieldsFromTranscript, type SessionTranscriptUsageSnapshot, } from "./session-utils.fs.js"; import type { SessionsListParams } from "./protocol/index.js"; import type { GatewayAgentRow, GatewaySessionRow, GatewaySessionsDefaults, SessionsListResult, } from "./session-utils.types.js"; export { archiveFileOnDisk, archiveSessionTranscripts, attachOpenClawTranscriptMeta, capArrayByJsonBytes, readFirstUserMessageFromTranscript, readLastMessagePreviewFromTranscript, readLatestSessionUsageFromTranscript, readSessionTitleFieldsFromTranscript, readSessionPreviewItemsFromTranscript, readSessionMessages, resolveSessionTranscriptCandidates, } from "./session-utils.fs.js"; export type { GatewayAgentRow, GatewaySessionRow, GatewaySessionsDefaults, SessionsListResult, SessionsPatchResult, SessionsPreviewEntry, SessionsPreviewResult, } from "./session-utils.types.js"; const DERIVED_TITLE_MAX_LEN = 60; const DEFAULT_SESSIONS_LIST_FALLBACK_CONCURRENCY = 1; const MAX_SESSIONS_LIST_FALLBACK_CONCURRENCY = 16; type GatewayTranscriptUsageFallback = { estimatedCostUsd?: number; totalTokens?: number; totalTokensFresh?: boolean; contextTokens?: number; }; type SessionsListNormalizedOptions = { now: number; includeGlobal: boolean; includeUnknown: boolean; includeDerivedTitles: boolean; includeLastMessage: boolean; spawnedBy: string; label: string; agentId: string; search: string; activeMinutes?: number; limit?: number; }; function tryResolveExistingPath(value: string): string | null { try { return fs.realpathSync(value); } catch { return null; } } function resolveIdentityAvatarUrl( cfg: OpenClawConfig, agentId: string, avatar: string | undefined, ): string | undefined { if (!avatar) { return undefined; } const trimmed = avatar.trim(); if (!trimmed) { return undefined; } if (isAvatarDataUrl(trimmed) || isAvatarHttpUrl(trimmed)) { return trimmed; } if (!isWorkspaceRelativeAvatarPath(trimmed)) { return undefined; } const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId); const workspaceRoot = tryResolveExistingPath(workspaceDir) ?? path.resolve(workspaceDir); const resolvedCandidate = path.resolve(workspaceRoot, trimmed); if (!isPathWithinRoot(workspaceRoot, resolvedCandidate)) { return undefined; } try { const opened = openBoundaryFileSync({ absolutePath: resolvedCandidate, rootPath: workspaceRoot, rootRealPath: workspaceRoot, boundaryLabel: "workspace root", maxBytes: AVATAR_MAX_BYTES, skipLexicalRootCheck: true, }); if (!opened.ok) { return undefined; } try { const buffer = fs.readFileSync(opened.fd); const mime = resolveAvatarMime(resolvedCandidate); return `data:${mime};base64,${buffer.toString("base64")}`; } finally { fs.closeSync(opened.fd); } } catch { return undefined; } } 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()) { const normalized = firstUserMessage.replace(/\s+/g, " ").trim(); return truncateTitle(normalized, DERIVED_TITLE_MAX_LEN); } if (entry.sessionId) { return formatSessionIdPrefix(entry.sessionId, entry.updatedAt); } return undefined; } function resolveSessionRuntimeMs( run: { startedAt?: number; endedAt?: number; accumulatedRuntimeMs?: number } | null, now: number, ) { return getSubagentSessionRuntimeMs(run, now); } function resolvePositiveNumber(value: number | null | undefined): number | undefined { return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined; } function resolveNonNegativeNumber(value: number | null | undefined): number | undefined { return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined; } function resolveEstimatedSessionCostUsd(params: { cfg: OpenClawConfig; provider?: string; model?: string; entry?: Pick< SessionEntry, "estimatedCostUsd" | "inputTokens" | "outputTokens" | "cacheRead" | "cacheWrite" >; explicitCostUsd?: number; }): number | undefined { const explicitCostUsd = resolveNonNegativeNumber( params.explicitCostUsd ?? params.entry?.estimatedCostUsd, ); if (explicitCostUsd !== undefined) { return explicitCostUsd; } const input = resolvePositiveNumber(params.entry?.inputTokens); const output = resolvePositiveNumber(params.entry?.outputTokens); const cacheRead = resolvePositiveNumber(params.entry?.cacheRead); const cacheWrite = resolvePositiveNumber(params.entry?.cacheWrite); if ( input === undefined && output === undefined && cacheRead === undefined && cacheWrite === undefined ) { return undefined; } const cost = resolveModelCostConfig({ provider: params.provider, model: params.model, config: params.cfg, }); if (!cost) { return undefined; } const estimated = estimateUsageCost({ usage: { ...(input !== undefined ? { input } : {}), ...(output !== undefined ? { output } : {}), ...(cacheRead !== undefined ? { cacheRead } : {}), ...(cacheWrite !== undefined ? { cacheWrite } : {}), }, cost, }); return resolveNonNegativeNumber(estimated); } function resolveSessionsListFallbackConcurrency(cfg: OpenClawConfig): number { const raw = cfg.gateway?.sessionsList?.fallbackConcurrency; if (typeof raw !== "number" || !Number.isFinite(raw)) { return DEFAULT_SESSIONS_LIST_FALLBACK_CONCURRENCY; } return Math.max( DEFAULT_SESSIONS_LIST_FALLBACK_CONCURRENCY, Math.min(MAX_SESSIONS_LIST_FALLBACK_CONCURRENCY, Math.floor(raw)), ); } function normalizeSessionsListOptions( opts: SessionsListParams, now = Date.now(), ): SessionsListNormalizedOptions { return { now, includeGlobal: opts.includeGlobal === true, includeUnknown: opts.includeUnknown === true, includeDerivedTitles: opts.includeDerivedTitles === true, includeLastMessage: opts.includeLastMessage === true, spawnedBy: typeof opts.spawnedBy === "string" ? opts.spawnedBy : "", label: typeof opts.label === "string" ? opts.label.trim() : "", agentId: typeof opts.agentId === "string" ? normalizeAgentId(opts.agentId) : "", search: typeof opts.search === "string" ? opts.search.trim().toLowerCase() : "", activeMinutes: typeof opts.activeMinutes === "number" && Number.isFinite(opts.activeMinutes) ? Math.max(1, Math.floor(opts.activeMinutes)) : undefined, limit: typeof opts.limit === "number" && Number.isFinite(opts.limit) ? Math.max(1, Math.floor(opts.limit)) : undefined, }; } function filterSessionListEntries( store: Record, opts: SessionsListNormalizedOptions, ): Array<[string, SessionEntry]> { return Object.entries(store) .filter(([key]) => { if (isCronRunSessionKey(key)) { return false; } if (!opts.includeGlobal && key === "global") { return false; } if (!opts.includeUnknown && key === "unknown") { return false; } if (opts.agentId) { if (key === "global" || key === "unknown") { return false; } const parsed = parseAgentSessionKey(key); if (!parsed) { return false; } return normalizeAgentId(parsed.agentId) === opts.agentId; } return true; }) .filter(([key, entry]) => { if (!opts.spawnedBy) { return true; } if (key === "unknown" || key === "global") { return false; } return entry?.spawnedBy === opts.spawnedBy; }) .filter(([, entry]) => { if (!opts.label) { return true; } return entry?.label === opts.label; }); } function finalizeSessionListRows( sessions: GatewaySessionRow[], opts: SessionsListNormalizedOptions, ): GatewaySessionRow[] { let filtered = sessions.toSorted((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)); if (opts.search) { filtered = filtered.filter((session) => { const fields = [ session.displayName, session.label, session.subject, session.sessionId, session.key, ]; return fields.some( (field) => typeof field === "string" && field.toLowerCase().includes(opts.search), ); }); } if (opts.activeMinutes !== undefined) { const cutoff = opts.now - opts.activeMinutes * 60_000; filtered = filtered.filter((session) => (session.updatedAt ?? 0) >= cutoff); } if (opts.limit !== undefined) { filtered = filtered.slice(0, opts.limit); } return filtered; } function resolveChildSessionKeys( controllerSessionKey: string, store: Record, ): string[] | undefined { const childSessionKeys = new Set( listSubagentRunsForController(controllerSessionKey) .map((entry) => entry.childSessionKey) .filter((value) => typeof value === "string" && value.trim().length > 0), ); for (const [key, entry] of Object.entries(store)) { if (!entry || key === controllerSessionKey) { continue; } const spawnedBy = entry.spawnedBy?.trim(); const parentSessionKey = entry.parentSessionKey?.trim(); if (spawnedBy === controllerSessionKey || parentSessionKey === controllerSessionKey) { childSessionKeys.add(key); } } const childSessions = Array.from(childSessionKeys); return childSessions.length > 0 ? childSessions : undefined; } function resolveSessionEntryModelIdentity(params: { cfg: OpenClawConfig; key: string; entry?: SessionEntry; }): { provider?: string; model: string } { const sessionAgentId = normalizeAgentId( parseAgentSessionKey(params.key)?.agentId ?? resolveDefaultAgentId(params.cfg), ); const resolved = resolveSessionModelIdentityRef(params.cfg, params.entry, sessionAgentId); return { provider: resolved.provider, model: resolved.model ?? DEFAULT_MODEL, }; } function shouldResolveTranscriptUsageFallback(params: { cfg: OpenClawConfig; entry?: SessionEntry; fallbackProvider?: string; fallbackModel?: string; }): boolean { return ( resolvePositiveNumber(resolveFreshSessionTotalTokens(params.entry)) === undefined || resolvePositiveNumber(params.entry?.contextTokens) === undefined || resolveEstimatedSessionCostUsd({ cfg: params.cfg, provider: params.fallbackProvider, model: params.fallbackModel, entry: params.entry, }) === undefined ); } function buildTranscriptUsageFallbackFromSnapshot(params: { cfg: OpenClawConfig; snapshot: SessionTranscriptUsageSnapshot; fallbackProvider?: string; fallbackModel?: string; }): GatewayTranscriptUsageFallback { const modelProvider = params.snapshot.modelProvider ?? params.fallbackProvider; const model = params.snapshot.model ?? params.fallbackModel; const contextTokens = resolveContextTokensForModel({ cfg: params.cfg, provider: modelProvider, model, }); const estimatedCostUsd = resolveEstimatedSessionCostUsd({ cfg: params.cfg, provider: modelProvider, model, explicitCostUsd: params.snapshot.costUsd, entry: { inputTokens: params.snapshot.inputTokens, outputTokens: params.snapshot.outputTokens, cacheRead: params.snapshot.cacheRead, cacheWrite: params.snapshot.cacheWrite, }, }); return { totalTokens: resolvePositiveNumber(params.snapshot.totalTokens), totalTokensFresh: params.snapshot.totalTokensFresh === true, contextTokens: resolvePositiveNumber(contextTokens), estimatedCostUsd, }; } function resolveTranscriptUsageFallback(params: { cfg: OpenClawConfig; key: string; entry?: SessionEntry; storePath: string; fallbackProvider?: string; fallbackModel?: string; }): { estimatedCostUsd?: number; totalTokens?: number; totalTokensFresh?: boolean; contextTokens?: number; } | null { const entry = params.entry; if (!entry?.sessionId) { return null; } const parsed = parseAgentSessionKey(params.key); const agentId = parsed?.agentId ? normalizeAgentId(parsed.agentId) : resolveDefaultAgentId(params.cfg); const snapshot = readLatestSessionUsageFromTranscript( entry.sessionId, params.storePath, entry.sessionFile, agentId, ); if (!snapshot) { return null; } return buildTranscriptUsageFallbackFromSnapshot({ cfg: params.cfg, snapshot, fallbackProvider: params.fallbackProvider, fallbackModel: params.fallbackModel, }); } async function resolveTranscriptUsageFallbackAsync(params: { cfg: OpenClawConfig; key: string; entry?: SessionEntry; storePath: string; fallbackProvider?: string; fallbackModel?: string; }): Promise { const entry = params.entry; if (!entry?.sessionId) { return null; } const parsed = parseAgentSessionKey(params.key); const agentId = parsed?.agentId ? normalizeAgentId(parsed.agentId) : resolveDefaultAgentId(params.cfg); const snapshot = await readLatestSessionUsageFromTranscriptAsync( entry.sessionId, params.storePath, entry.sessionFile, agentId, ); if (!snapshot) { return null; } return buildTranscriptUsageFallbackFromSnapshot({ cfg: params.cfg, snapshot, fallbackProvider: params.fallbackProvider, fallbackModel: params.fallbackModel, }); } export function loadSessionEntry(sessionKey: string) { const cfg = loadConfig(); const canonicalKey = resolveSessionStoreKey({ cfg, sessionKey }); const agentId = resolveSessionStoreAgentId(cfg, canonicalKey); const { storePath, store, match } = resolveGatewaySessionStoreLookup({ cfg, key: sessionKey.trim(), canonicalKey, agentId, }); const legacyKey = match?.key !== canonicalKey ? match?.key : undefined; return { cfg, storePath, store, entry: match?.entry, canonicalKey, legacyKey }; } /** * Find a session entry by exact or case-insensitive key match. * Returns both the entry and the actual store key it was found under, * so callers can clean up legacy mixed-case keys when they differ from canonicalKey. */ function findStoreMatch( store: Record, ...candidates: string[] ): { entry: SessionEntry; key: string } | undefined { // Exact match first. for (const candidate of candidates) { if (candidate && store[candidate]) { return { entry: store[candidate], key: candidate }; } } // Case-insensitive scan for ALL candidates. const loweredSet = new Set(candidates.filter(Boolean).map((c) => c.toLowerCase())); for (const key of Object.keys(store)) { if (loweredSet.has(key.toLowerCase())) { return { entry: store[key], key }; } } return undefined; } /** * Find all on-disk store keys that match the given key case-insensitively. * Returns every key from the store whose lowercased form equals the target's lowercased form. */ export function findStoreKeysIgnoreCase( store: Record, targetKey: string, ): string[] { const lowered = targetKey.toLowerCase(); const matches: string[] = []; for (const key of Object.keys(store)) { if (key.toLowerCase() === lowered) { matches.push(key); } } return matches; } /** * Remove legacy key variants for one canonical session key. * Candidates can include aliases (for example, "agent:ops:main" when canonical is "agent:ops:work"). */ export function pruneLegacyStoreKeys(params: { store: Record; canonicalKey: string; candidates: Iterable; }) { const keysToDelete = new Set(); for (const candidate of params.candidates) { const trimmed = String(candidate ?? "").trim(); if (!trimmed) { continue; } if (trimmed !== params.canonicalKey) { keysToDelete.add(trimmed); } for (const match of findStoreKeysIgnoreCase(params.store, trimmed)) { if (match !== params.canonicalKey) { keysToDelete.add(match); } } } for (const key of keysToDelete) { delete params.store[key]; } } export function migrateAndPruneGatewaySessionStoreKey(params: { cfg: ReturnType; key: string; store: Record; }) { const target = resolveGatewaySessionStoreTarget({ cfg: params.cfg, key: params.key, store: params.store, }); const primaryKey = target.canonicalKey; if (!params.store[primaryKey]) { const existingKey = target.storeKeys.find((candidate) => Boolean(params.store[candidate])); if (existingKey) { params.store[primaryKey] = params.store[existingKey]; } } pruneLegacyStoreKeys({ store: params.store, canonicalKey: primaryKey, candidates: target.storeKeys, }); return { target, primaryKey, entry: params.store[primaryKey] }; } 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: OpenClawConfig): string[] { const ids = new Set(); const defaultId = normalizeAgentId(resolveDefaultAgentId(cfg)); ids.add(defaultId); for (const entry of cfg.agents?.list ?? []) { if (entry?.id) { ids.add(normalizeAgentId(entry.id)); } } for (const id of listExistingAgentIdsFromDisk()) { ids.add(id); } 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; } export function listAgentsForGateway(cfg: OpenClawConfig): { 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; identity?: GatewayAgentRow["identity"] } >(); for (const entry of cfg.agents?.list ?? []) { if (!entry?.id) { continue; } const identity = entry.identity ? { name: entry.identity.name?.trim() || undefined, theme: entry.identity.theme?.trim() || undefined, emoji: entry.identity.emoji?.trim() || undefined, avatar: entry.identity.avatar?.trim() || undefined, avatarUrl: resolveIdentityAvatarUrl( cfg, normalizeAgentId(entry.id), entry.identity.avatar?.trim(), ), } : undefined; configuredById.set(normalizeAgentId(entry.id), { name: typeof entry.name === "string" && entry.name.trim() ? entry.name.trim() : undefined, identity, }); } 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) && (!allowedIds || allowedIds.has(mainKey))) { agentIds = [...agentIds, mainKey]; } const agents = agentIds.map((id) => { const meta = configuredById.get(id); return { id, name: meta?.name, identity: meta?.identity, }; }); return { defaultId, mainKey, scope, agents }; } function canonicalizeSessionKeyForAgent(agentId: string, key: string): string { const lowered = key.toLowerCase(); if (lowered === "global" || lowered === "unknown") { return lowered; } if (lowered.startsWith("agent:")) { return lowered; } return `agent:${normalizeAgentId(agentId)}:${lowered}`; } function resolveDefaultStoreAgentId(cfg: OpenClawConfig): string { return normalizeAgentId(resolveDefaultAgentId(cfg)); } export function resolveSessionStoreKey(params: { cfg: OpenClawConfig; sessionKey: string; }): string { const raw = (params.sessionKey ?? "").trim(); if (!raw) { return raw; } const rawLower = raw.toLowerCase(); if (rawLower === "global" || rawLower === "unknown") { return rawLower; } const parsed = parseAgentSessionKey(raw); if (parsed) { const agentId = normalizeAgentId(parsed.agentId); const lowered = raw.toLowerCase(); const canonical = canonicalizeMainSessionAlias({ cfg: params.cfg, agentId, sessionKey: lowered, }); if (canonical !== lowered) { return canonical; } return lowered; } const lowered = raw.toLowerCase(); const rawMainKey = normalizeMainKey(params.cfg.session?.mainKey); if (lowered === "main" || lowered === rawMainKey) { return resolveMainSessionKey(params.cfg); } const agentId = resolveDefaultStoreAgentId(params.cfg); return canonicalizeSessionKeyForAgent(agentId, lowered); } function resolveSessionStoreAgentId(cfg: OpenClawConfig, 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); } export function canonicalizeSpawnedByForAgent( cfg: OpenClawConfig, agentId: string, spawnedBy?: string, ): string | undefined { const raw = spawnedBy?.trim(); if (!raw) { return undefined; } const lower = raw.toLowerCase(); if (lower === "global" || lower === "unknown") { return lower; } let result: string; if (raw.toLowerCase().startsWith("agent:")) { result = raw.toLowerCase(); } else { result = `agent:${normalizeAgentId(agentId)}:${lower}`; } // Resolve main-alias references (e.g. agent:ops:main → configured main key). const parsed = parseAgentSessionKey(result); const resolvedAgent = parsed?.agentId ? normalizeAgentId(parsed.agentId) : agentId; return canonicalizeMainSessionAlias({ cfg, agentId: resolvedAgent, sessionKey: result }); } function buildGatewaySessionStoreScanTargets(params: { cfg: OpenClawConfig; key: string; canonicalKey: string; agentId: string; }): string[] { const targets = new Set(); if (params.canonicalKey) { targets.add(params.canonicalKey); } if (params.key && params.key !== params.canonicalKey) { targets.add(params.key); } if (params.canonicalKey === "global" || params.canonicalKey === "unknown") { return [...targets]; } const agentMainKey = resolveAgentMainSessionKey({ cfg: params.cfg, agentId: params.agentId }); if (params.canonicalKey === agentMainKey) { targets.add(`agent:${params.agentId}:main`); } return [...targets]; } function resolveGatewaySessionStoreCandidates( cfg: OpenClawConfig, agentId: string, ): SessionStoreTarget[] { const storeConfig = cfg.session?.store; const defaultTarget = { agentId, storePath: resolveStorePath(storeConfig, { agentId }), }; if (!isStorePathTemplate(storeConfig)) { return [defaultTarget]; } const targets = new Map(); targets.set(defaultTarget.storePath, defaultTarget); for (const target of resolveAllAgentSessionStoreTargetsSync(cfg)) { if (target.agentId === agentId) { targets.set(target.storePath, target); } } return [...targets.values()]; } function resolveGatewaySessionStoreLookup(params: { cfg: OpenClawConfig; key: string; canonicalKey: string; agentId: string; initialStore?: Record; }): { storePath: string; store: Record; match: { entry: SessionEntry; key: string } | undefined; } { const scanTargets = buildGatewaySessionStoreScanTargets(params); const candidates = resolveGatewaySessionStoreCandidates(params.cfg, params.agentId); const fallback = candidates[0] ?? { agentId: params.agentId, storePath: resolveStorePath(params.cfg.session?.store, { agentId: params.agentId }), }; let selectedStorePath = fallback.storePath; let selectedStore = params.initialStore ?? loadSessionStore(fallback.storePath); let selectedMatch = findStoreMatch(selectedStore, ...scanTargets); let selectedUpdatedAt = selectedMatch?.entry.updatedAt ?? Number.NEGATIVE_INFINITY; for (let index = 1; index < candidates.length; index += 1) { const candidate = candidates[index]; if (!candidate) { continue; } const store = loadSessionStore(candidate.storePath); const match = findStoreMatch(store, ...scanTargets); if (!match) { continue; } const updatedAt = match.entry.updatedAt ?? 0; // Mirror combined-store merge behavior so follow-up mutations target the // same backing store that won the listing merge when ids collide. if (!selectedMatch || updatedAt >= selectedUpdatedAt) { selectedStorePath = candidate.storePath; selectedStore = store; selectedMatch = match; selectedUpdatedAt = updatedAt; } } return { storePath: selectedStorePath, store: selectedStore, match: selectedMatch, }; } export function resolveGatewaySessionStoreTarget(params: { cfg: OpenClawConfig; key: string; scanLegacyKeys?: boolean; store?: Record; }): { 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 { storePath, store } = resolveGatewaySessionStoreLookup({ cfg: params.cfg, key, canonicalKey, agentId, initialStore: params.store, }); if (canonicalKey === "global" || canonicalKey === "unknown") { const storeKeys = key && key !== canonicalKey ? [canonicalKey, key] : [key]; return { agentId, storePath, canonicalKey, storeKeys }; } const storeKeys = new Set(); storeKeys.add(canonicalKey); if (key && key !== canonicalKey) { storeKeys.add(key); } if (params.scanLegacyKeys !== false) { // Scan the on-disk store for case variants of every target to find // legacy mixed-case entries (e.g. "agent:ops:MAIN" when canonical is "agent:ops:work"). const scanTargets = buildGatewaySessionStoreScanTargets({ cfg: params.cfg, key, canonicalKey, agentId, }); for (const seed of scanTargets) { for (const legacyKey of findStoreKeysIgnoreCase(store, seed)) { storeKeys.add(legacyKey); } } } return { agentId, storePath, canonicalKey, storeKeys: Array.from(storeKeys), }; } // Merge with existing entry based on latest timestamp to ensure data consistency and avoid overwriting with less complete data. function mergeSessionEntryIntoCombined(params: { cfg: OpenClawConfig; combined: Record; entry: SessionEntry; agentId: string; canonicalKey: string; }) { const { cfg, combined, entry, agentId, canonicalKey } = params; const existing = combined[canonicalKey]; if (existing && (existing.updatedAt ?? 0) > (entry.updatedAt ?? 0)) { combined[canonicalKey] = { ...entry, ...existing, spawnedBy: canonicalizeSpawnedByForAgent(cfg, agentId, existing.spawnedBy ?? entry.spawnedBy), }; } else { combined[canonicalKey] = { ...existing, ...entry, spawnedBy: canonicalizeSpawnedByForAgent( cfg, agentId, entry.spawnedBy ?? existing?.spawnedBy, ), }; } } export function loadCombinedSessionStoreForGateway(cfg: OpenClawConfig): { storePath: string; store: Record; } { 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 = {}; for (const [key, entry] of Object.entries(store)) { const canonicalKey = canonicalizeSessionKeyForAgent(defaultAgentId, key); mergeSessionEntryIntoCombined({ cfg, combined, entry, agentId: defaultAgentId, canonicalKey, }); } return { storePath, store: combined }; } const targets = resolveAllAgentSessionStoreTargetsSync(cfg); const combined: Record = {}; for (const target of targets) { const agentId = target.agentId; const storePath = target.storePath; const store = loadSessionStore(storePath); for (const [key, entry] of Object.entries(store)) { const canonicalKey = canonicalizeSessionKeyForAgent(agentId, key); mergeSessionEntryIntoCombined({ cfg, combined, entry, agentId, canonicalKey, }); } } const storePath = typeof storeConfig === "string" && storeConfig.trim() ? storeConfig.trim() : "(multiple)"; return { storePath, store: combined }; } export function getSessionDefaults(cfg: OpenClawConfig): 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: OpenClawConfig, entry?: | SessionEntry | Pick, agentId?: string, ): { provider: string; model: string } { const resolved = agentId ? resolveDefaultModelForAgent({ cfg, agentId }) : resolveConfiguredModelRef({ cfg, defaultProvider: DEFAULT_PROVIDER, defaultModel: DEFAULT_MODEL, }); // Prefer the last runtime model recorded on the session entry. // This is the actual model used by the latest run and must win over defaults. let provider = resolved.provider; let model = resolved.model; const runtimeModel = entry?.model?.trim(); const runtimeProvider = entry?.modelProvider?.trim(); if (runtimeModel) { if (runtimeProvider) { // Provider is explicitly recorded — use it directly. Re-parsing the // model string through parseModelRef would incorrectly split OpenRouter // vendor-prefixed model names (e.g. model="anthropic/claude-haiku-4.5" // with provider="openrouter") into { provider: "anthropic" }, discarding // the stored OpenRouter provider and causing direct API calls to a // provider the user has no credentials for. return { provider: runtimeProvider, model: runtimeModel }; } const parsedRuntime = parseModelRef(runtimeModel, provider || DEFAULT_PROVIDER); if (parsedRuntime) { provider = parsedRuntime.provider; model = parsedRuntime.model; } else { model = runtimeModel; } return { provider, model }; } // Fall back to explicit per-session override (set at spawn/model-patch time), // then finally to configured defaults. const storedModelOverride = entry?.modelOverride?.trim(); if (storedModelOverride) { const overrideProvider = entry?.providerOverride?.trim() || provider || DEFAULT_PROVIDER; const parsedOverride = parseModelRef(storedModelOverride, overrideProvider); if (parsedOverride) { provider = parsedOverride.provider; model = parsedOverride.model; } else { provider = overrideProvider; model = storedModelOverride; } } return { provider, model }; } export function resolveSessionModelIdentityRef( cfg: OpenClawConfig, entry?: | SessionEntry | Pick, agentId?: string, fallbackModelRef?: string, ): { provider?: string; model: string } { const runtimeModel = entry?.model?.trim(); const runtimeProvider = entry?.modelProvider?.trim(); if (runtimeModel) { if (runtimeProvider) { return { provider: runtimeProvider, model: runtimeModel }; } const inferredProvider = inferUniqueProviderFromConfiguredModels({ cfg, model: runtimeModel, }); if (inferredProvider) { return { provider: inferredProvider, model: runtimeModel }; } if (runtimeModel.includes("/")) { const parsedRuntime = parseModelRef(runtimeModel, DEFAULT_PROVIDER); if (parsedRuntime) { return { provider: parsedRuntime.provider, model: parsedRuntime.model }; } return { model: runtimeModel }; } return { model: runtimeModel }; } const fallbackRef = fallbackModelRef?.trim(); if (fallbackRef) { const parsedFallback = parseModelRef(fallbackRef, DEFAULT_PROVIDER); if (parsedFallback) { return { provider: parsedFallback.provider, model: parsedFallback.model }; } const inferredProvider = inferUniqueProviderFromConfiguredModels({ cfg, model: fallbackRef, }); if (inferredProvider) { return { provider: inferredProvider, model: fallbackRef }; } return { model: fallbackRef }; } const resolved = resolveSessionModelRef(cfg, entry, agentId); return { provider: resolved.provider, model: resolved.model }; } export function buildGatewaySessionRow(params: { cfg: OpenClawConfig; storePath: string; store: Record; key: string; entry?: SessionEntry; now?: number; includeDerivedTitles?: boolean; includeLastMessage?: boolean; transcriptUsage?: GatewayTranscriptUsageFallback | null; skipTranscriptUsageFallback?: boolean; }): GatewaySessionRow { const { cfg, storePath, store, key, entry } = params; const now = params.now ?? Date.now(); const updatedAt = entry?.updatedAt ?? null; 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); const parsedAgent = parseAgentSessionKey(key); const sessionAgentId = normalizeAgentId(parsedAgent?.agentId ?? resolveDefaultAgentId(cfg)); const subagentRun = getSubagentRunByChildSessionKey(key); const subagentStatus = subagentRun ? resolveSubagentSessionStatus(subagentRun) : undefined; const subagentStartedAt = subagentRun ? getSubagentSessionStartedAt(subagentRun) : undefined; const subagentEndedAt = subagentRun ? subagentRun.endedAt : undefined; const subagentRuntimeMs = subagentRun ? resolveSessionRuntimeMs(subagentRun, now) : undefined; const resolvedModel = resolveSessionModelIdentityRef( cfg, entry, sessionAgentId, subagentRun?.model, ); const modelProvider = resolvedModel.provider; const model = resolvedModel.model ?? DEFAULT_MODEL; const transcriptUsage = params.skipTranscriptUsageFallback ? (params.transcriptUsage ?? null) : shouldResolveTranscriptUsageFallback({ cfg, entry, fallbackProvider: modelProvider, fallbackModel: model, }) ? resolveTranscriptUsageFallback({ cfg, key, entry, storePath, fallbackProvider: modelProvider, fallbackModel: model, }) : null; const totalTokens = resolvePositiveNumber(resolveFreshSessionTotalTokens(entry)) ?? resolvePositiveNumber(transcriptUsage?.totalTokens); const totalTokensFresh = typeof totalTokens === "number" && Number.isFinite(totalTokens) && totalTokens > 0 ? true : transcriptUsage?.totalTokensFresh === true; const childSessions = resolveChildSessionKeys(key, store); const estimatedCostUsd = resolveEstimatedSessionCostUsd({ cfg, provider: modelProvider, model, entry, }) ?? resolveNonNegativeNumber(transcriptUsage?.estimatedCostUsd); const contextTokens = resolvePositiveNumber(entry?.contextTokens) ?? resolvePositiveNumber(transcriptUsage?.contextTokens) ?? resolvePositiveNumber( resolveContextTokensForModel({ cfg, provider: modelProvider, model, }), ); let derivedTitle: string | undefined; let lastMessagePreview: string | undefined; if (entry?.sessionId && (params.includeDerivedTitles || params.includeLastMessage)) { const fields = readSessionTitleFieldsFromTranscript( entry.sessionId, storePath, entry.sessionFile, sessionAgentId, ); if (params.includeDerivedTitles) { derivedTitle = deriveSessionTitle(entry, fields.firstUserMessage); } if (params.includeLastMessage && fields.lastMessagePreview) { lastMessagePreview = fields.lastMessagePreview; } } return { key, spawnedBy: entry?.spawnedBy, kind: classifySessionKey(key, entry), label: entry?.label, displayName, derivedTitle, lastMessagePreview, 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, totalTokensFresh, estimatedCostUsd, status: subagentRun ? subagentStatus : entry?.status, startedAt: subagentRun ? subagentStartedAt : entry?.startedAt, endedAt: subagentRun ? subagentEndedAt : entry?.endedAt, runtimeMs: subagentRun ? subagentRuntimeMs : entry?.runtimeMs, parentSessionKey: entry?.parentSessionKey, childSessions, responseUsage: entry?.responseUsage, modelProvider, model, contextTokens, deliveryContext: deliveryFields.deliveryContext, lastChannel: deliveryFields.lastChannel ?? entry?.lastChannel, lastTo: deliveryFields.lastTo ?? entry?.lastTo, lastAccountId: deliveryFields.lastAccountId ?? entry?.lastAccountId, }; } export function loadGatewaySessionRow( sessionKey: string, options?: { includeDerivedTitles?: boolean; includeLastMessage?: boolean; now?: number }, ): GatewaySessionRow | null { const { cfg, storePath, store, entry, canonicalKey } = loadSessionEntry(sessionKey); if (!entry) { return null; } return buildGatewaySessionRow({ cfg, storePath, store, key: canonicalKey, entry, now: options?.now, includeDerivedTitles: options?.includeDerivedTitles, includeLastMessage: options?.includeLastMessage, }); } export function listSessionsFromStore(params: { cfg: OpenClawConfig; storePath: string; store: Record; opts: SessionsListParams; }): SessionsListResult { const { cfg, storePath, store, opts } = params; const normalizedOpts = normalizeSessionsListOptions(opts); let sessions = filterSessionListEntries(store, normalizedOpts) .map(([key, entry]) => buildGatewaySessionRow({ cfg, storePath, store, key, entry, now: normalizedOpts.now, includeDerivedTitles: normalizedOpts.includeDerivedTitles, includeLastMessage: normalizedOpts.includeLastMessage, }), ); sessions = finalizeSessionListRows(sessions, normalizedOpts); return { ts: normalizedOpts.now, path: storePath, count: sessions.length, defaults: getSessionDefaults(cfg), sessions, }; } export async function listSessionsFromStoreAsync(params: { cfg: OpenClawConfig; storePath: string; store: Record; opts: SessionsListParams; }): Promise { const concurrency = resolveSessionsListFallbackConcurrency(params.cfg); if (concurrency <= DEFAULT_SESSIONS_LIST_FALLBACK_CONCURRENCY) { return listSessionsFromStore(params); } const normalizedOpts = normalizeSessionsListOptions(params.opts); const filteredEntries = filterSessionListEntries(params.store, normalizedOpts); const transcriptUsageByKey = new Map(); const tasks = filteredEntries .filter(([key, entry]) => { const resolvedModel = resolveSessionEntryModelIdentity({ cfg: params.cfg, key, entry, }); return shouldResolveTranscriptUsageFallback({ cfg: params.cfg, entry, fallbackProvider: resolvedModel.provider, fallbackModel: resolvedModel.model, }); }) .map(([key, entry]) => async () => { const resolvedModel = resolveSessionEntryModelIdentity({ cfg: params.cfg, key, entry, }); const usage = await resolveTranscriptUsageFallbackAsync({ cfg: params.cfg, key, entry, storePath: params.storePath, fallbackProvider: resolvedModel.provider, fallbackModel: resolvedModel.model, }); return { key, usage }; }); if (tasks.length > 0) { const { results } = await runTasksWithConcurrency({ tasks, limit: concurrency, errorMode: "continue", }); for (const result of results) { if (result?.key) { transcriptUsageByKey.set(result.key, result.usage ?? null); } } } let sessions = filteredEntries.map(([key, entry]) => buildGatewaySessionRow({ cfg: params.cfg, storePath: params.storePath, store: params.store, key, entry, now: normalizedOpts.now, includeDerivedTitles: normalizedOpts.includeDerivedTitles, includeLastMessage: normalizedOpts.includeLastMessage, transcriptUsage: transcriptUsageByKey.get(key) ?? null, skipTranscriptUsageFallback: transcriptUsageByKey.has(key), }), ); sessions = finalizeSessionListRows(sessions, normalizedOpts); return { ts: normalizedOpts.now, path: params.storePath, count: sessions.length, defaults: getSessionDefaults(params.cfg), sessions, }; }