openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
1,045 lines (1,044 loc) • 38.3 kB
JavaScript
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 };