| |
| |
| |
| |
| |
| |
|
|
| import fs from "node:fs/promises"; |
| import os from "node:os"; |
| import path from "node:path"; |
| import type { OpenClawConfig } from "../../../config/config.js"; |
| import type { HookHandler } from "../../hooks.js"; |
| import { resolveAgentWorkspaceDir } from "../../../agents/agent-scope.js"; |
| import { resolveStateDir } from "../../../config/paths.js"; |
| import { createSubsystemLogger } from "../../../logging/subsystem.js"; |
| import { resolveAgentIdFromSessionKey } from "../../../routing/session-key.js"; |
| import { hasInterSessionUserProvenance } from "../../../sessions/input-provenance.js"; |
| import { resolveHookConfig } from "../../config.js"; |
| import { generateSlugViaLLM } from "../../llm-slug-generator.js"; |
|
|
| const log = createSubsystemLogger("hooks/session-memory"); |
|
|
| |
| |
| |
| async function getRecentSessionContent( |
| sessionFilePath: string, |
| messageCount: number = 15, |
| ): Promise<string | null> { |
| try { |
| const content = await fs.readFile(sessionFilePath, "utf-8"); |
| const lines = content.trim().split("\n"); |
|
|
| |
| const allMessages: string[] = []; |
| for (const line of lines) { |
| try { |
| const entry = JSON.parse(line); |
| |
| if (entry.type === "message" && entry.message) { |
| const msg = entry.message; |
| const role = msg.role; |
| if ((role === "user" || role === "assistant") && msg.content) { |
| if (role === "user" && hasInterSessionUserProvenance(msg)) { |
| continue; |
| } |
| |
| const text = Array.isArray(msg.content) |
| ? |
| msg.content.find((c: any) => c.type === "text")?.text |
| : msg.content; |
| if (text && !text.startsWith("/")) { |
| allMessages.push(`${role}: ${text}`); |
| } |
| } |
| } |
| } catch { |
| |
| } |
| } |
|
|
| |
| const recentMessages = allMessages.slice(-messageCount); |
| return recentMessages.join("\n"); |
| } catch { |
| return null; |
| } |
| } |
|
|
| |
| |
| |
| const saveSessionToMemory: HookHandler = async (event) => { |
| |
| if (event.type !== "command" || event.action !== "new") { |
| return; |
| } |
|
|
| try { |
| log.debug("Hook triggered for /new command"); |
|
|
| const context = event.context || {}; |
| const cfg = context.cfg as OpenClawConfig | undefined; |
| const agentId = resolveAgentIdFromSessionKey(event.sessionKey); |
| const workspaceDir = cfg |
| ? resolveAgentWorkspaceDir(cfg, agentId) |
| : path.join(resolveStateDir(process.env, os.homedir), "workspace"); |
| const memoryDir = path.join(workspaceDir, "memory"); |
| await fs.mkdir(memoryDir, { recursive: true }); |
|
|
| |
| const now = new Date(event.timestamp); |
| const dateStr = now.toISOString().split("T")[0]; |
|
|
| |
| const sessionEntry = (context.previousSessionEntry || context.sessionEntry || {}) as Record< |
| string, |
| unknown |
| >; |
| const currentSessionId = sessionEntry.sessionId as string; |
| const currentSessionFile = sessionEntry.sessionFile as string; |
|
|
| log.debug("Session context resolved", { |
| sessionId: currentSessionId, |
| sessionFile: currentSessionFile, |
| hasCfg: Boolean(cfg), |
| }); |
|
|
| const sessionFile = currentSessionFile || undefined; |
|
|
| |
| const hookConfig = resolveHookConfig(cfg, "session-memory"); |
| const messageCount = |
| typeof hookConfig?.messages === "number" && hookConfig.messages > 0 |
| ? hookConfig.messages |
| : 15; |
|
|
| let slug: string | null = null; |
| let sessionContent: string | null = null; |
|
|
| if (sessionFile) { |
| |
| sessionContent = await getRecentSessionContent(sessionFile, messageCount); |
| log.debug("Session content loaded", { |
| length: sessionContent?.length ?? 0, |
| messageCount, |
| }); |
|
|
| |
| const isTestEnv = |
| process.env.OPENCLAW_TEST_FAST === "1" || |
| process.env.VITEST === "true" || |
| process.env.VITEST === "1" || |
| process.env.NODE_ENV === "test"; |
| const allowLlmSlug = !isTestEnv && hookConfig?.llmSlug !== false; |
|
|
| if (sessionContent && cfg && allowLlmSlug) { |
| log.debug("Calling generateSlugViaLLM..."); |
| |
| slug = await generateSlugViaLLM({ sessionContent, cfg }); |
| log.debug("Generated slug", { slug }); |
| } |
| } |
|
|
| |
| if (!slug) { |
| const timeSlug = now.toISOString().split("T")[1].split(".")[0].replace(/:/g, ""); |
| slug = timeSlug.slice(0, 4); |
| log.debug("Using fallback timestamp slug", { slug }); |
| } |
|
|
| |
| const filename = `${dateStr}-${slug}.md`; |
| const memoryFilePath = path.join(memoryDir, filename); |
| log.debug("Memory file path resolved", { |
| filename, |
| path: memoryFilePath.replace(os.homedir(), "~"), |
| }); |
|
|
| |
| const timeStr = now.toISOString().split("T")[1].split(".")[0]; |
|
|
| |
| const sessionId = (sessionEntry.sessionId as string) || "unknown"; |
| const source = (context.commandSource as string) || "unknown"; |
|
|
| |
| const entryParts = [ |
| `# Session: ${dateStr} ${timeStr} UTC`, |
| "", |
| `- **Session Key**: ${event.sessionKey}`, |
| `- **Session ID**: ${sessionId}`, |
| `- **Source**: ${source}`, |
| "", |
| ]; |
|
|
| |
| if (sessionContent) { |
| entryParts.push("## Conversation Summary", "", sessionContent, ""); |
| } |
|
|
| const entry = entryParts.join("\n"); |
|
|
| |
| await fs.writeFile(memoryFilePath, entry, "utf-8"); |
| log.debug("Memory file written successfully"); |
|
|
| |
| const relPath = memoryFilePath.replace(os.homedir(), "~"); |
| log.info(`Session context saved to ${relPath}`); |
| } catch (err) { |
| if (err instanceof Error) { |
| log.error("Failed to save session memory", { |
| errorName: err.name, |
| errorMessage: err.message, |
| stack: err.stack, |
| }); |
| } else { |
| log.error("Failed to save session memory", { error: String(err) }); |
| } |
| } |
| }; |
|
|
| export default saveSessionToMemory; |
|
|