// Session History: Read OpenClaw session/chat history
// This module provides functions to retrieve AI conversation history
// Supports multiple backends:
// 1. Direct file reading (for local session files)
// 2. CLI execution (via openclaw commands)
import type { PluginLogger } from "openclaw/plugin-sdk";
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import { execSync, spawn, type SpawnOptions } from "node:child_process";
// OpenClaw session info returned from sessions.list
export type OpenClawSession = {
key: string; // Session key (e.g., "main", "dm:peer:xxx")
title?: string; // Session title
derivedTitle?: string; // AI-derived title
lastMessage?: { // Last message in session
role: "user" | "assistant";
content: string;
timestamp?: number;
};
messageCount?: number; // Total messages in session
createdAt?: number; // Session creation timestamp
updatedAt?: number; // Last activity timestamp
sessionId?: string; // Session UUID
sessionFile?: string; // Path to session file
};
// OpenClaw chat message from chat.history
export type OpenClawMessage = {
id?: string;
role: "user" | "assistant" | "system";
content: string;
timestamp?: number;
metadata?: Record<string, unknown>;
};
// Chat history response
export type ChatHistoryResponse = {
sessionKey: string;
messages: OpenClawMessage[];
hasMore?: boolean;
cursor?: string;
};
// Sessions list response
export type SessionsListResponse = {
sessions: OpenClawSession[];
total?: number;
};
// Session store entry (from sessions.json)
type SessionStoreEntry = {
sessionId: string;
sessionFile?: string;
updatedAt?: number;
chatType?: string;
origin?: {
label?: string;
provider?: string;
surface?: string;
chatType?: string;
from?: string;
to?: string;
};
lastChannel?: string;
// ... other fields
};
// Session file line format
type SessionFileLine = {
type?: string;
message?: {
role: "user" | "assistant" | "system";
content: string | Array<{ type: string; text?: string }>;
id?: string;
timestamp?: string;
};
};
/**
* Configuration for session file reading
*/
export type SessionFileConfig = {
// Path to sessions.json store file
sessionsStorePath?: string;
// Base directory for OpenClaw data (default: ~/.openclaw)
openclawDir?: string;
// Agent ID for session file lookup
agentId?: string;
// Backend to use: "file" (default), "cli", or "auto"
backend?: "file" | "cli" | "auto";
// Path to openclaw CLI binary (for CLI backend)
cliBinary?: string;
// Gateway URL (for CLI backend, optional)
gatewayUrl?: string;
};
let sessionFileConfig: SessionFileConfig = {};
/**
* Configure the session file reader
*/
export function configureSessionFileReader(config: SessionFileConfig): void {
sessionFileConfig = { ...sessionFileConfig, ...config };
}
/**
* Get the configured session file config
*/
export function getSessionFileConfig(): SessionFileConfig {
return { ...sessionFileConfig };
}
/**
* Resolve the sessions.json store path
*/
function resolveSessionsStorePath(config: SessionFileConfig): string[] {
const candidates: string[] = [];
if (config.sessionsStorePath) {
candidates.push(config.sessionsStorePath);
}
const baseDir = config.openclawDir || path.join(os.homedir(), ".openclaw");
const agentId = config.agentId || "main";
// Standard locations
candidates.push(path.join(baseDir, "agents", agentId, "sessions.json"));
candidates.push(path.join(baseDir, "sessions.json"));
candidates.push(path.join(process.cwd(), "sessions.json"));
return candidates;
}
/**
* Load the sessions store from file
*/
function loadSessionsStore(log?: PluginLogger): Record<string, SessionStoreEntry> {
const candidates = resolveSessionsStorePath(sessionFileConfig);
for (const storePath of candidates) {
try {
if (fs.existsSync(storePath)) {
const raw = fs.readFileSync(storePath, "utf-8");
const store = JSON.parse(raw) as Record<string, SessionStoreEntry>;
log?.debug?.(`[session-history] Loaded sessions store from: ${storePath}`);
return store;
}
} catch (err) {
log?.debug?.(`[session-history] Failed to load sessions store from ${storePath}: ${err}`);
}
}
log?.warn?.(`[session-history] No sessions store found in candidates: ${candidates.join(", ")}`);
return {};
}
/**
* Resolve session transcript file candidates
*/
function resolveSessionTranscriptCandidates(
sessionId: string,
sessionFile?: string,
config?: SessionFileConfig,
): string[] {
const candidates: string[] = [];
const cfg = config || sessionFileConfig;
// Direct session file path from store
if (sessionFile) {
candidates.push(sessionFile);
}
const baseDir = cfg.openclawDir || path.join(os.homedir(), ".openclaw");
const agentId = cfg.agentId || "main";
// Agent sessions directory
candidates.push(path.join(baseDir, "agents", agentId, "sessions", `${sessionId}.jsonl`));
// Legacy sessions directory
candidates.push(path.join(baseDir, "sessions", `${sessionId}.jsonl`));
// Current working directory
if (cfg.sessionsStorePath) {
const dir = path.dirname(cfg.sessionsStorePath);
candidates.push(path.join(dir, `${sessionId}.jsonl`));
}
return candidates;
}
/**
* Extract text content from message content (handles both string and array formats)
*/
function extractMessageContent(content: string | Array<{ type: string; text?: string }>): string {
if (typeof content === "string") {
return content;
}
if (Array.isArray(content)) {
return content
.filter((part) => part.type === "text" && part.text)
.map((part) => part.text)
.join("\n");
}
return String(content);
}
/**
* Read messages from a session transcript file (.jsonl)
*/
function readSessionTranscript(
sessionId: string,
sessionFile?: string,
options?: {
limit?: number;
log?: PluginLogger;
},
): OpenClawMessage[] {
const { limit = 200, log } = options ?? {};
const candidates = resolveSessionTranscriptCandidates(sessionId, sessionFile);
const filePath = candidates.find((p) => fs.existsSync(p));
if (!filePath) {
log?.debug?.(`[session-history] No transcript file found for session ${sessionId}, tried: ${candidates.join(", ")}`);
return [];
}
log?.debug?.(`[session-history] Reading transcript from: ${filePath}`);
try {
const lines = fs.readFileSync(filePath, "utf-8").split(/\r?\n/);
const messages: OpenClawMessage[] = [];
for (const line of lines) {
if (!line.trim()) {
continue;
}
try {
const parsed = JSON.parse(line) as SessionFileLine;
// Skip header lines (type: "session")
if (parsed.type === "session") {
continue;
}
// Extract message if present
if (parsed.message && parsed.message.role && parsed.message.content) {
const msg: OpenClawMessage = {
role: parsed.message.role,
content: extractMessageContent(parsed.message.content),
id: parsed.message.id,
timestamp: parsed.message.timestamp ? new Date(parsed.message.timestamp).getTime() : undefined,
};
messages.push(msg);
}
} catch {
// ignore bad lines
}
}
// Apply limit (return last N messages)
if (messages.length > limit) {
return messages.slice(-limit);
}
return messages;
} catch (err) {
log?.error?.(`[session-history] Failed to read transcript file ${filePath}: ${err}`);
return [];
}
}
/**
* Get chat history for a specific session by reading session files directly
* @param sessionKey - The session key (e.g., "main", "dm:peer:user123", "conv-xxx")
* @param options - Optional parameters
* @returns Chat history with messages
*/
export async function getOpenClawChatHistory(
sessionKey: string = "main",
options?: {
limit?: number;
cursor?: string;
log?: PluginLogger;
},
): Promise<ChatHistoryResponse> {
const { limit = 200, log } = options ?? {};
log?.info?.(`[session-history] Fetching chat history for session: ${sessionKey}, limit: ${limit}`);
// Load sessions store to find the session entry
const store = loadSessionsStore(log);
const entry = store[sessionKey];
if (!entry?.sessionId) {
log?.warn?.(`[session-history] Session not found in store: ${sessionKey}`);
return {
sessionKey,
messages: [],
hasMore: false,
};
}
// Read messages from transcript file
const messages = readSessionTranscript(entry.sessionId, entry.sessionFile, {
limit,
log,
});
log?.info?.(`[session-history] Got ${messages.length} messages for session: ${sessionKey}`);
return {
sessionKey,
messages,
hasMore: false, // File-based reading doesn't support pagination
};
}
/**
* List all OpenClaw sessions from sessions.json
* @param options - Optional parameters
* @returns List of sessions
*/
export async function listOpenClawSessions(
options?: {
limit?: number;
includeLastMessage?: boolean;
includeDerivedTitles?: boolean;
log?: PluginLogger;
},
): Promise<SessionsListResponse> {
const {
limit = 100,
includeLastMessage = true,
log,
} = options ?? {};
log?.info?.(`[session-history] Listing sessions, limit: ${limit}`);
const store = loadSessionsStore(log);
const sessionEntries = Object.entries(store);
// Sort by updatedAt descending
sessionEntries.sort((a, b) => {
const aTime = a[1].updatedAt ?? 0;
const bTime = b[1].updatedAt ?? 0;
return bTime - aTime;
});
// Apply limit
const limited = sessionEntries.slice(0, limit);
const sessions: OpenClawSession[] = [];
for (const [key, entry] of limited) {
const session: OpenClawSession = {
key,
sessionId: entry.sessionId,
sessionFile: entry.sessionFile,
updatedAt: entry.updatedAt,
};
// Include last message if requested
if (includeLastMessage && entry.sessionId) {
const messages = readSessionTranscript(entry.sessionId, entry.sessionFile, {
limit: 1,
log,
});
if (messages.length > 0) {
const last = messages[messages.length - 1];
session.lastMessage = {
role: last.role as "user" | "assistant",
content: last.content.slice(0, 200), // Truncate for preview
timestamp: last.timestamp,
};
session.messageCount = messages.length;
}
}
sessions.push(session);
}
log?.info?.(`[session-history] Got ${sessions.length} sessions`);
return {
sessions,
total: sessionEntries.length,
};
}
/**
* Get session previews (batch fetch with last messages)
* @param sessionKeys - Array of session keys to fetch
* @param options - Optional parameters
* @returns Array of session previews
*/
export async function getSessionPreviews(
sessionKeys: string[],
options?: {
log?: PluginLogger;
},
): Promise<OpenClawSession[]> {
const { log } = options ?? {};
log?.info?.(`[session-history] Getting previews for ${sessionKeys.length} sessions`);
const store = loadSessionsStore(log);
const sessions: OpenClawSession[] = [];
for (const key of sessionKeys) {
const entry = store[key];
if (!entry?.sessionId) {
continue;
}
const session: OpenClawSession = {
key,
sessionId: entry.sessionId,
sessionFile: entry.sessionFile,
updatedAt: entry.updatedAt,
};
// Get last few messages for preview
const messages = readSessionTranscript(entry.sessionId, entry.sessionFile, {
limit: 5,
log,
});
if (messages.length > 0) {
const last = messages[messages.length - 1];
session.lastMessage = {
role: last.role as "user" | "assistant",
content: last.content.slice(0, 200),
timestamp: last.timestamp,
};
session.messageCount = messages.length;
}
sessions.push(session);
}
log?.info?.(`[session-history] Got ${sessions.length} session previews`);
return sessions;
}
/**
* Find session key by conversation ID or user ID pattern
* OpenClaw uses session keys like "dm:peer:{conversationId}" for per-peer sessions
* @param conversationId - The conversation ID from GoServer
* @returns The corresponding OpenClaw session key
*/
export function resolveSessionKey(conversationId: string): string {
// For per-peer DM sessions, the key format is "dm:peer:{peerId}"
// But in our case, we use "conv-{hash}" format
// Check if it's already a session key format
if (conversationId.startsWith("conv-") || conversationId.startsWith("dm:") || conversationId === "main") {
return conversationId;
}
// Otherwise, assume it's a conversation ID hash
return `conv-${conversationId}`;
}
/**
* Get chat history by conversation ID (convenience wrapper)
* @param conversationId - GoServer conversation ID
* @param options - Optional parameters
* @returns Chat history response
*/
export async function getChatHistoryByConversationId(
conversationId: string,
options?: {
limit?: number;
log?: PluginLogger;
},
): Promise<ChatHistoryResponse> {
const sessionKey = resolveSessionKey(conversationId);
return getOpenClawChatHistory(sessionKey, options);
}
// ============================================================================
// CLI-based Backend
// ============================================================================
/**
* Find the openclaw CLI binary path
*/
function findOpenClawCli(config: SessionFileConfig): string | null {
// User-specified path
if (config.cliBinary) {
if (fs.existsSync(config.cliBinary)) {
return config.cliBinary;
}
return null;
}
// Check common locations
const candidates = [
// npm global
"openclaw",
// npx
"npx openclaw",
// Local node_modules
path.join(process.cwd(), "node_modules", ".bin", "openclaw"),
// System paths
"/usr/local/bin/openclaw",
"/usr/bin/openclaw",
];
for (const candidate of candidates) {
try {
execSync(`which ${candidate.split(" ")[0]} 2>/dev/null`, { encoding: "utf-8" });
return candidate;
} catch {
// Not found, try next
}
}
return null;
}
/**
* Execute an openclaw CLI command and return the result.
* @param subcommands - Array of subcommands, e.g. ["gateway", "call", "chat.history"] or ["sessions"]
* @param args - Additional CLI flags, e.g. ["--json", "--params", '{"sessionKey":"main"}']
*/
async function executeClawCommand(
subcommands: string[],
args: string[],
options?: {
log?: PluginLogger;
timeout?: number;
gatewayUrl?: string;
gatewayToken?: string;
},
): Promise<string> {
const { log, timeout = 30000, gatewayUrl, gatewayToken } = options ?? {};
const cliPath = findOpenClawCli(sessionFileConfig);
if (!cliPath) {
throw new Error("OpenClaw CLI not found");
}
// Build command arguments
const fullArgs = [...args];
if (gatewayUrl) {
fullArgs.push("--url", gatewayUrl);
}
if (gatewayToken) {
fullArgs.push("--token", gatewayToken);
}
const cmdStr = `${cliPath} ${subcommands.join(" ")} ${fullArgs.join(" ")}`;
log?.debug?.(`[session-history] Executing: ${cmdStr}`);
return new Promise((resolve, reject) => {
const parts = cliPath.split(" ");
const binary = parts[0];
const preArgs = parts.slice(1);
// Ensure node's bin directory is in PATH (fixes nvm/pnpm environments
// where the spawned shell may not have node in its PATH)
const nodeDir = path.dirname(process.execPath);
const currentPath = process.env.PATH || "";
const envPath = currentPath.includes(nodeDir)
? currentPath
: `${nodeDir}:${currentPath}`;
const spawnOpts: SpawnOptions = {
timeout,
env: { ...process.env, PATH: envPath },
};
const child = spawn(binary, [...preArgs, ...subcommands, ...fullArgs], spawnOpts);
let stdout = "";
let stderr = "";
child.stdout?.on("data", (data) => {
stdout += data.toString();
});
child.stderr?.on("data", (data) => {
stderr += data.toString();
});
child.on("error", (err) => {
log?.error?.(`[session-history] CLI error: ${err.message}`);
reject(err);
});
child.on("close", (code) => {
if (code !== 0) {
log?.error?.(`[session-history] CLI exited with code ${code}: ${stderr}`);
reject(new Error(`CLI exited with code ${code}: ${stderr}`));
} else {
resolve(stdout);
}
});
});
}
/**
* Get chat history via CLI command
* Uses: openclaw gateway call chat.history --params '{"sessionKey":"<key>","limit":<n>}' --json
*/
export async function getOpenClawChatHistoryViaCli(
sessionKey: string = "main",
options?: {
limit?: number;
log?: PluginLogger;
gatewayUrl?: string;
},
): Promise<ChatHistoryResponse> {
const { limit = 200, log, gatewayUrl } = options ?? {};
log?.info?.(`[session-history] Fetching chat history via CLI for session: ${sessionKey}`);
try {
// Build RPC params object
const params: Record<string, unknown> = {};
if (sessionKey) {
params.sessionKey = sessionKey;
}
if (limit) {
params.limit = limit;
}
// Use: openclaw gateway call chat.history --params '{...}' --json
const args = ["--json", "--params", JSON.stringify(params)];
const result = await executeClawCommand(
["gateway", "call", "chat.history"],
args,
{
log,
gatewayUrl: gatewayUrl || sessionFileConfig.gatewayUrl,
},
);
// Parse JSON result
const parsed = JSON.parse(result) as {
messages?: Array<{
role: string;
content: string | Array<{ type: string; text?: string }>;
id?: string;
timestamp?: string;
}>;
};
const messages: OpenClawMessage[] = (parsed.messages ?? []).map((msg) => ({
role: msg.role as "user" | "assistant" | "system",
content: typeof msg.content === "string"
? msg.content
: msg.content
.filter((p) => p.type === "text" && p.text)
.map((p) => p.text)
.join("\n"),
id: msg.id,
timestamp: msg.timestamp ? new Date(msg.timestamp).getTime() : undefined,
}));
log?.info?.(`[session-history] Got ${messages.length} messages via CLI`);
return {
sessionKey,
messages,
hasMore: false,
};
} catch (err) {
log?.error?.(`[session-history] CLI chat.history failed: ${err}`);
throw err;
}
}
/**
* List sessions via CLI command
* Uses: openclaw sessions --json (local, reads session store directly)
* Falls back to: openclaw gateway call sessions.list --params '{...}' --json (via Gateway RPC)
*/
export async function listOpenClawSessionsViaCli(
options?: {
limit?: number;
includeGlobal?: boolean;
includeUnknown?: boolean;
log?: PluginLogger;
gatewayUrl?: string;
},
): Promise<SessionsListResponse> {
const { limit = 100, includeGlobal = false, includeUnknown = false, log, gatewayUrl } = options ?? {};
log?.info?.(`[session-history] Listing sessions via CLI`);
// Try local "openclaw sessions --json" first (no Gateway needed)
try {
const localArgs = ["--json"];
const result = await executeClawCommand(["sessions"], localArgs, { log });
const parsed = JSON.parse(result) as {
sessions?: Array<{
key: string;
sessionId?: string;
sessionFile?: string;
updatedAt?: number;
label?: string;
displayName?: string;
}>;
count?: number;
};
let sessions: OpenClawSession[] = (parsed.sessions ?? []).map((s) => ({
key: s.key,
sessionId: s.sessionId,
sessionFile: s.sessionFile,
updatedAt: s.updatedAt,
title: s.label || s.displayName,
}));
// Apply limit
if (limit && sessions.length > limit) {
sessions = sessions.slice(0, limit);
}
log?.info?.(`[session-history] Got ${sessions.length} sessions via CLI (local)`);
return {
sessions,
total: parsed.count ?? sessions.length,
};
} catch (localErr) {
log?.debug?.(`[session-history] Local sessions command failed, trying gateway RPC: ${localErr}`);
}
// Fall back to Gateway RPC: openclaw gateway call sessions.list --params '{...}' --json
try {
const params: Record<string, unknown> = {};
if (limit) {
params.limit = limit;
}
if (includeGlobal) {
params.includeGlobal = true;
}
if (includeUnknown) {
params.includeUnknown = true;
}
const args = ["--json", "--params", JSON.stringify(params)];
const result = await executeClawCommand(
["gateway", "call", "sessions.list"],
args,
{
log,
gatewayUrl: gatewayUrl || sessionFileConfig.gatewayUrl,
},
);
const parsed = JSON.parse(result) as {
sessions?: Array<{
key: string;
sessionId?: string;
sessionFile?: string;
updatedAt?: number;
label?: string;
displayName?: string;
}>;
count?: number;
};
const sessions: OpenClawSession[] = (parsed.sessions ?? []).map((s) => ({
key: s.key,
sessionId: s.sessionId,
sessionFile: s.sessionFile,
updatedAt: s.updatedAt,
title: s.label || s.displayName,
}));
log?.info?.(`[session-history] Got ${sessions.length} sessions via CLI (gateway RPC)`);
return {
sessions,
total: parsed.count ?? sessions.length,
};
} catch (err) {
log?.error?.(`[session-history] CLI sessions.list failed: ${err}`);
throw err;
}
}
// ============================================================================
// Auto Backend Selection
// ============================================================================
/**
* Get chat history using the configured backend (auto, file, or cli)
* @param sessionKey - Session key
* @param options - Options
* @returns Chat history
*/
export async function getChatHistory(
sessionKey: string = "main",
options?: {
limit?: number;
log?: PluginLogger;
},
): Promise<ChatHistoryResponse> {
// Only CLI backend is supported
return getOpenClawChatHistoryViaCli(sessionKey, options);
}
/**
* List sessions using the configured backend (auto, file, or cli)
* @param options - Options
* @returns Sessions list
*/
export async function listSessions(
options?: {
limit?: number;
includeLastMessage?: boolean;
includeDerivedTitles?: boolean;
log?: PluginLogger;
backend?: "file" | "cli" | "auto";
},
): Promise<SessionsListResponse> {
const backend = options?.backend || sessionFileConfig.backend || "auto";
const { log } = options ?? {};
if (backend === "cli") {
return listOpenClawSessionsViaCli(options);
}
if (backend === "file") {
return listOpenClawSessions(options);
}
// Auto: try file first, fall back to CLI
try {
const result = await listOpenClawSessions(options);
if (result.sessions.length > 0) {
return result;
}
} catch (err) {
log?.debug?.(`[session-history] File backend failed, trying CLI: ${err}`);
}
// Fall back to CLI
try {
return await listOpenClawSessionsViaCli(options);
} catch (err) {
log?.warn?.(`[session-history] CLI backend also failed: ${err}`);
return {
sessions: [],
total: 0,
};
}
}