fix(hooks): use local timezone for session-memory filenames (#72408)

This commit is contained in:
Vincent Koc
2026-04-26 16:04:10 -07:00
committed by GitHub
parent 41ad03dda4
commit 97e64196a0
5 changed files with 91 additions and 10 deletions

View File

@@ -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.

View File

@@ -173,7 +173,7 @@ openclaw hooks enable <hook-name>
### session-memory details
Extracts the last 15 user/assistant messages, generates a descriptive filename slug via LLM, and saves to `<workspace>/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 `<workspace>/memory/YYYY-MM-DD-slug.md` using the host local date. Requires `workspace.dir` to be configured.
<a id="bootstrap-extra-files"></a>

View File

@@ -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

View File

@@ -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");

View File

@@ -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}`,