mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 14:10:51 +00:00
fix(hooks): use local timezone for session-memory filenames (#72408)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
Reference in New Issue
Block a user