diff --git a/CHANGELOG.md b/CHANGELOG.md index 75425cd7f..561d3b69d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ ## Unreleased +### Breaking +- Timestamps in agent envelopes are now UTC (compact `YYYY-MM-DDTHH:mmZ`); removed `messages.timestampPrefix`. Add `agent.userTimezone` to tell the model the user’s local time (system prompt only). + ### Fixes - Onboarding: resolve CLI entrypoint when running via `npx` so gateway daemon install works without a build step. - Linux: auto-attempt lingering during onboarding (try without sudo, fallback to sudo) and prompt on install/restart to keep the gateway alive after logout/idle. Thanks @tobiasbischoff for PR #237. diff --git a/docs/configuration.md b/docs/configuration.md index deb2a0eda..534e90d6b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -432,16 +432,26 @@ Default: `~/clawd`. If `agent.sandbox` is enabled, non-main sessions can override this with their own per-session workspaces under `agent.sandbox.workspaceRoot`. +### `agent.userTimezone` + +Sets the user’s timezone for **system prompt context** (not for timestamps in +message envelopes). If unset, Clawdbot uses the host timezone at runtime. + +```json5 +{ + agent: { userTimezone: "America/Chicago" } +} +``` + ### `messages` -Controls inbound/outbound prefixes and timestamps. +Controls inbound/outbound prefixes. ```json5 { messages: { messagePrefix: "[clawdbot]", - responsePrefix: "🦞", - timestampPrefix: "Europe/London" + responsePrefix: "🦞" } } ``` diff --git a/docs/timezone.md b/docs/timezone.md new file mode 100644 index 000000000..8a9d0ca6a --- /dev/null +++ b/docs/timezone.md @@ -0,0 +1,40 @@ +--- +summary: "Timezone handling for agents, envelopes, and prompts" +read_when: + - You need to understand how timestamps are normalized for the model + - Configuring the user timezone for system prompts +--- + +# Timezones + +Clawdbot standardizes timestamps so the model sees a **single reference time**. + +## Message envelopes (UTC) + +Inbound messages are wrapped in an envelope like: + +``` +[Surface ... 2026-01-05T21:26Z] message text +``` + +The timestamp in the envelope is **always UTC**, with minutes precision. + +## Tool payloads (raw provider data) + +Tool calls (`discord.readMessages`, `slack.readMessages`, etc.) return **raw provider timestamps**. +These are typically UTC ISO strings (Discord) or UTC epoch strings (Slack). We do not rewrite them. + +## User timezone for the system prompt + +Set `agent.userTimezone` to tell the model the user's local time zone. If it is +unset, Clawdbot resolves the **host timezone at runtime** (no config write). + +```json5 +{ + agent: { userTimezone: "America/Chicago" } +} +``` + +The system prompt includes: +- `User timezone: America/Chicago` +- `Current user time: 2026-01-05 15:26` diff --git a/src/agents/pi-embedded-runner.ts b/src/agents/pi-embedded-runner.ts index aec17853b..c1ec121c3 100644 --- a/src/agents/pi-embedded-runner.ts +++ b/src/agents/pi-embedded-runner.ts @@ -116,6 +116,46 @@ function resolveGlobalLane(lane?: string) { return cleaned ? cleaned : "main"; } +function resolveUserTimezone(configured?: string): string { + const trimmed = configured?.trim(); + if (trimmed) { + try { + new Intl.DateTimeFormat("en-US", { timeZone: trimmed }).format( + new Date(), + ); + return trimmed; + } catch { + // ignore invalid timezone + } + } + const host = Intl.DateTimeFormat().resolvedOptions().timeZone; + return host?.trim() || "UTC"; +} + +function formatUserTime(date: Date, timeZone: string): string | undefined { + try { + const parts = new Intl.DateTimeFormat("en-CA", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hourCycle: "h23", + }).formatToParts(date); + const map: Record = {}; + for (const part of parts) { + if (part.type !== "literal") map[part.type] = part.value; + } + if (!map.year || !map.month || !map.day || !map.hour || !map.minute) { + return undefined; + } + return `${map.year}-${map.month}-${map.day} ${map.hour}:${map.minute}`; + } catch { + return undefined; + } +} + export function buildEmbeddedSandboxInfo( sandbox?: Awaited>, ): EmbeddedSandboxInfo | undefined { @@ -398,6 +438,10 @@ export async function runEmbeddedPiAgent(params: { }; const sandboxInfo = buildEmbeddedSandboxInfo(sandbox); const reasoningTagHint = provider === "ollama"; + const userTimezone = resolveUserTimezone( + params.config?.agent?.userTimezone, + ); + const userTime = formatUserTime(new Date(), userTimezone); const systemPrompt = buildSystemPrompt({ appendPrompt: buildAgentSystemPromptAppend({ workspaceDir: resolvedWorkspace, @@ -408,6 +452,8 @@ export async function runEmbeddedPiAgent(params: { runtimeInfo, sandboxInfo, toolNames: tools.map((tool) => tool.name), + userTimezone, + userTime, }), contextFiles, skills: promptSkills, diff --git a/src/agents/system-prompt.test.ts b/src/agents/system-prompt.test.ts index 802feef77..e2bc2fe7b 100644 --- a/src/agents/system-prompt.test.ts +++ b/src/agents/system-prompt.test.ts @@ -46,4 +46,16 @@ describe("buildAgentSystemPromptAppend", () => { expect(prompt).toContain("sessions_send"); expect(prompt).toContain("Unavailable tools (do not call):"); }); + + it("includes user time when provided", () => { + const prompt = buildAgentSystemPromptAppend({ + workspaceDir: "/tmp/clawd", + userTimezone: "America/Chicago", + userTime: "2026-01-05 15:26", + }); + + expect(prompt).toContain("## Time"); + expect(prompt).toContain("User timezone: America/Chicago"); + expect(prompt).toContain("Current user time: 2026-01-05 15:26"); + }); }); diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index 7d97aff24..4528d372d 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -7,6 +7,8 @@ export function buildAgentSystemPromptAppend(params: { ownerNumbers?: string[]; reasoningTagHint?: boolean; toolNames?: string[]; + userTimezone?: string; + userTime?: string; runtimeInfo?: { host?: string; os?: string; @@ -109,6 +111,8 @@ export function buildAgentSystemPromptAppend(params: { "Hey there! What would you like to do next?", ].join(" ") : undefined; + const userTimezone = params.userTimezone?.trim(); + const userTime = params.userTime?.trim(); const runtimeInfo = params.runtimeInfo; const runtimeLines: string[] = []; if (runtimeInfo?.host) runtimeLines.push(`Host: ${runtimeInfo.host}`); @@ -182,6 +186,10 @@ export function buildAgentSystemPromptAppend(params: { "Never send streaming/partial replies to external messaging surfaces; only final replies should be delivered there.", "Clawdbot handles message transport automatically; respond normally and your reply will be delivered to the current chat.", "", + userTimezone || userTime ? "## Time" : "", + userTimezone ? `User timezone: ${userTimezone}` : "", + userTime ? `Current user time: ${userTime}` : "", + userTimezone || userTime ? "" : "", "## Reply Tags", "To request a native reply/quote on supported surfaces, include one tag in your reply:", "- [[reply_to_current]] replies to the triggering message.", diff --git a/src/auto-reply/envelope.test.ts b/src/auto-reply/envelope.test.ts index 90f8b9ef0..d5ae06674 100644 --- a/src/auto-reply/envelope.test.ts +++ b/src/auto-reply/envelope.test.ts @@ -19,12 +19,12 @@ describe("formatAgentEnvelope", () => { process.env.TZ = originalTz; - expect(body).toMatch( - /^\[WebChat user1 mac-mini 10\.0\.0\.5 2025-01-02T03:04\+00:00\{.+\}\] hello$/, + expect(body).toBe( + "[WebChat user1 mac-mini 10.0.0.5 2025-01-02T03:04Z] hello", ); }); - it("formats timestamps in local time (not UTC)", () => { + it("formats timestamps in UTC regardless of local timezone", () => { const originalTz = process.env.TZ; process.env.TZ = "America/Los_Angeles"; @@ -37,9 +37,7 @@ describe("formatAgentEnvelope", () => { process.env.TZ = originalTz; - expect(body).toBe( - "[WebChat 2025-01-01T19:04-08:00{America/Los_Angeles}] hello", - ); + expect(body).toBe("[WebChat 2025-01-02T03:04Z] hello"); }); it("handles missing optional fields", () => { diff --git a/src/auto-reply/envelope.ts b/src/auto-reply/envelope.ts index fc5ad21fe..6238e5c82 100644 --- a/src/auto-reply/envelope.ts +++ b/src/auto-reply/envelope.ts @@ -12,25 +12,15 @@ function formatTimestamp(ts?: number | Date): string | undefined { const date = ts instanceof Date ? ts : new Date(ts); if (Number.isNaN(date.getTime())) return undefined; - const yyyy = String(date.getFullYear()).padStart(4, "0"); - const mm = String(date.getMonth() + 1).padStart(2, "0"); - const dd = String(date.getDate()).padStart(2, "0"); - const hh = String(date.getHours()).padStart(2, "0"); - const min = String(date.getMinutes()).padStart(2, "0"); + 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"); + const hh = String(date.getUTCHours()).padStart(2, "0"); + const min = String(date.getUTCMinutes()).padStart(2, "0"); - // getTimezoneOffset() is minutes *behind* UTC. Flip sign to get ISO offset. - const offsetMinutes = -date.getTimezoneOffset(); - const sign = offsetMinutes >= 0 ? "+" : "-"; - const absOffsetMinutes = Math.abs(offsetMinutes); - const offsetH = String(Math.floor(absOffsetMinutes / 60)).padStart(2, "0"); - const offsetM = String(absOffsetMinutes % 60).padStart(2, "0"); - - const tz = Intl.DateTimeFormat().resolvedOptions().timeZone; - const tzSuffix = tz ? `{${tz}}` : ""; - - // Compact ISO-like *local* timestamp with minutes precision. - // Example: 2025-01-02T03:04-08:00{America/Los_Angeles} - return `${yyyy}-${mm}-${dd}T${hh}:${min}${sign}${offsetH}:${offsetM}${tzSuffix}`; + // Compact ISO-like UTC timestamp with minutes precision. + // Example: 2025-01-02T03:04Z + return `${yyyy}-${mm}-${dd}T${hh}:${min}Z`; } export function formatAgentEnvelope(params: AgentEnvelopeParams): string { diff --git a/src/config/types.ts b/src/config/types.ts index ee1b4a332..3db517bf1 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -445,7 +445,6 @@ export type RoutingConfig = { export type MessagesConfig = { messagePrefix?: string; // Prefix added to all inbound messages (default: "[clawdbot]" if no allowFrom, else "") responsePrefix?: string; // Prefix auto-added to all outbound replies (e.g., "🦞") - timestampPrefix?: boolean | string; // true/false or IANA timezone string (default: true with UTC) }; export type BridgeBindMode = "auto" | "lan" | "tailnet" | "loopback"; @@ -672,6 +671,8 @@ export type ClawdbotConfig = { imageModel?: string; /** Agent working directory (preferred). Used as the default cwd for agent runs. */ workspace?: string; + /** Optional IANA timezone for the user (used in system prompt; defaults to host timezone). */ + userTimezone?: string; /** Optional allowlist for /model (provider/model or model-only). */ allowedModels?: string[]; /** Optional model aliases for /model (alias -> provider/model). */ diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index cce97db56..c047b920e 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -150,7 +150,6 @@ const MessagesSchema = z .object({ messagePrefix: z.string().optional(), responsePrefix: z.string().optional(), - timestampPrefix: z.union([z.boolean(), z.string()]).optional(), }) .optional(); @@ -376,6 +375,7 @@ export const ClawdbotSchema = z.object({ model: z.string().optional(), imageModel: z.string().optional(), workspace: z.string().optional(), + userTimezone: z.string().optional(), allowedModels: z.array(z.string()).optional(), modelAliases: z.record(z.string(), z.string()).optional(), modelFallbacks: z.array(z.string()).optional(), diff --git a/src/telegram/bot.test.ts b/src/telegram/bot.test.ts index 26f82f659..594958389 100644 --- a/src/telegram/bot.test.ts +++ b/src/telegram/bot.test.ts @@ -101,7 +101,7 @@ describe("createTelegramBot", () => { expect(replySpy).toHaveBeenCalledTimes(1); const payload = replySpy.mock.calls[0][0]; expect(payload.Body).toMatch( - /^\[Telegram Ada Lovelace \(@ada_bot\) id:1234 2025-01-09T01:00\+01:00\{Europe\/Vienna\}\]/, + /^\[Telegram Ada Lovelace \(@ada_bot\) id:1234 2025-01-09T00:00Z\]/, ); expect(payload.Body).toContain("hello world"); } finally { diff --git a/src/web/auto-reply.test.ts b/src/web/auto-reply.test.ts index 05fd023a9..02f77c23d 100644 --- a/src/web/auto-reply.test.ts +++ b/src/web/auto-reply.test.ts @@ -465,9 +465,6 @@ describe("web auto-reply", () => { }; setLoadConfigMock(() => ({ - messages: { - timestampPrefix: "UTC", - }, session: { store: store.storePath }, })); @@ -500,11 +497,11 @@ describe("web auto-reply", () => { const firstArgs = resolver.mock.calls[0][0]; const secondArgs = resolver.mock.calls[1][0]; expect(firstArgs.Body).toContain( - "[WhatsApp +1 2025-01-01T01:00+01:00{Europe/Vienna}] [clawdbot] first", + "[WhatsApp +1 2025-01-01T00:00Z] [clawdbot] first", ); expect(firstArgs.Body).not.toContain("second"); expect(secondArgs.Body).toContain( - "[WhatsApp +1 2025-01-01T02:00+01:00{Europe/Vienna}] [clawdbot] second", + "[WhatsApp +1 2025-01-01T01:00Z] [clawdbot] second", ); expect(secondArgs.Body).not.toContain("first"); @@ -1350,7 +1347,6 @@ describe("web auto-reply", () => { messages: { messagePrefix: "[same-phone]", responsePrefix: undefined, - timestampPrefix: false, }, })); @@ -1475,7 +1471,6 @@ describe("web auto-reply", () => { messages: { messagePrefix: undefined, responsePrefix: "🦞", - timestampPrefix: false, }, })); @@ -1520,7 +1515,6 @@ describe("web auto-reply", () => { messages: { messagePrefix: undefined, responsePrefix: "🦞", - timestampPrefix: false, }, })); @@ -1565,7 +1559,6 @@ describe("web auto-reply", () => { messages: { messagePrefix: undefined, responsePrefix: "🦞", - timestampPrefix: false, }, })); @@ -1611,7 +1604,6 @@ describe("web auto-reply", () => { messages: { messagePrefix: undefined, responsePrefix: "🦞", - timestampPrefix: false, }, })); diff --git a/src/web/inbound.media.test.ts b/src/web/inbound.media.test.ts index a1cfb9a6b..fb4fd78ec 100644 --- a/src/web/inbound.media.test.ts +++ b/src/web/inbound.media.test.ts @@ -16,7 +16,6 @@ vi.mock("../config/config.js", async (importOriginal) => { messages: { messagePrefix: undefined, responsePrefix: undefined, - timestampPrefix: false, }, }), }; diff --git a/src/web/monitor-inbox.test.ts b/src/web/monitor-inbox.test.ts index ab8aa4525..483121a3e 100644 --- a/src/web/monitor-inbox.test.ts +++ b/src/web/monitor-inbox.test.ts @@ -16,7 +16,6 @@ const mockLoadConfig = vi.fn().mockReturnValue({ messages: { messagePrefix: undefined, responsePrefix: undefined, - timestampPrefix: false, }, }); @@ -480,7 +479,6 @@ describe("web monitor inbox", () => { messages: { messagePrefix: undefined, responsePrefix: undefined, - timestampPrefix: false, }, }); @@ -536,7 +534,6 @@ describe("web monitor inbox", () => { messages: { messagePrefix: undefined, responsePrefix: undefined, - timestampPrefix: false, }, }); @@ -576,7 +573,6 @@ describe("web monitor inbox", () => { messages: { messagePrefix: undefined, responsePrefix: undefined, - timestampPrefix: false, }, }); @@ -592,7 +588,6 @@ describe("web monitor inbox", () => { messages: { messagePrefix: undefined, responsePrefix: undefined, - timestampPrefix: false, }, }); @@ -628,7 +623,6 @@ describe("web monitor inbox", () => { messages: { messagePrefix: undefined, responsePrefix: undefined, - timestampPrefix: false, }, }); @@ -643,7 +637,6 @@ describe("web monitor inbox", () => { messages: { messagePrefix: undefined, responsePrefix: undefined, - timestampPrefix: false, }, }); @@ -685,7 +678,6 @@ describe("web monitor inbox", () => { messages: { messagePrefix: undefined, responsePrefix: undefined, - timestampPrefix: false, }, }); @@ -720,7 +712,6 @@ describe("web monitor inbox", () => { messages: { messagePrefix: undefined, responsePrefix: undefined, - timestampPrefix: false, }, }); @@ -737,7 +728,6 @@ describe("web monitor inbox", () => { messages: { messagePrefix: undefined, responsePrefix: undefined, - timestampPrefix: false, }, }); @@ -773,7 +763,6 @@ describe("web monitor inbox", () => { messages: { messagePrefix: undefined, responsePrefix: undefined, - timestampPrefix: false, }, }); @@ -840,7 +829,6 @@ it("defaults to self-only when no config is present", async () => { messages: { messagePrefix: undefined, responsePrefix: undefined, - timestampPrefix: false, }, }); diff --git a/src/web/test-helpers.ts b/src/web/test-helpers.ts index 968d7649c..e1c1597aa 100644 --- a/src/web/test-helpers.ts +++ b/src/web/test-helpers.ts @@ -13,7 +13,6 @@ const DEFAULT_CONFIG = { messages: { messagePrefix: undefined, responsePrefix: undefined, - timestampPrefix: false, }, };