UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

1,045 lines (1,044 loc) 38.3 kB
import { r as __exportAll } from "./chunk-CNf5ZN-e.js"; import { n as signalProcessTree, t as killProcessTree } from "./kill-tree-kSm0C74g.js"; import { n as validateToolCall, t as validateToolArguments } from "./src-M7TBQdDX.js"; import { i as writeJsonlEntriesSync, n as appendJsonlEntrySync } from "./transcript-jsonl-CD0KPCG0.js"; import { i as getAgentDir, l as getSessionsDir } from "./config-DSj7k-uT.js"; import { i as streamSimple, n as completeSimple } from "./stream-4H1GSjKe.js"; import { $ as COMPACTION_SUMMARY_SUFFIX, A as parseCommandArgs, B as estimateTokens, C as CoreAgentHarness, Ct as resolveAgentCoreCompleteFn, D as formatPromptTemplateInvocation, E as loadSourcedSkills, F as prepareBranchEntries, G as prepareCompaction, H as findTurnStartIndex, I as DEFAULT_COMPACTION_SETTINGS, J as Session, K as shouldCompact, L as calculateContextTokens, M as collectEntriesForBranchSummary, N as collectEntriesForBranchSummaryFromBranches, O as loadPromptTemplates, P as generateBranchSummary, Q as COMPACTION_SUMMARY_PREFIX, R as compact, S as formatSkillsForSystemPrompt, St as runAgentLoopContinue, T as loadSkills, U as generateSummary, V as findCutPoint, W as getLastAssistantUsage, X as BRANCH_SUMMARY_PREFIX, Y as buildSessionContext$1, Z as BRANCH_SUMMARY_SUFFIX, _ as createTimestamp, _t as toError, a as DEFAULT_MAX_LINES, at as createCustomMessage, b as toSession, bt as agentLoopContinue, c as truncateHead, ct as BranchSummaryError, d as InMemorySessionRepo, dt as FileError, et as asAgentMessage, f as InMemorySessionStorage, ft as SessionError, g as createSessionId$1, gt as ok, h as loadJsonlSessionMetadata, ht as getOrUndefined, i as DEFAULT_MAX_BYTES, it as createCompactionSummaryMessage, j as substituteArgs, k as loadSourcedPromptTemplates, l as truncateLine, lt as CompactionError, m as JsonlSessionStorage, mt as getOrThrow, n as executeShellWithCapture, nt as convertToLlm, ot as NodeExecutionEnv, p as JsonlSessionRepo, pt as err, q as serializeConversation, r as sanitizeBinaryOutput, rt as createBranchSummaryMessage, s as formatSize, st as AgentHarnessError, t as streamProxy, tt as bashExecutionToText, u as truncateTail, ut as ExecutionError, v as getEntriesToFork, vt as Agent$1, w as formatSkillInvocation, wt as resolveAgentCoreStreamFn, x as uuidv7, xt as runAgentLoop, y as getFileSystemResultOrThrow, yt as agentLoop, z as estimateContextTokens } from "./proxy-C1ZgbVua.js"; import "./llm-B2byLsni.js"; import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, statSync, writeFileSync } from "node:fs"; import { join, resolve } from "node:path"; import { readFile, readdir, stat } from "node:fs/promises"; import { randomUUID } from "node:crypto"; //#region src/agents/runtime/index.ts /** * OpenClaw-owned agent runtime facade. * * Wires agent-core to the plugin SDK LLM runtime and re-exports reusable runtime helpers. */ var runtime_exports = /* @__PURE__ */ __exportAll({ Agent: () => Agent, AgentHarness: () => CoreAgentHarness, AgentHarnessError: () => AgentHarnessError, BRANCH_SUMMARY_PREFIX: () => BRANCH_SUMMARY_PREFIX, BRANCH_SUMMARY_SUFFIX: () => BRANCH_SUMMARY_SUFFIX, BranchSummaryError: () => BranchSummaryError, COMPACTION_SUMMARY_PREFIX: () => COMPACTION_SUMMARY_PREFIX, COMPACTION_SUMMARY_SUFFIX: () => COMPACTION_SUMMARY_SUFFIX, CompactionError: () => CompactionError, CoreAgentHarness: () => CoreAgentHarness, DEFAULT_COMPACTION_SETTINGS: () => DEFAULT_COMPACTION_SETTINGS, DEFAULT_MAX_BYTES: () => DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES: () => DEFAULT_MAX_LINES, ExecutionError: () => ExecutionError, FileError: () => FileError, GREP_MAX_LINE_LENGTH: () => 500, InMemorySessionRepo: () => InMemorySessionRepo, InMemorySessionStorage: () => InMemorySessionStorage, JsonlSessionRepo: () => JsonlSessionRepo, JsonlSessionStorage: () => JsonlSessionStorage, NodeExecutionEnv: () => NodeExecutionEnv, Session: () => Session, SessionError: () => SessionError, agentLoop: () => agentLoop, agentLoopContinue: () => agentLoopContinue, asAgentMessage: () => asAgentMessage, bashExecutionToText: () => bashExecutionToText, buildSessionContext: () => buildSessionContext$1, calculateContextTokens: () => calculateContextTokens, collectEntriesForBranchSummary: () => collectEntriesForBranchSummary, collectEntriesForBranchSummaryFromBranches: () => collectEntriesForBranchSummaryFromBranches, compact: () => compact, convertToLlm: () => convertToLlm, createBranchSummaryMessage: () => createBranchSummaryMessage, createCompactionSummaryMessage: () => createCompactionSummaryMessage, createCustomMessage: () => createCustomMessage, createSessionId: () => createSessionId$1, createTimestamp: () => createTimestamp, err: () => err, estimateContextTokens: () => estimateContextTokens, estimateTokens: () => estimateTokens, executeShellWithCapture: () => executeShellWithCapture, findCutPoint: () => findCutPoint, findTurnStartIndex: () => findTurnStartIndex, formatPromptTemplateInvocation: () => formatPromptTemplateInvocation, formatSize: () => formatSize, formatSkillInvocation: () => formatSkillInvocation, formatSkillsForSystemPrompt: () => formatSkillsForSystemPrompt, generateBranchSummary: () => generateBranchSummary, generateSummary: () => generateSummary, getEntriesToFork: () => getEntriesToFork, getFileSystemResultOrThrow: () => getFileSystemResultOrThrow, getLastAssistantUsage: () => getLastAssistantUsage, getOrThrow: () => getOrThrow, getOrUndefined: () => getOrUndefined, killProcessTree: () => killProcessTree, loadJsonlSessionMetadata: () => loadJsonlSessionMetadata, loadPromptTemplates: () => loadPromptTemplates, loadSkills: () => loadSkills, loadSourcedPromptTemplates: () => loadSourcedPromptTemplates, loadSourcedSkills: () => loadSourcedSkills, ok: () => ok, openClawAgentCoreRuntime: () => openClawAgentCoreRuntime, parseCommandArgs: () => parseCommandArgs, prepareBranchEntries: () => prepareBranchEntries, prepareCompaction: () => prepareCompaction, resolveAgentCoreCompleteFn: () => resolveAgentCoreCompleteFn, resolveAgentCoreStreamFn: () => resolveAgentCoreStreamFn, runAgentLoop: () => runAgentLoop, runAgentLoopContinue: () => runAgentLoopContinue, sanitizeBinaryOutput: () => sanitizeBinaryOutput, serializeConversation: () => serializeConversation, shouldCompact: () => shouldCompact, signalProcessTree: () => signalProcessTree, streamProxy: () => streamProxy, substituteArgs: () => substituteArgs, toError: () => toError, toSession: () => toSession, truncateHead: () => truncateHead, truncateLine: () => truncateLine, truncateTail: () => truncateTail, uuidv7: () => uuidv7, validateToolArguments: () => validateToolArguments, validateToolCall: () => validateToolCall }); const openClawAgentCoreRuntime = { completeSimple, streamSimple }; var Agent = class extends Agent$1 { constructor(options = {}) { super({ runtime: openClawAgentCoreRuntime, ...options }); } }; //#endregion //#region src/agents/sessions/session-manager.ts /** * JSONL-backed session tree manager. * * Reads, writes, forks, labels, and exports persisted session entries while preserving branch structure. */ function createSessionId() { return uuidv7(); } /** Generate a unique short ID (8 hex chars, collision-checked) */ function generateId(byId) { for (let i = 0; i < 100; i++) { const id = randomUUID().slice(0, 8); if (!byId.has(id)) return id; } return randomUUID(); } /** Migrate v1 → v2: add id/parentId tree structure. Mutates in place. */ function migrateV1ToV2(entries) { const ids = /* @__PURE__ */ new Set(); let prevId = null; for (const entry of entries) { if (entry.type === "session") { entry.version = 2; continue; } entry.id = generateId(ids); ids.add(entry.id); entry.parentId = prevId; prevId = entry.id; if (entry.type === "compaction") { const comp = entry; if (typeof comp.firstKeptEntryIndex === "number") { const targetEntry = entries[comp.firstKeptEntryIndex]; if (targetEntry && targetEntry.type !== "session") comp.firstKeptEntryId = targetEntry.id; delete comp.firstKeptEntryIndex; } } } } /** Migrate v2 → v3: rename hookMessage role to custom. Mutates in place. */ function migrateV2ToV3(entries) { for (const entry of entries) { if (entry.type === "session") { entry.version = 3; continue; } if (entry.type === "message") { const msgEntry = entry; if (msgEntry.message && msgEntry.message.role === "hookMessage") msgEntry.message.role = "custom"; } } } /** * Run all necessary migrations to bring entries to current version. * Mutates entries in place. Returns true if any migration was applied. */ function migrateToCurrentVersion(entries) { const version = entries.find((e) => e.type === "session")?.version ?? 1; if (version >= 3) return false; if (version < 2) migrateV1ToV2(entries); if (version < 3) migrateV2ToV3(entries); return true; } /** Exported for testing */ function migrateSessionEntries(entries) { migrateToCurrentVersion(entries); } /** Exported for compaction.test.ts */ function parseSessionEntries(content) { const entries = []; const lines = content.trim().split("\n"); for (const line of lines) { if (!line.trim()) continue; try { const entry = JSON.parse(line); entries.push(entry); } catch {} } return entries; } function getLatestCompactionEntry(entries) { for (let i = entries.length - 1; i >= 0; i--) if (entries[i].type === "compaction") return entries[i]; return null; } /** * Build the session context from entries using tree traversal. * If leafId is provided, walks from that entry to root. * Handles compaction and branch summaries along the path. */ function buildSessionContext(entries, leafId, byIdInput) { let byId = byIdInput; if (!byId) { byId = /* @__PURE__ */ new Map(); for (const entry of entries) byId.set(entry.id, entry); } let leaf; if (leafId === null) return { messages: [], thinkingLevel: "off", model: null }; if (leafId) leaf = byId.get(leafId); if (!leaf) leaf = entries[entries.length - 1]; if (!leaf) return { messages: [], thinkingLevel: "off", model: null }; const path = []; let current = leaf; while (current) { path.unshift(current); current = current.parentId ? byId.get(current.parentId) : void 0; } return buildSessionContext$1(path); } /** * Compute the default session directory for a cwd. * Encodes cwd into a safe directory name under ~/.openclaw/agent/sessions/. */ function getDefaultSessionDir(cwd, agentDir = getAgentDir()) { const sessionDir = join(agentDir, "sessions", `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`); if (!existsSync(sessionDir)) mkdirSync(sessionDir, { recursive: true }); return sessionDir; } /** Exported for testing */ function loadEntriesFromFile(filePath) { if (!existsSync(filePath)) return []; const entries = parseJsonlEntries(readFileSync(filePath, "utf8")); if (entries.length === 0) return entries; const header = entries[0]; if (header.type !== "session" || typeof header.id !== "string") return []; return entries; } function parseJsonlEntries(content) { const entries = []; const lines = content.trim().split("\n"); for (const line of lines) { if (!line.trim()) continue; try { const entry = JSON.parse(line); entries.push(entry); } catch {} } return entries; } function hasReadableSessionHeader(entries) { const header = entries[0]; return header?.type === "session" && typeof header.id === "string"; } function buildCorruptSessionBackupPath(filePath) { return `${filePath}.corrupt-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}-${randomUUID().slice(0, 8)}.jsonl`; } function recoverCorruptSessionEntries(filePath, cwd) { const content = readFileSync(filePath, "utf8"); if (content.trim().length === 0) return null; const parsedEntries = parseJsonlEntries(content); const header = parsedEntries.find((entry) => entry.type === "session" && typeof entry.id === "string") ?? { type: "session", version: 3, id: createSessionId(), timestamp: (/* @__PURE__ */ new Date()).toISOString(), cwd }; const recoveredEntries = parsedEntries.filter((entry) => entry.type !== "session"); const backupPath = buildCorruptSessionBackupPath(filePath); const backupMode = statSync(filePath).mode & 511; writeFileSync(backupPath, content, { encoding: "utf8", mode: backupMode || 384 }); chmodSync(backupPath, backupMode || 384); return [header, ...recoveredEntries]; } function isValidSessionFile(filePath) { try { const fd = openSync(filePath, "r"); const buffer = Buffer.alloc(512); const bytesRead = readSync(fd, buffer, 0, 512, 0); closeSync(fd); const firstLine = buffer.toString("utf8", 0, bytesRead).split("\n")[0]; if (!firstLine) return false; const header = JSON.parse(firstLine); return header.type === "session" && typeof header.id === "string"; } catch { return false; } } /** Exported for testing */ function findMostRecentSession(sessionDir) { try { return readdirSync(sessionDir).filter((f) => f.endsWith(".jsonl")).map((f) => join(sessionDir, f)).filter(isValidSessionFile).map((path) => ({ path, mtime: statSync(path).mtime })).toSorted((a, b) => b.mtime.getTime() - a.mtime.getTime())[0]?.path || null; } catch { return null; } } function isMessageWithContent(message) { return typeof message.role === "string" && "content" in message; } function extractTextContent(message) { const content = message.content; if (typeof content === "string") return content; return content.filter((block) => block.type === "text").map((block) => block.text).join(" "); } function getLastActivityTime(entries) { let lastActivityTime; for (const entry of entries) { if (entry.type !== "message") continue; const message = entry.message; if (!isMessageWithContent(message)) continue; if (message.role !== "user" && message.role !== "assistant") continue; const msgTimestamp = message.timestamp; if (typeof msgTimestamp === "number") { lastActivityTime = Math.max(lastActivityTime ?? 0, msgTimestamp); continue; } const entryTimestamp = entry.timestamp; if (typeof entryTimestamp === "string") { const t = new Date(entryTimestamp).getTime(); if (!Number.isNaN(t)) lastActivityTime = Math.max(lastActivityTime ?? 0, t); } } return lastActivityTime; } function getSessionModifiedDate(entries, header, statsMtime) { const lastActivityTime = getLastActivityTime(entries); if (typeof lastActivityTime === "number" && lastActivityTime > 0) return new Date(lastActivityTime); const headerTime = typeof header.timestamp === "string" ? new Date(header.timestamp).getTime() : NaN; return !Number.isNaN(headerTime) ? new Date(headerTime) : statsMtime; } async function buildSessionInfo(filePath) { try { const content = await readFile(filePath, "utf8"); const entries = []; const lines = content.trim().split("\n"); for (const line of lines) { if (!line.trim()) continue; try { entries.push(JSON.parse(line)); } catch {} } if (entries.length === 0) return null; const header = entries[0]; if (header.type !== "session") return null; const stats = await stat(filePath); let messageCount = 0; let firstMessage = ""; const allMessages = []; let name; for (const entry of entries) { if (entry.type === "session_info") name = entry.name?.trim() || void 0; if (entry.type !== "message") continue; messageCount++; const message = entry.message; if (!isMessageWithContent(message)) continue; if (message.role !== "user" && message.role !== "assistant") continue; const textContent = extractTextContent(message); if (!textContent) continue; allMessages.push(textContent); if (!firstMessage && message.role === "user") firstMessage = textContent; } const cwd = typeof header.cwd === "string" ? header.cwd : ""; const parentSessionPath = header.parentSession; const modified = getSessionModifiedDate(entries, header, stats.mtime); return { path: filePath, id: header.id, cwd, name, parentSessionPath, created: new Date(header.timestamp), modified, messageCount, firstMessage: firstMessage || "(no messages)", allMessagesText: allMessages.join(" ") }; } catch { return null; } } const MAX_CONCURRENT_SESSION_INFO_LOADS = 10; async function buildSessionInfosWithConcurrency(files, onLoaded) { const results = Array.from({ length: files.length }, () => null); const inFlight = /* @__PURE__ */ new Set(); let nextIndex = 0; const startNext = () => { const index = nextIndex++; const file = files[index]; if (!file) return; const task = buildSessionInfo(file).then((info) => { results[index] = info; }).catch(() => { results[index] = null; }).finally(() => { inFlight.delete(task); onLoaded(); }); inFlight.add(task); }; while (nextIndex < files.length || inFlight.size > 0) { while (nextIndex < files.length && inFlight.size < MAX_CONCURRENT_SESSION_INFO_LOADS) startNext(); if (inFlight.size > 0) await Promise.race(inFlight); } return results; } async function listSessionsFromDir(dir, onProgress, progressOffset = 0, progressTotal) { const sessions = []; if (!existsSync(dir)) return sessions; try { const files = (await readdir(dir)).filter((f) => f.endsWith(".jsonl")).map((f) => join(dir, f)); const total = progressTotal ?? files.length; let loaded = 0; const results = await buildSessionInfosWithConcurrency(files, () => { loaded++; onProgress?.(progressOffset + loaded, total); }); for (const info of results) if (info) sessions.push(info); } catch {} return sessions; } /** * Manages conversation sessions as append-only trees stored in JSONL files. * * Each session entry has an id and parentId forming a tree structure. The "leaf" * pointer tracks the current position. Appending creates a child of the current leaf. * Branching moves the leaf to an earlier entry, allowing new branches without * modifying history. * * Use buildSessionContext() to get the resolved message list for the LLM, which * handles compaction summaries and follows the path from root to current leaf. */ var SessionManager = class SessionManager { constructor(cwd, sessionDir, sessionFile, persist) { this.sessionId = ""; this.flushed = false; this.fileEntries = []; this.byId = /* @__PURE__ */ new Map(); this.labelsById = /* @__PURE__ */ new Map(); this.labelTimestampsById = /* @__PURE__ */ new Map(); this.leafId = null; this.recoveredCorruptHeader = false; this.cwd = cwd; this.sessionDir = sessionDir; this.shouldPersist = persist; if (persist && sessionDir && !existsSync(sessionDir)) mkdirSync(sessionDir, { recursive: true }); if (sessionFile) this.setSessionFile(sessionFile); else this.newSession(); } /** Switch to a different session file (used for resume and branching) */ setSessionFile(sessionFile) { this.sessionFile = resolve(sessionFile); this.recoveredCorruptHeader = false; if (existsSync(this.sessionFile)) { this.fileEntries = loadEntriesFromFile(this.sessionFile); if (this.fileEntries.length === 0) { const recoveredEntries = recoverCorruptSessionEntries(this.sessionFile, this.cwd); if (recoveredEntries && hasReadableSessionHeader(recoveredEntries)) { this.fileEntries = recoveredEntries; const header = this.fileEntries.find((e) => e.type === "session"); this.sessionId = header?.id ?? createSessionId(); this.buildIndex(); this.rewriteFile(); this.recoveredCorruptHeader = true; this.flushed = true; return; } const explicitPath = this.sessionFile; this.newSession(); this.sessionFile = explicitPath; this.rewriteFile(); this.flushed = true; return; } const header = this.fileEntries.find((e) => e.type === "session"); this.sessionId = header?.id ?? createSessionId(); if (migrateToCurrentVersion(this.fileEntries)) this.rewriteFile(); this.buildIndex(); this.flushed = true; } else { const explicitPath = this.sessionFile; this.newSession(); this.sessionFile = explicitPath; } } newSession(options) { this.recoveredCorruptHeader = false; this.sessionId = options?.id ?? createSessionId(); const timestamp = (/* @__PURE__ */ new Date()).toISOString(); const header = { type: "session", version: 3, id: this.sessionId, timestamp, cwd: this.cwd, parentSession: options?.parentSession }; this.fileEntries = [header]; this.byId.clear(); this.labelsById.clear(); this.leafId = null; this.flushed = false; if (this.shouldPersist) { const fileTimestamp = timestamp.replace(/[:.]/g, "-"); this.sessionFile = join(this.getSessionDir(), `${fileTimestamp}_${this.sessionId}.jsonl`); } return this.sessionFile; } buildIndex() { this.byId.clear(); this.labelsById.clear(); this.labelTimestampsById.clear(); this.leafId = null; for (const entry of this.fileEntries) { if (entry.type === "session") continue; this.byId.set(entry.id, entry); this.leafId = entry.id; if (entry.type === "label") if (entry.label) { this.labelsById.set(entry.targetId, entry.label); this.labelTimestampsById.set(entry.targetId, entry.timestamp); } else { this.labelsById.delete(entry.targetId); this.labelTimestampsById.delete(entry.targetId); } } } rewriteFile() { if (!this.shouldPersist || !this.sessionFile) return; writeJsonlEntriesSync(this.sessionFile, this.fileEntries); } isPersisted() { return this.shouldPersist; } getCwd() { return this.cwd; } getSessionDir() { return this.sessionDir; } getSessionId() { return this.sessionId; } wasRecoveredFromCorruptHeader() { return this.recoveredCorruptHeader; } getSessionFile() { return this.sessionFile; } persist(entry) { if (!this.shouldPersist || !this.sessionFile) return; if (!this.fileEntries.some((e) => e.type === "message" && e.message.role === "assistant")) { this.flushed = false; return; } if (!this.flushed) { writeJsonlEntriesSync(this.sessionFile, this.fileEntries); this.flushed = true; } else appendJsonlEntrySync(this.sessionFile, entry); } appendEntry(entry) { this.fileEntries.push(entry); this.byId.set(entry.id, entry); this.leafId = entry.id; this.persist(entry); } /** Append a message as child of current leaf, then advance leaf. Returns entry id. * Does not allow writing CompactionSummaryMessage and BranchSummaryMessage directly. * Reason: we want these to be top-level entries in the session, not message session entries, * so it is easier to find them. * These need to be appended via appendCompaction() and appendBranchSummary() methods. */ appendMessage(message) { const entry = { type: "message", id: generateId(this.byId), parentId: this.leafId, timestamp: (/* @__PURE__ */ new Date()).toISOString(), message }; this.appendEntry(entry); return entry.id; } /** Append a thinking level change as child of current leaf, then advance leaf. Returns entry id. */ appendThinkingLevelChange(thinkingLevel) { const entry = { type: "thinking_level_change", id: generateId(this.byId), parentId: this.leafId, timestamp: (/* @__PURE__ */ new Date()).toISOString(), thinkingLevel }; this.appendEntry(entry); return entry.id; } /** Append a model change as child of current leaf, then advance leaf. Returns entry id. */ appendModelChange(provider, modelId) { const entry = { type: "model_change", id: generateId(this.byId), parentId: this.leafId, timestamp: (/* @__PURE__ */ new Date()).toISOString(), provider, modelId }; this.appendEntry(entry); return entry.id; } /** Append a compaction summary as child of current leaf, then advance leaf. Returns entry id. */ appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromHook) { const entry = { type: "compaction", id: generateId(this.byId), parentId: this.leafId, timestamp: (/* @__PURE__ */ new Date()).toISOString(), summary, firstKeptEntryId, tokensBefore, details, fromHook }; this.appendEntry(entry); return entry.id; } /** Append a custom entry (for extensions) as child of current leaf, then advance leaf. Returns entry id. */ appendCustomEntry(customType, data) { const entry = { type: "custom", customType, data, id: generateId(this.byId), parentId: this.leafId, timestamp: (/* @__PURE__ */ new Date()).toISOString() }; this.appendEntry(entry); return entry.id; } /** Append a session info entry (e.g., display name). Returns entry id. */ appendSessionInfo(name) { const entry = { type: "session_info", id: generateId(this.byId), parentId: this.leafId, timestamp: (/* @__PURE__ */ new Date()).toISOString(), name: name.trim() }; this.appendEntry(entry); return entry.id; } /** Get the current session name from the latest session_info entry, if any. */ getSessionName() { const entries = this.getEntries(); for (let i = entries.length - 1; i >= 0; i--) { const entry = entries[i]; if (entry.type === "session_info") return entry.name?.trim() || void 0; } } /** * Append a custom message entry (for extensions) that participates in LLM context. * @param customType Extension identifier for filtering on reload * @param content Message content (string or TextContent/ImageContent array) * @param display Whether to show in TUI (true = styled display, false = hidden) * @param details Optional extension-specific metadata (not sent to LLM) * @returns Entry id */ appendCustomMessageEntry(customType, content, display, details) { const entry = { type: "custom_message", customType, content, display, details, id: generateId(this.byId), parentId: this.leafId, timestamp: (/* @__PURE__ */ new Date()).toISOString() }; this.appendEntry(entry); return entry.id; } getLeafId() { return this.leafId; } getLeafEntry() { return this.leafId ? this.byId.get(this.leafId) : void 0; } getEntry(id) { return this.byId.get(id); } /** * Get all direct children of an entry. */ getChildren(parentId) { const children = []; for (const entry of this.byId.values()) if (entry.parentId === parentId) children.push(entry); return children; } /** * Get the label for an entry, if any. */ getLabel(id) { return this.labelsById.get(id); } /** * Set or clear a label on an entry. * Labels are user-defined markers for bookmarking/navigation. * Pass undefined or empty string to clear the label. */ appendLabelChange(targetId, label) { if (!this.byId.has(targetId)) throw new Error(`Entry ${targetId} not found`); const entry = { type: "label", id: generateId(this.byId), parentId: this.leafId, timestamp: (/* @__PURE__ */ new Date()).toISOString(), targetId, label }; this.appendEntry(entry); if (label) { this.labelsById.set(targetId, label); this.labelTimestampsById.set(targetId, entry.timestamp); } else { this.labelsById.delete(targetId); this.labelTimestampsById.delete(targetId); } return entry.id; } /** * Walk from entry to root, returning all entries in path order. * Includes all entry types (messages, compaction, model changes, etc.). * Use buildSessionContext() to get the resolved messages for the LLM. */ getBranch(fromId) { const path = []; const startId = fromId ?? this.leafId; let current = startId ? this.byId.get(startId) : void 0; while (current) { path.unshift(current); current = current.parentId ? this.byId.get(current.parentId) : void 0; } return path; } /** * Build the session context (what gets sent to the LLM). * Uses tree traversal from current leaf. */ buildSessionContext() { return buildSessionContext(this.getEntries(), this.leafId, this.byId); } /** * Get session header. */ getHeader() { const h = this.fileEntries.find((e) => e.type === "session"); return h ? h : null; } /** * Get all session entries (excludes header). Returns a shallow copy. * The session is append-only: use appendXXX() to add entries, branch() to * change the leaf pointer. Entries cannot be modified or deleted. */ getEntries() { return this.fileEntries.filter((e) => e.type !== "session"); } /** * Get the session as a tree structure. Returns a shallow defensive copy of all entries. * A well-formed session has exactly one root (first entry with parentId === null). * Orphaned entries (broken parent chain) are also returned as roots. */ getTree() { const entries = this.getEntries(); const nodeMap = /* @__PURE__ */ new Map(); const roots = []; for (const entry of entries) { const label = this.labelsById.get(entry.id); const labelTimestamp = this.labelTimestampsById.get(entry.id); nodeMap.set(entry.id, { entry, children: [], label, labelTimestamp }); } for (const entry of entries) { const node = nodeMap.get(entry.id); if (entry.parentId === null || entry.parentId === entry.id) roots.push(node); else { const parent = nodeMap.get(entry.parentId); if (parent) parent.children.push(node); else roots.push(node); } } const stack = [...roots]; while (stack.length > 0) { const node = stack.pop(); node.children.sort((a, b) => new Date(a.entry.timestamp).getTime() - new Date(b.entry.timestamp).getTime()); stack.push(...node.children); } return roots; } /** * Start a new branch from an earlier entry. * Moves the leaf pointer to the specified entry. The next appendXXX() call * will create a child of that entry, forming a new branch. Existing entries * are not modified or deleted. */ branch(branchFromId) { if (!this.byId.has(branchFromId)) throw new Error(`Entry ${branchFromId} not found`); this.leafId = branchFromId; } /** * Reset the leaf pointer to null (before any entries). * The next appendXXX() call will create a new root entry (parentId = null). * Use this when navigating to re-edit the first user message. */ resetLeaf() { this.leafId = null; } /** * Start a new branch with a summary of the abandoned path. * Same as branch(), but also appends a branch_summary entry that captures * context from the abandoned conversation path. */ branchWithSummary(branchFromId, summary, details, fromHook) { if (branchFromId !== null && !this.byId.has(branchFromId)) throw new Error(`Entry ${branchFromId} not found`); this.leafId = branchFromId; const entry = { type: "branch_summary", id: generateId(this.byId), parentId: branchFromId, timestamp: (/* @__PURE__ */ new Date()).toISOString(), fromId: branchFromId ?? "root", summary, details, fromHook }; this.appendEntry(entry); return entry.id; } /** * Create a new session file containing only the path from root to the specified leaf. * Useful for extracting a single conversation path from a branched session. * Returns the new session file path, or undefined if not persisting. */ createBranchedSession(leafId) { const previousSessionFile = this.sessionFile; const path = this.getBranch(leafId); if (path.length === 0) throw new Error(`Entry ${leafId} not found`); const pathWithoutLabels = path.filter((e) => e.type !== "label"); const newSessionId = createSessionId(); const timestamp = (/* @__PURE__ */ new Date()).toISOString(); const fileTimestamp = timestamp.replace(/[:.]/g, "-"); const newSessionFile = join(this.getSessionDir(), `${fileTimestamp}_${newSessionId}.jsonl`); const header = { type: "session", version: 3, id: newSessionId, timestamp, cwd: this.cwd, parentSession: this.shouldPersist ? previousSessionFile : void 0 }; const pathEntryIds = new Set(pathWithoutLabels.map((e) => e.id)); const labelsToWrite = []; for (const [targetId, label] of this.labelsById) if (pathEntryIds.has(targetId)) labelsToWrite.push({ targetId, label, timestamp: this.labelTimestampsById.get(targetId) }); if (this.shouldPersist) { let parentId = pathWithoutLabels[pathWithoutLabels.length - 1]?.id || null; const labelEntries = []; for (const { targetId, label, timestamp: labelTimestamp } of labelsToWrite) { const labelEntry = { type: "label", id: generateId(new Set(pathEntryIds)), parentId, timestamp: labelTimestamp, targetId, label }; pathEntryIds.add(labelEntry.id); labelEntries.push(labelEntry); parentId = labelEntry.id; } this.fileEntries = [ header, ...pathWithoutLabels, ...labelEntries ]; this.sessionId = newSessionId; this.sessionFile = newSessionFile; this.buildIndex(); if (this.fileEntries.some((e) => e.type === "message" && e.message.role === "assistant")) { this.rewriteFile(); this.flushed = true; } else this.flushed = false; return newSessionFile; } const labelEntries = []; let parentId = pathWithoutLabels[pathWithoutLabels.length - 1]?.id || null; for (const { targetId, label, timestamp: labelTimestamp } of labelsToWrite) { const labelEntry = { type: "label", id: generateId(new Set([...pathEntryIds, ...labelEntries.map((e) => e.id)])), parentId, timestamp: labelTimestamp, targetId, label }; labelEntries.push(labelEntry); parentId = labelEntry.id; } this.fileEntries = [ header, ...pathWithoutLabels, ...labelEntries ]; this.sessionId = newSessionId; this.buildIndex(); } /** * Create a new session. * @param cwd Working directory (stored in session header) * @param sessionDir Optional session directory. If omitted, uses default (~/.openclaw/agent/sessions/<encoded-cwd>/). */ static create(cwd, sessionDir) { return new SessionManager(cwd, sessionDir ?? getDefaultSessionDir(cwd), void 0, true); } /** * Open a specific session file. * @param path Path to session file * @param sessionDir Optional session directory for /new or /branch. If omitted, derives from file's parent. * @param cwdOverride Optional cwd override instead of the session header cwd. */ static open(path, sessionDir, cwdOverride) { const header = loadEntriesFromFile(path).find((e) => e.type === "session"); return new SessionManager(cwdOverride ?? header?.cwd ?? process.cwd(), sessionDir ?? resolve(path, ".."), path, true); } /** * Continue the most recent session, or create new if none. * @param cwd Working directory * @param sessionDir Optional session directory. If omitted, uses default (~/.openclaw/agent/sessions/<encoded-cwd>/). */ static continueRecent(cwd, sessionDir) { const dir = sessionDir ?? getDefaultSessionDir(cwd); const mostRecent = findMostRecentSession(dir); if (mostRecent) return new SessionManager(cwd, dir, mostRecent, true); return new SessionManager(cwd, dir, void 0, true); } /** Create an in-memory session (no file persistence) */ static inMemory(cwd = process.cwd()) { return new SessionManager(cwd, "", void 0, false); } /** * Fork a session from another project directory into the current project. * Creates a new session in the target cwd with the full history from the source session. * @param sourcePath Path to the source session file * @param targetCwd Target working directory (where the new session will be stored) * @param sessionDir Optional session directory. If omitted, uses default for targetCwd. */ static forkFrom(sourcePath, targetCwd, sessionDir) { const sourceEntries = loadEntriesFromFile(sourcePath); if (sourceEntries.length === 0) throw new Error(`Cannot fork: source session file is empty or invalid: ${sourcePath}`); if (!sourceEntries.find((e) => e.type === "session")) throw new Error(`Cannot fork: source session has no header: ${sourcePath}`); const dir = sessionDir ?? getDefaultSessionDir(targetCwd); if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); const newSessionId = createSessionId(); const timestamp = (/* @__PURE__ */ new Date()).toISOString(); const newSessionFile = join(dir, `${timestamp.replace(/[:.]/g, "-")}_${newSessionId}.jsonl`); appendJsonlEntrySync(newSessionFile, { type: "session", version: 3, id: newSessionId, timestamp, cwd: targetCwd, parentSession: sourcePath }); for (const entry of sourceEntries) if (entry.type !== "session") appendJsonlEntrySync(newSessionFile, entry); return new SessionManager(targetCwd, dir, newSessionFile, true); } /** * List all sessions for a directory. * @param cwd Working directory (used to compute default session directory) * @param sessionDir Optional session directory. If omitted, uses default (~/.openclaw/agent/sessions/<encoded-cwd>/). * @param onProgress Optional callback for progress updates (loaded, total) */ static async list(cwd, sessionDir, onProgress) { const sessions = await listSessionsFromDir(sessionDir ?? getDefaultSessionDir(cwd), onProgress); sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime()); return sessions; } /** * List all sessions across all project directories. * @param onProgress Optional callback for progress updates (loaded, total) */ static async listAll(onProgress) { const sessionsDir = getSessionsDir(); try { if (!existsSync(sessionsDir)) return []; const dirs = (await readdir(sessionsDir, { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => join(sessionsDir, e.name)); let totalFiles = 0; const dirFiles = []; for (const dir of dirs) try { const files = (await readdir(dir)).filter((f) => f.endsWith(".jsonl")); dirFiles.push(files.map((f) => join(dir, f))); totalFiles += files.length; } catch { dirFiles.push([]); } let loaded = 0; const sessions = []; const results = await buildSessionInfosWithConcurrency(dirFiles.flat(), () => { loaded++; onProgress?.(loaded, totalFiles); }); for (const info of results) if (info) sessions.push(info); sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime()); return sessions; } catch { return []; } } }; //#endregion export { getLatestCompactionEntry as a, parseSessionEntries as c, runtime_exports as d, getDefaultSessionDir as i, Agent as l, buildSessionContext as n, loadEntriesFromFile as o, findMostRecentSession as r, migrateSessionEntries as s, SessionManager as t, openClawAgentCoreRuntime as u };