commit 49e21fb19cb12d2a60bc00294dff61f792670e20 Author: Luna Date: Wed Apr 8 19:20:51 2026 +0200 first commit diff --git a/HOOK.md b/HOOK.md new file mode 100644 index 0000000..c95c455 --- /dev/null +++ b/HOOK.md @@ -0,0 +1,24 @@ +--- +name: session-memory-cus +description: "Save full session context on /new or /reset with slugged filenames in user timezone" +metadata: + { + "openclaw": + { + "emoji": "🧠", + "events": ["command:new", "command:reset"], + "requires": { "config": ["workspace.dir"] }, + }, + } +--- + +# Session Memory Custom Hook + +Writes full user/assistant transcript context from the previous session into +`/memory/YYYY-MM-DD-slug.md` when `/new` or `/reset` runs. + +Differences from bundled session-memory: + +- Uses full session context in output file +- Uses `agents.defaults.userTimezone` (fallback host timezone) for date/time +- Uses reduced excerpt only for slug generation diff --git a/handler.ts b/handler.ts new file mode 100644 index 0000000..4438685 --- /dev/null +++ b/handler.ts @@ -0,0 +1,302 @@ +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 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 = path.join(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;