import fs from "node:fs"; import os from "node:os"; import path from "node:path"; 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, loadSessionStore, resolveAgentIdFromSessionKey, resolveSessionTranscriptPath, resolveStorePath, type SessionEntry, type SessionScope, } from "../config/sessions.js"; import { DEFAULT_MAIN_KEY, normalizeAgentId, parseAgentSessionKey, } from "../routing/session-key.js"; export type GatewaySessionsDefaults = { model: string | null; contextTokens: number | null; }; export type GatewaySessionRow = { key: string; kind: "direct" | "group" | "global" | "unknown"; displayName?: string; provider?: string; subject?: string; room?: string; space?: string; chatType?: "direct" | "group" | "room"; updatedAt: number | null; sessionId?: string; systemSent?: boolean; abortedLastRun?: boolean; thinkingLevel?: string; verboseLevel?: string; reasoningLevel?: string; elevatedLevel?: string; sendPolicy?: "allow" | "deny"; inputTokens?: number; outputTokens?: number; totalTokens?: number; responseUsage?: "on" | "off"; modelProvider?: string; model?: string; contextTokens?: number; lastProvider?: SessionEntry["lastProvider"]; lastTo?: string; lastAccountId?: string; }; export type GatewayAgentRow = { id: string; name?: string; }; export type SessionsListResult = { ts: number; path: string; count: number; defaults: GatewaySessionsDefaults; sessions: GatewaySessionRow[]; }; export type SessionsPatchResult = { ok: true; path: string; key: string; entry: SessionEntry; }; export function readSessionMessages( sessionId: string, storePath: string | undefined, sessionFile?: string, ): unknown[] { const candidates = resolveSessionTranscriptCandidates( sessionId, storePath, sessionFile, ); const filePath = candidates.find((p) => fs.existsSync(p)); if (!filePath) return []; const lines = fs.readFileSync(filePath, "utf-8").split(/\r?\n/); const messages: unknown[] = []; for (const line of lines) { if (!line.trim()) continue; try { const parsed = JSON.parse(line); if (parsed?.message) { messages.push(parsed.message); } } catch { // ignore bad lines } } return messages; } export function resolveSessionTranscriptCandidates( sessionId: string, storePath: string | undefined, sessionFile?: string, agentId?: string, ): string[] { const candidates: string[] = []; if (sessionFile) candidates.push(sessionFile); if (storePath) { const dir = path.dirname(storePath); candidates.push(path.join(dir, `${sessionId}.jsonl`)); } if (agentId) { candidates.push(resolveSessionTranscriptPath(sessionId, agentId)); } candidates.push( path.join(os.homedir(), ".clawdbot", "sessions", `${sessionId}.jsonl`), ); return candidates; } export function archiveFileOnDisk(filePath: string, reason: string): string { const ts = new Date().toISOString().replaceAll(":", "-"); const archived = `${filePath}.${reason}.${ts}`; fs.renameSync(filePath, archived); return archived; } function jsonUtf8Bytes(value: unknown): number { try { return Buffer.byteLength(JSON.stringify(value), "utf8"); } catch { return Buffer.byteLength(String(value), "utf8"); } } export function capArrayByJsonBytes( items: T[], maxBytes: number, ): { items: T[]; bytes: number } { if (items.length === 0) return { items, bytes: 2 }; const parts = items.map((item) => jsonUtf8Bytes(item)); let bytes = 2 + parts.reduce((a, b) => a + b, 0) + (items.length - 1); let start = 0; while (bytes > maxBytes && start < items.length - 1) { bytes -= parts[start] + 1; start += 1; } const next = start > 0 ? items.slice(start) : items; return { items: next, bytes }; } export function loadSessionEntry(sessionKey: string) { const cfg = loadConfig(); const sessionCfg = cfg.session; const agentId = resolveAgentIdFromSessionKey(sessionKey); const storePath = resolveStorePath(sessionCfg?.store, { agentId }); const store = loadSessionStore(storePath); const parsed = parseAgentSessionKey(sessionKey); const legacyKey = parsed?.rest; const entry = store[sessionKey] ?? (legacyKey ? store[legacyKey] : undefined); return { cfg, storePath, store, entry }; } 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 === "room") return "group"; if ( key.startsWith("group:") || key.includes(":group:") || key.includes(":channel:") ) { return "group"; } return "direct"; } export function parseGroupKey( key: string, ): { provider?: string; kind?: "group" | "channel"; id?: string } | null { const agentParsed = parseAgentSessionKey(key); const rawKey = agentParsed?.rest ?? key; if (rawKey.startsWith("group:")) { const raw = rawKey.slice("group:".length); return raw ? { id: raw } : null; } const parts = rawKey.split(":").filter(Boolean); if (parts.length >= 3) { const [provider, kind, ...rest] = parts; if (kind === "group" || kind === "channel") { const id = rest.join(":"); return { provider, 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 ids = new Set(); const defaultId = normalizeAgentId(cfg.routing?.defaultAgentId); ids.add(defaultId); const agents = cfg.routing?.agents; if (agents && typeof agents === "object") { for (const id of Object.keys(agents)) ids.add(normalizeAgentId(id)); } 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(cfg.routing?.defaultAgentId); const mainKey = (cfg.session?.mainKey ?? DEFAULT_MAIN_KEY).trim() || DEFAULT_MAIN_KEY; const scope = cfg.session?.scope ?? "per-sender"; const configured = cfg.routing?.agents; const configuredById = new Map(); if (configured && typeof configured === "object") { for (const [key, value] of Object.entries(configured)) { if (!value || typeof value !== "object") continue; configuredById.set(normalizeAgentId(key), { name: typeof value.name === "string" && value.name.trim() ? value.name.trim() : undefined, }); } } const agents = listConfiguredAgentIds(cfg).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 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 agentId = resolveAgentIdFromSessionKey(key); const storeConfig = params.cfg.session?.store; const storePath = resolveStorePath(storeConfig, { agentId }); if (key === "global" || key === "unknown") { return { agentId, storePath, canonicalKey: key, storeKeys: [key] }; } const parsed = parseAgentSessionKey(key); if (parsed) { return { agentId, storePath, canonicalKey: key, storeKeys: [key, parsed.rest], }; } if (key.startsWith("subagent:")) { const canonical = canonicalizeSessionKeyForAgent(agentId, key); return { agentId, storePath, canonicalKey: canonical, storeKeys: [canonical, key], }; } const canonical = canonicalizeSessionKeyForAgent(agentId, key); return { agentId, storePath, canonicalKey: canonical, storeKeys: [canonical, key], }; } export function loadCombinedSessionStoreForGateway(cfg: ClawdbotConfig): { storePath: string; store: Record; } { const storeConfig = cfg.session?.store; if (storeConfig && !isStorePathTemplate(storeConfig)) { const storePath = resolveStorePath(storeConfig); const defaultAgentId = normalizeAgentId(cfg.routing?.defaultAgentId); const store = loadSessionStore(storePath); const combined: Record = {}; 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 = {}; 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); combined[canonicalKey] = { ...entry, spawnedBy: canonicalizeSpawnedByForAgent(agentId, entry.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.agent?.contextTokens ?? lookupContextTokens(resolved.model) ?? DEFAULT_CONTEXT_TOKENS; return { 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; 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 spawnedBy = typeof opts.spawnedBy === "string" ? opts.spawnedBy : ""; const agentId = typeof opts.agentId === "string" ? normalizeAgentId(opts.agentId) : ""; 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; }) .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 provider = entry?.provider ?? parsed?.provider; const subject = entry?.subject; const room = entry?.room; const space = entry?.space; const id = parsed?.id; const displayName = entry?.displayName ?? (provider ? buildGroupDisplayName({ provider, subject, room, space, id, key, }) : undefined); return { key, kind: classifySessionKey(key, entry), displayName, provider, subject, room, space, chatType: entry?.chatType, 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, lastProvider: entry?.lastProvider, lastTo: entry?.lastTo, lastAccountId: entry?.lastAccountId, } satisfies GatewaySessionRow; }) .sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)); 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); } return { ts: now, path: storePath, count: sessions.length, defaults: getSessionDefaults(cfg), sessions, }; }