From 97e64196a01d533ac5898bff91e557262ef63a92 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 26 Apr 2026 16:04:10 -0700 Subject: [PATCH] fix(hooks): use local timezone for session-memory filenames (#72408) --- CHANGELOG.md | 1 + docs/automation/hooks.md | 2 +- src/hooks/bundled/session-memory/HOOK.md | 4 +- .../bundled/session-memory/handler.test.ts | 23 ++++++ src/hooks/bundled/session-memory/handler.ts | 71 +++++++++++++++++-- 5 files changed, 91 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b2184f65875..a709de1401a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Hooks/session-memory: use the host local timezone for memory filenames, fallback timestamp slugs, and markdown headers instead of UTC dates. Fixes #46703. (#46721) Thanks @Astro-Han. - Feishu: extract quoted/replied interactive-card text across schema 1.0, schema 2.0, i18n, template-variable, and post-format fallback shapes without carrying broad generated/config churn from related parser experiments. (#38776, #60383, #42218, #45936) Thanks @lishuaigit, @lskun, @just2gooo, and @Br1an67. - Exec approvals: accept a symlinked `OPENCLAW_HOME` as the trusted approvals root while still rejecting symlinked `.openclaw` path components below it. (#64663) Thanks @FunJim. - Logging: add top-level `hostname`, flattened `message`, and available `agent_id`, `session_id`, and `channel` fields to file-log JSONL records for multi-agent filtering without removing existing structured log arguments. Fixes #51075. Thanks @stevengonsalvez. diff --git a/docs/automation/hooks.md b/docs/automation/hooks.md index 5976c44ce1f..13db57a7d7d 100644 --- a/docs/automation/hooks.md +++ b/docs/automation/hooks.md @@ -173,7 +173,7 @@ openclaw hooks enable ### session-memory details -Extracts the last 15 user/assistant messages, generates a descriptive filename slug via LLM, and saves to `/memory/YYYY-MM-DD-slug.md`. Requires `workspace.dir` to be configured. +Extracts the last 15 user/assistant messages, generates a descriptive filename slug via LLM, and saves to `/memory/YYYY-MM-DD-slug.md` using the host local date. Requires `workspace.dir` to be configured. diff --git a/src/hooks/bundled/session-memory/HOOK.md b/src/hooks/bundled/session-memory/HOOK.md index b087e8fe164..8130fc91047 100644 --- a/src/hooks/bundled/session-memory/HOOK.md +++ b/src/hooks/bundled/session-memory/HOOK.md @@ -32,7 +32,7 @@ When you run `/new` or `/reset` to start a fresh session: Memory files are created with the following format: ```markdown -# Session: 2026-01-16 14:30:00 UTC +# Session: 2026-01-16 14:30:00 EST - **Session Key**: agent:main:main - **Session ID**: abc123def456 @@ -46,7 +46,7 @@ The LLM generates descriptive slugs based on your conversation: - `2026-01-16-vendor-pitch.md` - Discussion about vendor evaluation - `2026-01-16-api-design.md` - API architecture planning - `2026-01-16-bug-fix.md` - Debugging session -- `2026-01-16-1430.md` - Fallback timestamp if slug generation fails +- `2026-01-16-1430.md` - Fallback local timestamp if slug generation fails ## Requirements diff --git a/src/hooks/bundled/session-memory/handler.test.ts b/src/hooks/bundled/session-memory/handler.test.ts index 36dcb5438a2..e0232122f02 100644 --- a/src/hooks/bundled/session-memory/handler.test.ts +++ b/src/hooks/bundled/session-memory/handler.test.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../../config/config.js"; import { writeWorkspaceFile } from "../../../test-helpers/workspace.js"; +import { withEnvAsync } from "../../../test-utils/env.js"; import { createHookEvent } from "../../hooks.js"; import { findPreviousSessionFile, @@ -71,6 +72,7 @@ async function runNewWithPreviousSessionEntry(params: { action?: "new" | "reset"; sessionKey?: string; workspaceDirOverride?: string; + timestamp?: Date; }): Promise<{ files: string[]; memoryContent: string }> { const event = createHookEvent( "command", @@ -86,6 +88,9 @@ async function runNewWithPreviousSessionEntry(params: { ...(params.workspaceDirOverride ? { workspaceDir: params.workspaceDirOverride } : {}), }, ); + if (params.timestamp) { + event.timestamp = params.timestamp; + } await handler(event); @@ -247,6 +252,24 @@ describe("session-memory hook", () => { expect(memoryContent).toContain("assistant: Captured before reset"); }); + it("uses local timezone date and fallback time in memory filenames and headers", async () => { + await withEnvAsync({ TZ: "America/New_York" }, async () => { + const tempDir = await createCaseWorkspace("workspace"); + + const { files, memoryContent } = await runNewWithPreviousSessionEntry({ + tempDir, + timestamp: new Date("2026-01-01T04:30:15.000Z"), + previousSessionEntry: { + sessionId: "local-time-session", + }, + }); + + expect(files).toEqual(["2025-12-31-2330.md"]); + expect(memoryContent).toMatch(/^# Session: 2025-12-31 23:30:15(?: EST| GMT-5)?/); + expect(memoryContent).not.toContain("# Session: 2026-01-01 04:30:15 UTC"); + }); + }); + it("prefers workspaceDir from hook context when sessionKey points at main", async () => { const mainWorkspace = await createCaseWorkspace("workspace-main"); const naviWorkspace = await createCaseWorkspace("workspace-navi"); diff --git a/src/hooks/bundled/session-memory/handler.ts b/src/hooks/bundled/session-memory/handler.ts index 9236b23ddaa..647127d5fe2 100644 --- a/src/hooks/bundled/session-memory/handler.ts +++ b/src/hooks/bundled/session-memory/handler.ts @@ -28,6 +28,63 @@ import { findPreviousSessionFile, getRecentSessionContentWithResetFallback } fro const log = createSubsystemLogger("hooks/session-memory"); +function pickDateTimePart( + parts: Intl.DateTimeFormatPart[], + type: Intl.DateTimeFormatPartTypes, +): string | undefined { + return parts.find((part) => part.type === type)?.value; +} + +function resolveLocalTimeZone(): string | undefined { + const timeZone = process.env.TZ?.trim(); + if (!timeZone) { + return undefined; + } + try { + new Intl.DateTimeFormat("en-US", { timeZone }).format(new Date()); + return timeZone; + } catch { + return undefined; + } +} + +function formatLocalSessionTimestamp(date: Date): { + date: string; + time: string; + timeSlug: string; + timeZoneName?: string; +} { + const parts = new Intl.DateTimeFormat("en-US", { + timeZone: resolveLocalTimeZone(), + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hourCycle: "h23", + timeZoneName: "short", + }).formatToParts(date); + + const year = pickDateTimePart(parts, "year") ?? String(date.getFullYear()).padStart(4, "0"); + const month = pickDateTimePart(parts, "month") ?? String(date.getMonth() + 1).padStart(2, "0"); + const day = pickDateTimePart(parts, "day") ?? String(date.getDate()).padStart(2, "0"); + const hour = pickDateTimePart(parts, "hour") ?? String(date.getHours()).padStart(2, "0"); + const minute = pickDateTimePart(parts, "minute") ?? String(date.getMinutes()).padStart(2, "0"); + const second = pickDateTimePart(parts, "second") ?? String(date.getSeconds()).padStart(2, "0"); + const timeZoneName = [...parts] + .toReversed() + .find((part) => part.type === "timeZoneName") + ?.value?.trim(); + + return { + date: `${year}-${month}-${day}`, + time: `${hour}:${minute}:${second}`, + timeSlug: `${hour}${minute}`, + timeZoneName, + }; +} + function resolveDisplaySessionKey(params: { cfg?: OpenClawConfig; workspaceDir?: string; @@ -80,9 +137,10 @@ const saveSessionToMemory: HookHandler = async (event) => { const memoryDir = path.join(workspaceDir, "memory"); await fs.mkdir(memoryDir, { recursive: true }); - // Get today's date for filename + // Use the user's local timezone for memory artifact names and headings. const now = new Date(event.timestamp); - const dateStr = now.toISOString().split("T")[0]; // YYYY-MM-DD + const localTimestamp = formatLocalSessionTimestamp(now); + const dateStr = localTimestamp.date; // Generate descriptive slug from session using LLM // Prefer previousSessionEntry (old session before /new) over current (which may be empty) @@ -160,8 +218,7 @@ const saveSessionToMemory: HookHandler = async (event) => { // If no slug, use timestamp if (!slug) { - const timeSlug = now.toISOString().split("T")[1].split(".")[0].replace(/:/g, ""); - slug = timeSlug.slice(0, 4); // HHMM + slug = localTimestamp.timeSlug; log.debug("Using fallback timestamp slug", { slug }); } @@ -173,8 +230,8 @@ const saveSessionToMemory: HookHandler = async (event) => { path: memoryFilePath.replace(os.homedir(), "~"), }); - // Format time as HH:MM:SS UTC - const timeStr = now.toISOString().split("T")[1].split(".")[0]; + const timeStr = localTimestamp.time; + const timeZoneSuffix = localTimestamp.timeZoneName ? ` ${localTimestamp.timeZoneName}` : ""; // Extract context details const sessionId = (sessionEntry.sessionId as string) || "unknown"; @@ -182,7 +239,7 @@ const saveSessionToMemory: HookHandler = async (event) => { // Build Markdown entry const entryParts = [ - `# Session: ${dateStr} ${timeStr} UTC`, + `# Session: ${dateStr} ${timeStr}${timeZoneSuffix}`, "", `- **Session Key**: ${displaySessionKey}`, `- **Session ID**: ${sessionId}`,