Keep the existing serial sessions.list behavior by default while allowing transcript usage fallbacks to run with bounded parallelism when explicitly configured.
1531 lines
46 KiB
TypeScript
1531 lines
46 KiB
TypeScript
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<string, SessionEntry>,
|
|
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, SessionEntry>,
|
|
): 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<GatewayTranscriptUsageFallback | 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 = 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<string, SessionEntry>,
|
|
...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<string, unknown>,
|
|
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<string, unknown>;
|
|
canonicalKey: string;
|
|
candidates: Iterable<string>;
|
|
}) {
|
|
const keysToDelete = new Set<string>();
|
|
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<typeof loadConfig>;
|
|
key: string;
|
|
store: Record<string, SessionEntry>;
|
|
}) {
|
|
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<string>();
|
|
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<string>();
|
|
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<string, SessionStoreTarget>();
|
|
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<string, SessionEntry>;
|
|
}): {
|
|
storePath: string;
|
|
store: Record<string, SessionEntry>;
|
|
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<string, SessionEntry>;
|
|
}): {
|
|
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<string>();
|
|
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<string, SessionEntry>;
|
|
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<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);
|
|
mergeSessionEntryIntoCombined({
|
|
cfg,
|
|
combined,
|
|
entry,
|
|
agentId: defaultAgentId,
|
|
canonicalKey,
|
|
});
|
|
}
|
|
return { storePath, store: combined };
|
|
}
|
|
|
|
const targets = resolveAllAgentSessionStoreTargetsSync(cfg);
|
|
const combined: Record<string, SessionEntry> = {};
|
|
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<SessionEntry, "model" | "modelProvider" | "modelOverride" | "providerOverride">,
|
|
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<SessionEntry, "model" | "modelProvider" | "modelOverride" | "providerOverride">,
|
|
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<string, SessionEntry>;
|
|
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<string, SessionEntry>;
|
|
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<string, SessionEntry>;
|
|
opts: SessionsListParams;
|
|
}): Promise<SessionsListResult> {
|
|
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<string, GatewayTranscriptUsageFallback | null>();
|
|
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,
|
|
};
|
|
}
|