import { resolve } from 'node:path' import type { Model } from '@/lib/types' import type { OpenClawCronRunEntry } from './cron-view' import { getGateway, withOneShotGateway } from './gateway' import type { GatewayHandle } from './gateway' import { stripUserMessageMetadata } from './strip' import { OPENCLAW_FALLBACK_THINKING_LEVELS, hasOpenClawThinkingProfiles, openClawThinkingProfile, recordOpenClawThinkingProfiles } from './thinking' export type OpenClawAgent = { path: string agentId: string name?: string isDefault: boolean lastRunAt?: string } // Channel-routing metadata on a session row. Deliberately loose: the gateway // ships more fields than these (chatType, accountId, …) and the exact set // varies by channel plugin — we read only what we surface. `provider` is the // channel id ('telegram', 'irc', 'discord', …); moi's own chats come back as // 'webchat'. export type OpenClawSessionOrigin = { provider?: string surface?: string label?: string from?: string to?: string [k: string]: unknown } // Subset of the session row returned by `sessions.list`. The gateway ships // more fields, but these are the ones we map to our SessionInfo/StreamEvent. export type OpenClawSessionRow = { key: string sessionId: string updatedAt: number lastMessagePreview?: string displayName?: string derivedTitle?: string label?: string model?: string modelProvider?: string status?: string origin?: OpenClawSessionOrigin kind?: string // Parent session key for gateway-spawned subagent sessions. spawnedBy?: string // The thinking menu the gateway resolved for this row's model. Per-model, not // global — harvested into `thinking.ts` so the picker offers a valid set. thinkingOptions?: string[] thinkingLevels?: { id: string; label?: string }[] thinkingDefault?: string } // Shape returned by `sessions.get({ key })` — the full transcript, unlike // `sessions.preview` which is capped at ~11 items regardless of maxChars. export type OpenClawContentBlock = | { type: 'text'; text: string; textSignature?: string } | { type: 'thinking'; thinking: string; thinkingSignature?: string } | { type: 'toolCall'; id: string; name: string; arguments: unknown } | { type: string; [k: string]: unknown } export type OpenClawMessage = { role: 'user' | 'assistant' | 'toolResult' | string content: OpenClawContentBlock[] | string timestamp?: number __openclaw?: { id?: string; seq?: number } } export type OpenClawSessionDetail = { messages: OpenClawMessage[] } export type OpenClawSessionPreviewCandidate = { key: string updatedAt: number detail: OpenClawSessionDetail | null } export type OpenClawWorkspacePreview = { firstUserMessage?: string updatedAt?: number } const TIMEOUT_MS = 2000 type AgentsList = { defaultId: string agents: Array<{ id: string; workspace: string }> } type SessionsList = { sessions: Array<{ key: string; updatedAt: number }> } type FileGet = { file?: { content?: string } } function withTimeout(p: Promise, ms: number, label: string): Promise { return new Promise((res, rej) => { const t = setTimeout(() => rej(new Error(`openclaw ${label} timed out after ${ms}ms`)), ms) p.then( v => { clearTimeout(t) res(v) }, e => { clearTimeout(t) rej(e) } ) }) } function parseIdentityName(md: string | undefined): string | undefined { if (!md) return undefined const m = /^-\s*\*\*Name:\*\*\s*(.+)$/m.exec(md) return m ? m[1].trim() : undefined } type Rpc = (method: string, params?: Record) => Promise // Connect, hand the scoped `rpc(method, params)` to `fn`, then stop the client. // Any connect/auth/timeout failure → returns `null` silently (caller's choice); // the failure category still lands in gateway.ts's status for /status lines. async function withGatewayClient(fn: (rpc: Rpc) => Promise): Promise { return withOneShotGateway(fn) } export async function discoverOpenClawAgents(): Promise { const out = await withGatewayClient(async rpc => { const [agents, sessions] = await Promise.all([ rpc('agents.list'), rpc('sessions.list', { includeGlobal: true }) ]) const identities = await Promise.all( agents.agents.map(a => rpc('agents.files.get', { agentId: a.id, name: 'IDENTITY.md' }).catch(() => null) ) ) const lastRun = new Map() for (const s of sessions.sessions) { const m = /^agent:([^:]+):/.exec(s.key) if (!m) continue const cur = lastRun.get(m[1]) ?? 0 if (s.updatedAt > cur) lastRun.set(m[1], s.updatedAt) } return agents.agents.map((a, i) => { const ts = lastRun.get(a.id) return { path: resolve(a.workspace), agentId: a.id, name: parseIdentityName(identities[i]?.file?.content), isDefault: a.id === agents.defaultId, lastRunAt: ts ? new Date(ts).toISOString() : undefined } }) }) return out ?? [] } // Resolve an OpenClaw agentId for a workspace path. Used when the saved // workspace entry was registered before we started capturing agentId. async function resolveAgentIdForPath(rpc: Rpc, path: string): Promise { const agents = await rpc('agents.list') return agents.agents.find(a => resolve(a.workspace) === resolve(path))?.id } export async function getOpenClawSessions( workspacePath: string, agentId?: string ): Promise { const out = await withGatewayClient(async rpc => { const id = agentId ?? (await resolveAgentIdForPath(rpc, workspacePath)) if (!id) return [] const res = await rpc<{ sessions: OpenClawSessionRow[] }>('sessions.list', { agentId: id, includeDerivedTitles: true, includeLastMessage: true }) // Rows are the only carrier of each model's thinking menu (thinking.ts). recordOpenClawThinkingProfiles(res.sessions) return res.sessions }) return out ?? [] } export async function getOpenClawSessionMessages( sessionId: string, workspacePath: string, agentId?: string ): Promise { const out = await withGatewayClient(async rpc => { const id = agentId ?? (await resolveAgentIdForPath(rpc, workspacePath)) if (!id) return null // sessionId → key. `sessions.get` needs the composite key, not sessionId. const resolved = await rpc<{ key?: string }>('sessions.resolve', { sessionId, agentId: id }).catch(() => null) const key = resolved?.key if (!key) return null return await rpc('sessions.get', { key }) }) return out ?? null } // sessionId → gateway session key, or null when the gateway is down or the // session is unknown. Used to recognize cron bucket keys (`…:cron:`) // when the transcript comes back empty. export async function resolveOpenClawSessionKey( sessionId: string, workspacePath: string, agentId?: string ): Promise { const out = await withGatewayClient(async rpc => { const id = agentId ?? (await resolveAgentIdForPath(rpc, workspacePath)) if (!id) return null const resolved = await rpc<{ key?: string }>('sessions.resolve', { sessionId, agentId: id }).catch(() => null) return resolved?.key ?? null }) return out ?? null } // Recorded run history for one cron job (`cron.runs`, identical params and // page shape on both supported lines — verified live on 2026.7.1 and // 2026.6.33). Default sort is newest-first, so a limit keeps the most recent // runs; cron-view.ts re-sorts ascending for display. export async function getOpenClawCronRuns( jobId: string, limit = 50 ): Promise { const out = await withGatewayClient(async rpc => { const res = await rpc<{ entries?: OpenClawCronRunEntry[] }>('cron.runs', { jobId, limit }) return res.entries ?? [] }) return out ?? [] } // Archive a session: resolve to its key, then `sessions.patch // { archived: true }`. Rides the persistent gateway handle so failures THROW // (unlike the silent one-shot helpers) — the API layer maps them to a failed // archive. Version note: `archived` exists on SessionsPatchParamsSchema only // on 2026.7.x; 2026.6.x validates patch params with `additionalProperties: // false` and rejects the field, so archiving against a 6.x gateway fails // loudly instead of silently doing nothing. export async function archiveOpenClawSession( sessionId: string, workspacePath: string, agentId?: string ): Promise { const gateway = await getGateway() const rpc: Rpc = (method, params = {}) => withTimeout(gateway.rpc(method, params), TIMEOUT_MS, method) const id = agentId ?? (await resolveAgentIdForPath(rpc, workspacePath)) if (!id) throw new Error('openclaw agent not found for workspace') const resolved = await rpc<{ key?: string }>('sessions.resolve', { sessionId, agentId: id }) if (!resolved?.key) throw new Error(`openclaw session not found: ${sessionId}`) try { await rpc('sessions.patch', { key: resolved.key, archived: true }) } catch (err) { const raw = err instanceof Error ? err.message : String(err) // 2026.6.x has no `archived` patch field (additionalProperties: false → // "unexpected property"); the gateway also refuses archiving the agent's // main session on every line. Translate both to actionable messages. if (raw.includes("unexpected property 'archived'")) { throw new Error('Archiving chats needs OpenClaw 2026.7 or newer') } if (raw.toLowerCase().includes('main session')) { throw new Error("The agent's main chat can't be archived") } throw err } } function firstUserMessageText(detail: OpenClawSessionDetail | null): string | undefined { const message = detail?.messages.find(candidate => candidate.role === 'user') if (!message) return undefined const raw = typeof message.content === 'string' ? message.content : message.content .filter( (block): block is Extract => block.type === 'text' ) .map(block => block.text) .join('\n') const text = stripUserMessageMetadata(raw).trim() return text || undefined } export function selectOldestOpenClawFirstUserMessage( candidates: OpenClawSessionPreviewCandidate[] ): string | undefined { const oldest = candidates.slice().sort((a, b) => { const aCreatedAt = a.detail?.messages.find(message => typeof message.timestamp === 'number')?.timestamp ?? a.updatedAt const bCreatedAt = b.detail?.messages.find(message => typeof message.timestamp === 'number')?.timestamp ?? b.updatedAt return aCreatedAt - bCreatedAt || a.key.localeCompare(b.key) })[0] return oldest ? firstUserMessageText(oldest.detail) : undefined } export function selectLatestOpenClawUpdatedAt( sessions: Pick[] ): number | undefined { return sessions.reduce( (latest, session) => latest === undefined || session.updatedAt > latest ? session.updatedAt : latest, undefined ) } // Session-set identity for the first-user-message cache: transcripts are // append-only, so once the oldest session's first message exists it can only // change when a session is added or removed. Order-insensitive. export function openClawSessionSetSignature(sessions: Pick[]): string { return sessions .map(session => session.key) .sort() .join('\n') } const firstUserMessageCache = new Map() export async function getOpenClawWorkspacePreview( workspacePath: string, agentId: string | undefined, includeFirstUserMessage: boolean ): Promise { // Preview reads ride the shared persistent gateway connection instead of a // one-shot client per home-page card; per-call timeouts keep the card fast // when the gateway is up but slow. let gateway: GatewayHandle try { gateway = await getGateway() } catch { return {} } const rpc: Rpc = (method, params = {}) => withTimeout(gateway.rpc(method, params), TIMEOUT_MS, method) try { const id = agentId ?? (await resolveAgentIdForPath(rpc, workspacePath)) if (!id) return {} const res = await rpc<{ sessions: OpenClawSessionRow[] }>('sessions.list', { agentId: id }) const updatedAt = selectLatestOpenClawUpdatedAt(res.sessions) if (!includeFirstUserMessage) { return updatedAt !== undefined ? { updatedAt } : {} } const signature = openClawSessionSetSignature(res.sessions) const cached = firstUserMessageCache.get(workspacePath) let firstUserMessage = cached?.signature === signature ? cached.message : undefined if (firstUserMessage === undefined) { const candidates = await Promise.all( res.sessions.map(async session => ({ key: session.key, updatedAt: session.updatedAt, detail: await rpc('sessions.get', { key: session.key }).catch( () => null ) })) ) firstUserMessage = selectOldestOpenClawFirstUserMessage(candidates) // Only cache found messages: a session without a user message yet can // gain one later without the session set changing. if (firstUserMessage) { firstUserMessageCache.set(workspacePath, { signature, message: firstUserMessage }) } } return { ...(firstUserMessage ? { firstUserMessage } : {}), ...(updatedAt !== undefined ? { updatedAt } : {}) } } catch { return {} } } // One entry from the gateway's `models.list` catalog. The catalog is // gateway-wide (no per-agent param) — the allowed model set filtered by config. type OpenClawModelChoice = { id: string name: string provider: string alias?: string contextWindow?: number reasoning?: boolean } export async function getOpenClawModels(): Promise { const out = await withGatewayClient(async rpc => { const res = await rpc<{ models: OpenClawModelChoice[] }>('models.list') // `models.list` reports only `reasoning: boolean` — never the level menu, // and no RPC exposes it. Session rows do, so prime the profile map from // one aggregate list when it's still cold (first picker open on a fresh // server). Later calls read the map discovery/live ingest keep warm. if (!hasOpenClawThinkingProfiles()) { const sessions = await rpc<{ sessions: OpenClawSessionRow[] }>('sessions.list', { includeGlobal: true }).catch(() => null) if (sessions) recordOpenClawThinkingProfiles(sessions.sessions) } return res.models }) // Map the gateway catalog onto the Model shape. The picker value is the // full `provider/id` ref — the exact form `sessions.patch {model}` accepts // and the form session rows report back (`modelProvider`/`model`), so both // the applied-model cache comparison and the thinking-profile lookup hold. return (out ?? []).map(m => { const value = m.provider && !m.id.includes('/') ? `${m.provider}/${m.id}` : m.id const model: Model = { value, displayName: m.name } // Per-model menu (thinking.ts). A learned menu with a real choice in it is // itself proof the model reasons, and it outranks the catalog flag: on // 2026.7.2-beta.7 `models.list` stopped setting `reasoning` for the OpenAI // models (verified live) while their session rows still advertise the full // `off…ultra` menu, so trusting the flag alone silently drops the effort // picker for every GPT model. const profile = openClawThinkingProfile(value) if (!m.reasoning && !(profile && profile.levels.length > 1)) return model // Falling back to the cross-provider intersection offers fewer levels than // the model has rather than levels it would reject — a rejection silently // drops the user's pick. model.supportsEffort = true model.supportedEffortLevels = profile ? [...profile.levels] : [...OPENCLAW_FALLBACK_THINKING_LEVELS] if (profile?.default && profile.levels.includes(profile.default)) { model.defaultEffort = profile.default } return model }) }