diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c755959c..d5e457d2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ Docs: https://docs.clawd.bot - MSTeams: add file uploads, adaptive cards, and attachment handling improvements. (#1410) Thanks @Evizero. ### Breaking -- **BREAKING:** Envelope timestamps now default to host-local time (was UTC) so agents don’t have to constantly convert. +- **BREAKING:** Envelope and system event timestamps now default to host-local time (was UTC) so agents don’t have to constantly convert. ### Fixes - Config: avoid stack traces for invalid configs and log the config path. diff --git a/docs/date-time.md b/docs/date-time.md index 97383ef38..8b711350d 100644 --- a/docs/date-time.md +++ b/docs/date-time.md @@ -74,12 +74,13 @@ Time format: 12-hour If only the timezone is known, we still include the section and instruct the model to assume UTC for unknown time references. -## System event lines (UTC) +## System event lines (local by default) -Queued system events inserted into agent context are prefixed with a UTC timestamp: +Queued system events inserted into agent context are prefixed with a timestamp using the +same timezone selection as message envelopes (default: host-local). ``` -System: [2026-01-12T20:19:17Z] Model switched. +System: [2026-01-12 12:19:17 PST] Model switched. ``` ### Configure user timezone + format diff --git a/src/auto-reply/reply/session-updates.test.ts b/src/auto-reply/reply/session-updates.test.ts index 05def80da..d673e2b4f 100644 --- a/src/auto-reply/reply/session-updates.test.ts +++ b/src/auto-reply/reply/session-updates.test.ts @@ -5,8 +5,10 @@ import { enqueueSystemEvent, resetSystemEventsForTest } from "../../infra/system import { prependSystemEvents } from "./session-updates.js"; describe("prependSystemEvents", () => { - it("adds a UTC timestamp to queued system events", async () => { + it("adds a local timestamp to queued system events by default", async () => { vi.useFakeTimers(); + const originalTz = process.env.TZ; + process.env.TZ = "America/Los_Angeles"; const timestamp = new Date("2026-01-12T20:19:17Z"); vi.setSystemTime(timestamp); @@ -20,11 +22,10 @@ describe("prependSystemEvents", () => { prefixedBodyBase: "User: hi", }); - const expectedTimestamp = "2026-01-12T20:19:17Z"; - - expect(result).toContain(`System: [${expectedTimestamp}] Model switched.`); + expect(result).toMatch(/System: \[2026-01-12 12:19:17 [^\]]+\] Model switched\./); resetSystemEventsForTest(); + process.env.TZ = originalTz; vi.useRealTimers(); }); }); diff --git a/src/auto-reply/reply/session-updates.ts b/src/auto-reply/reply/session-updates.ts index 227e61cd5..e5ad81d8e 100644 --- a/src/auto-reply/reply/session-updates.ts +++ b/src/auto-reply/reply/session-updates.ts @@ -1,5 +1,6 @@ import crypto from "node:crypto"; +import { resolveUserTimezone } from "../../agents/date-time.js"; import { buildWorkspaceSkillSnapshot } from "../../agents/skills.js"; import { ensureSkillsWatcher, getSkillsSnapshotVersion } from "../../agents/skills/refresh.js"; import type { ClawdbotConfig } from "../../config/config.js"; @@ -27,9 +28,32 @@ export async function prependSystemEvents(params: { return trimmed; }; - const formatSystemEventTimestamp = (ts: number) => { - const date = new Date(ts); - if (Number.isNaN(date.getTime())) return "unknown-time"; + const resolveExplicitTimezone = (value: string): string | undefined => { + try { + new Intl.DateTimeFormat("en-US", { timeZone: value }).format(new Date()); + return value; + } catch { + return undefined; + } + }; + + const resolveSystemEventTimezone = (cfg: ClawdbotConfig) => { + const raw = cfg.agents?.defaults?.envelopeTimezone?.trim(); + if (!raw) return { mode: "local" as const }; + const lowered = raw.toLowerCase(); + if (lowered === "utc" || lowered === "gmt") return { mode: "utc" as const }; + if (lowered === "local" || lowered === "host") return { mode: "local" as const }; + if (lowered === "user") { + return { + mode: "iana" as const, + timeZone: resolveUserTimezone(cfg.agents?.defaults?.userTimezone), + }; + } + const explicit = resolveExplicitTimezone(raw); + return explicit ? { mode: "iana" as const, timeZone: explicit } : { mode: "local" as const }; + }; + + const formatUtcTimestamp = (date: Date): string => { const yyyy = String(date.getUTCFullYear()).padStart(4, "0"); const mm = String(date.getUTCMonth() + 1).padStart(2, "0"); const dd = String(date.getUTCDate()).padStart(2, "0"); @@ -39,6 +63,42 @@ export async function prependSystemEvents(params: { return `${yyyy}-${mm}-${dd}T${hh}:${min}:${sec}Z`; }; + const formatZonedTimestamp = (date: Date, timeZone?: string): string | undefined => { + const parts = new Intl.DateTimeFormat("en-US", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hourCycle: "h23", + timeZoneName: "short", + }).formatToParts(date); + const pick = (type: string) => parts.find((part) => part.type === type)?.value; + const yyyy = pick("year"); + const mm = pick("month"); + const dd = pick("day"); + const hh = pick("hour"); + const min = pick("minute"); + const sec = pick("second"); + const tz = [...parts] + .reverse() + .find((part) => part.type === "timeZoneName") + ?.value?.trim(); + if (!yyyy || !mm || !dd || !hh || !min || !sec) return undefined; + return `${yyyy}-${mm}-${dd} ${hh}:${min}:${sec}${tz ? ` ${tz}` : ""}`; + }; + + const formatSystemEventTimestamp = (ts: number, cfg: ClawdbotConfig) => { + const date = new Date(ts); + if (Number.isNaN(date.getTime())) return "unknown-time"; + const zone = resolveSystemEventTimezone(cfg); + if (zone.mode === "utc") return formatUtcTimestamp(date); + if (zone.mode === "local") return formatZonedTimestamp(date) ?? "unknown-time"; + return formatZonedTimestamp(date, zone.timeZone) ?? "unknown-time"; + }; + const systemLines: string[] = []; const queued = drainSystemEventEntries(params.sessionKey); systemLines.push( @@ -46,7 +106,7 @@ export async function prependSystemEvents(params: { .map((event) => { const compacted = compactSystemEvent(event.text); if (!compacted) return null; - return `[${formatSystemEventTimestamp(event.ts)}] ${compacted}`; + return `[${formatSystemEventTimestamp(event.ts, params.cfg)}] ${compacted}`; }) .filter((v): v is string => Boolean(v)), );