import fs from "node:fs/promises"; import path from "node:path"; type AnyObj = Record; function asObj(v: unknown): AnyObj { return v && typeof v === "object" ? (v as AnyObj) : {}; } function extractTextMessageContent(content: unknown): string | undefined { if (typeof content === "string") return content; if (!Array.isArray(content)) return; const chunks: string[] = []; for (const block of content) { if (!block || typeof block !== "object") continue; const candidate = block as { type?: unknown; text?: unknown }; if (candidate.type === "text" && typeof candidate.text === "string") { chunks.push(candidate.text); } } const out = chunks.join("\n").trim(); return out.length ? out : undefined; } function stripResetSuffix(fileName: string): string { const i = fileName.indexOf(".reset."); return i === -1 ? fileName : fileName.slice(0, i); } async function findPreviousSessionFile(params: { sessionsDir: string; currentSessionFile?: string; sessionId?: string; }): Promise { try { const files = await fs.readdir(params.sessionsDir); const fileSet = new Set(files); const baseFromReset = params.currentSessionFile ? stripResetSuffix(path.basename(params.currentSessionFile)) : undefined; if (baseFromReset && fileSet.has(baseFromReset)) { return path.join(params.sessionsDir, baseFromReset); } const trimmedSessionId = params.sessionId?.trim(); if (trimmedSessionId) { const canonicalFile = `${trimmedSessionId}.jsonl`; if (fileSet.has(canonicalFile)) return path.join(params.sessionsDir, canonicalFile); const topicVariants = files .filter( (name) => name.startsWith(`${trimmedSessionId}-topic-`) && name.endsWith(".jsonl") && !name.includes(".reset."), ) .sort() .reverse(); if (topicVariants.length > 0) { return path.join(params.sessionsDir, topicVariants[0]!); } } if (!params.currentSessionFile) return; const nonResetJsonl = files .filter((name) => name.endsWith(".jsonl") && !name.includes(".reset.")) .sort() .reverse(); if (nonResetJsonl.length > 0) { return path.join(params.sessionsDir, nonResetJsonl[0]!); } } catch { // ignore } return undefined; } async function resolveSessionFile(params: { workspaceDir: string; currentSessionFile?: string; sessionId?: string; }): Promise { let current = params.currentSessionFile; if (current && !current.includes(".reset.")) return current; const dirs = new Set(); if (current) dirs.add(path.dirname(current)); dirs.add(path.join(params.workspaceDir, "sessions")); for (const sessionsDir of dirs) { const recovered = await findPreviousSessionFile({ sessionsDir, currentSessionFile: current, sessionId: params.sessionId, }); if (recovered) return recovered; } return current; } async function getFullSessionContent(sessionFilePath: string): Promise { try { const raw = await fs.readFile(sessionFilePath, "utf-8"); const lines = raw.trim().split("\n"); const allMessages: string[] = []; for (const line of lines) { try { const entry = JSON.parse(line) as AnyObj; if (entry.type !== "message") continue; const message = asObj(entry.message); const role = message.role; if (role !== "user" && role !== "assistant") continue; const text = extractTextMessageContent(message.content); if (!text) continue; if (text.startsWith("/")) continue; allMessages.push(`${String(role)}: ${text}`); } catch { // ignore broken line } } if (allMessages.length === 0) return null; return allMessages.join("\n\n"); } catch { return null; } } function resolveTimezone(cfg: AnyObj): string { const raw = asObj(asObj(cfg.agents).defaults).userTimezone; const tz = typeof raw === "string" ? raw.trim() : ""; if (tz) { try { Intl.DateTimeFormat("en-US", { timeZone: tz }).format(new Date()); return tz; } catch { // ignore invalid timezone } } return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; } function formatDatePartsInTimezone(date: Date, timeZone: string): { dateStr: string; timeStr: string } { const dtf = new Intl.DateTimeFormat("en-CA", { timeZone, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false, hourCycle: "h23", }); const parts = dtf.formatToParts(date); const get = (type: Intl.DateTimeFormatPartTypes) => parts.find((p) => p.type === type)?.value ?? "00"; const dateStr = `${get("year")}-${get("month")}-${get("day")}`; const timeStr = `${get("hour")}:${get("minute")}:${get("second")}`; return { dateStr, timeStr }; } function slugifyLocal(text: string): string { return text .toLowerCase() .replace(/[^a-z0-9\s-]/g, " ") .replace(/\s+/g, "-") .replace(/-+/g, "-") .replace(/^-|-$/g, "") .slice(0, 80); } function buildSlugExcerpt(full: string): string { const lines = full .split("\n") .map((s) => s.trim()) .filter(Boolean); const tail = lines.slice(-24).join("\n"); return tail.slice(-3000); } async function getUniqueFilePath(dir: string, filename: string): Promise { const candidate = path.join(dir, filename); try { await fs.access(candidate); } catch { return candidate; } const parsed = path.parse(filename); for (let i = 1; ; i += 1) { const name = `${parsed.name}-${i}${parsed.ext}`; const p = path.join(dir, name); try { await fs.access(p); } catch { return p; } } } async function generateSlug(params: { excerpt: string; cfg: AnyObj; fallbackDate: Date; timeZone: string; }): Promise { const { excerpt, cfg, fallbackDate, timeZone } = params; if (excerpt.trim()) { try { const mod = await import("/root/.nvm/versions/node/v25.8.1/lib/node_modules/openclaw/dist/llm-slug-generator.js"); const fn = (mod as { generateSlugViaLLM?: (p: { sessionContent: string; cfg: unknown }) => Promise }) .generateSlugViaLLM; if (typeof fn === "function") { const slug = await fn({ sessionContent: excerpt, cfg }); if (slug && typeof slug === "string" && slug.trim()) return slugifyLocal(slug); } } catch { // fall through to deterministic fallback } const det = slugifyLocal(excerpt.split("\n").join(" ")); if (det) return det.split("-").slice(0, 8).join("-"); } const { timeStr } = formatDatePartsInTimezone(fallbackDate, timeZone); return timeStr.replace(/:/g, "").slice(0, 4); } const handler = async (event: { type: string; action: string; sessionKey: string; timestamp: Date; context?: AnyObj; }) => { if (event.type !== "command" || (event.action !== "new" && event.action !== "reset")) return; try { const context = asObj(event.context); const cfg = asObj(context.cfg); const workspaceDirRaw = context.workspaceDir; const workspaceDir = typeof workspaceDirRaw === "string" && workspaceDirRaw.trim().length > 0 ? workspaceDirRaw : "/root/.openclaw/workspace"; const sessionEntry = asObj(context.previousSessionEntry ?? context.sessionEntry); const sessionId = typeof sessionEntry.sessionId === "string" ? sessionEntry.sessionId : "unknown"; const initialSessionFile = typeof sessionEntry.sessionFile === "string" && sessionEntry.sessionFile.trim().length > 0 ? sessionEntry.sessionFile : undefined; const sessionFile = await resolveSessionFile({ workspaceDir, currentSessionFile: initialSessionFile, sessionId, }); if (!sessionFile) return; const fullContext = await getFullSessionContent(sessionFile); if (!fullContext) return; const timeZone = resolveTimezone(cfg); const now = new Date(event.timestamp); const { dateStr, timeStr } = formatDatePartsInTimezone(now, timeZone); const excerpt = buildSlugExcerpt(fullContext); const slug = await generateSlug({ excerpt, cfg, fallbackDate: now, timeZone }); const memoryDir = path.join(workspaceDir, "memory"); await fs.mkdir(memoryDir, { recursive: true }); const filename = `${dateStr}-${slug}.md`; const memoryFilePath = await getUniqueFilePath(memoryDir, filename); const source = typeof context.commandSource === "string" ? context.commandSource : "unknown"; const out = [ `# Session: ${dateStr} ${timeStr} (${timeZone})`, "", `- **Session Key**: ${event.sessionKey}`, `- **Session ID**: ${sessionId}`, `- **Source**: ${source}`, "", "## Full Session Context", "", fullContext, "", ].join("\n"); await fs.writeFile(memoryFilePath, out, "utf-8"); console.log(`[session-memory-cus] Saved session context: ${memoryFilePath}`); } catch (err) { const msg = err instanceof Error ? `${err.name}: ${err.message}` : String(err); console.error(`[session-memory-cus] Failed: ${msg}`); } }; export default handler;