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 ClawdisConfig, loadConfig } from "../config/config.js"; import { buildGroupDisplayName, loadSessionStore, resolveStorePath, type SessionEntry, } from "../config/sessions.js"; export type GatewaySessionsDefaults = { model: string | null; contextTokens: number | null; }; export type GatewaySessionRow = { key: string; kind: "direct" | "group" | "global" | "unknown"; displayName?: string; surface?: string; subject?: string; room?: string; space?: string; chatType?: "direct" | "group" | "room"; updatedAt: number | null; sessionId?: string; systemSent?: boolean; abortedLastRun?: boolean; thinkingLevel?: string; verboseLevel?: string; sendPolicy?: "allow" | "deny"; inputTokens?: number; outputTokens?: number; totalTokens?: number; model?: string; contextTokens?: number; lastChannel?: SessionEntry["lastChannel"]; lastTo?: 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, ): unknown[] { const candidates = resolveSessionTranscriptCandidates(sessionId, storePath); 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, ): string[] { const candidates: string[] = []; if (storePath) { const dir = path.dirname(storePath); candidates.push(path.join(dir, `${sessionId}.jsonl`)); } candidates.push( path.join(os.homedir(), ".clawdis", "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 storePath = sessionCfg?.store ? resolveStorePath(sessionCfg.store) : resolveStorePath(undefined); const store = loadSessionStore(storePath); const entry = store[sessionKey]; 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, ): { surface?: string; kind?: "group" | "channel"; id?: string } | null { if (key.startsWith("group:")) { const raw = key.slice("group:".length); return raw ? { id: raw } : null; } const parts = key.split(":").filter(Boolean); if (parts.length >= 3) { const [surface, kind, ...rest] = parts; if (kind === "group" || kind === "channel") { const id = rest.join(":"); return { surface, kind, id }; } } return null; } export function getSessionDefaults( cfg: ClawdisConfig, ): 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: ClawdisConfig, 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: ClawdisConfig; 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 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; return true; }) .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 surface = entry?.surface ?? parsed?.surface; const subject = entry?.subject; const room = entry?.room; const space = entry?.space; const id = parsed?.id; const displayName = entry?.displayName ?? (surface ? buildGroupDisplayName({ surface, subject, room, space, id, key, }) : undefined); return { key, kind: classifySessionKey(key, entry), displayName, surface, subject, room, space, chatType: entry?.chatType, updatedAt, sessionId: entry?.sessionId, systemSent: entry?.systemSent, abortedLastRun: entry?.abortedLastRun, thinkingLevel: entry?.thinkingLevel, verboseLevel: entry?.verboseLevel, sendPolicy: entry?.sendPolicy, inputTokens: entry?.inputTokens, outputTokens: entry?.outputTokens, totalTokens: total, model: entry?.model, contextTokens: entry?.contextTokens, lastChannel: entry?.lastChannel, lastTo: entry?.lastTo, } 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, }; }