From 8b89980a89c93adef98454641e74cc064275af44 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 15 Jan 2026 22:26:31 +0000 Subject: [PATCH] feat(date-time): standardize time context and tool timestamps --- CHANGELOG.md | 3 + docs/channels/discord.md | 1 + docs/channels/slack.md | 1 + docs/concepts/system-prompt.md | 17 +- docs/concepts/timezone.md | 19 +- docs/date-time.md | 85 +++++++++ docs/gateway/configuration.md | 11 ++ src/agents/cli-runner/helpers.ts | 43 +---- src/agents/date-time.ts | 164 ++++++++++++++++++ src/agents/pi-embedded-runner/compact.ts | 9 +- src/agents/pi-embedded-runner/run/attempt.ts | 12 +- .../pi-embedded-runner/system-prompt.ts | 3 + src/agents/pi-embedded-runner/utils.ts | 39 ----- src/agents/system-prompt.test.ts | 33 +++- src/agents/system-prompt.ts | 22 ++- src/agents/tools/discord-actions-messaging.ts | 71 ++++---- src/agents/tools/discord-actions.test.ts | 76 ++++++++ src/agents/tools/slack-actions.test.ts | 34 ++++ src/agents/tools/slack-actions.ts | 20 ++- src/auto-reply/reply/session-updates.test.ts | 12 +- src/auto-reply/reply/session-updates.ts | 21 +-- src/config/types.agent-defaults.ts | 2 + src/config/zod-schema.agent-defaults.ts | 1 + 23 files changed, 534 insertions(+), 165 deletions(-) create mode 100644 docs/date-time.md create mode 100644 src/agents/date-time.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 19decdf18..12f11f298 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ - Fix: list model picker entries as provider/model pairs for explicit selection. (#970) — thanks @mcinteerj. - Daemon: fix profile-aware service label resolution (env-driven) and add coverage for launchd/systemd/schtasks. (#969) — thanks @bjesuiter. - Docs: clarify multi-gateway rescue bot guidance. (#969) — thanks @bjesuiter. +- Agents: add Current Date & Time system prompt section with configurable time format (auto/12/24). +- Tools: normalize Slack/Discord message timestamps with `timestampMs`/`timestampUtc` while keeping raw provider fields. +- Docs: add Date & Time guide and update prompt/timezone configuration docs. - Fix: guard model fallback against undefined provider/model values. (#954) — thanks @roshanasingh4. - Memory: make `node-llama-cpp` an optional dependency (avoid Node 25 install failures) and improve local-embeddings fallback/errors. - Browser: add `snapshot refs=aria` (Playwright aria-ref ids) for self-resolving refs across `snapshot` → `act`. diff --git a/docs/channels/discord.md b/docs/channels/discord.md index 4f7590c8b..3282279fb 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -363,6 +363,7 @@ The agent can call `discord` with actions like: - `react` / `reactions` (add or list reactions) - `sticker`, `poll`, `permissions` - `readMessages`, `sendMessage`, `editMessage`, `deleteMessage` +- Read/search/pin tool payloads include normalized `timestampMs` (UTC epoch ms) and `timestampUtc` alongside raw Discord `timestamp`. - `threadCreate`, `threadList`, `threadReply` - `pinMessage`, `unpinMessage`, `listPins` - `searchMessages`, `memberInfo`, `roleInfo`, `roleAdd`, `roleRemove`, `emojiList` diff --git a/docs/channels/slack.md b/docs/channels/slack.md index f7f4af044..1260c6286 100644 --- a/docs/channels/slack.md +++ b/docs/channels/slack.md @@ -327,4 +327,5 @@ Slack tool actions can be gated with `channels.slack.actions.*`: - Bot-authored messages are ignored by default; enable via `channels.slack.allowBots` or `channels.slack.channels..allowBots`. - Warning: If you allow replies to other bots (`channels.slack.allowBots=true` or `channels.slack.channels..allowBots=true`), prevent bot-to-bot reply loops with `requireMention`, `channels.slack.channels..users` allowlists, and/or clear guardrails in `AGENTS.md` and `SOUL.md`. - For the Slack tool, reaction removal semantics are in [/tools/reactions](/tools/reactions). +- Read/pin tool payloads include normalized `timestampMs` (UTC epoch ms) and `timestampUtc` alongside raw Slack `ts`. - Attachments are downloaded to the media store when permitted and under the size limit. diff --git a/docs/concepts/system-prompt.md b/docs/concepts/system-prompt.md index 55952d8e6..6c4de3544 100644 --- a/docs/concepts/system-prompt.md +++ b/docs/concepts/system-prompt.md @@ -20,7 +20,7 @@ The prompt is intentionally compact and uses fixed sections: - **Workspace**: working directory (`agents.defaults.workspace`). - **Workspace Files (injected)**: indicates bootstrap files are included below. - **Sandbox** (when enabled): indicates sandboxed runtime, sandbox paths, and whether elevated exec is available. -- **Time**: UTC default + the user’s local time (already converted). +- **Current Date & Time**: user-local time, timezone, and time format. - **Reply Tags**: optional reply tag syntax for supported providers. - **Heartbeats**: heartbeat prompt and ack behavior. - **Runtime**: host, OS, node, model, thinking level (one line). @@ -46,12 +46,19 @@ To inspect how much each injected file contributes (raw vs injected, truncation, ## Time handling -The Time line is compact and explicit: +The system prompt includes a dedicated **Current Date & Time** section when user +time or timezone is known. It is explicit about: -- Assume timestamps are **UTC** unless stated. -- The listed **user time** is already converted to `agents.defaults.userTimezone` (if set). +- The user’s **local time** (already converted). +- The **time zone** used for the conversion. +- The **time format** (12-hour / 24-hour). -Use `agents.defaults.userTimezone` in `~/.clawdbot/clawdbot.json` to change the user time zone. +Configure with: + +- `agents.defaults.userTimezone` +- `agents.defaults.timeFormat` (`auto` | `12` | `24`) + +See [Date & Time](/date-time) for full behavior details. ## Skills diff --git a/docs/concepts/timezone.md b/docs/concepts/timezone.md index 79d651a9d..9d88f1c43 100644 --- a/docs/concepts/timezone.md +++ b/docs/concepts/timezone.md @@ -19,10 +19,15 @@ Inbound messages are wrapped in an envelope like: The timestamp in the envelope is **always UTC**, with minutes precision. -## Tool payloads (raw provider data) +## Tool payloads (raw provider data + normalized fields) Tool calls (`channels.discord.readMessages`, `channels.slack.readMessages`, etc.) return **raw provider timestamps**. -These are typically UTC ISO strings (Discord) or UTC epoch strings (Slack). We do not rewrite them. +We also attach normalized fields for consistency: + +- `timestampMs` (UTC epoch milliseconds) +- `timestampUtc` (ISO 8601 UTC string) + +Raw provider fields are preserved. ## User timezone for the system prompt @@ -31,10 +36,14 @@ unset, Clawdbot resolves the **host timezone at runtime** (no config write). ```json5 { - agent: { userTimezone: "America/Chicago" } + agents: { defaults: { userTimezone: "America/Chicago" } } } ``` The system prompt includes: -- `User timezone: America/Chicago` -- `Current user time: 2026-01-05 15:26` +- `Current Date & Time` section with local time and timezone +- `Time format: 12-hour` or `24-hour` + +You can control the prompt format with `agents.defaults.timeFormat` (`auto` | `12` | `24`). + +See [Date & Time](/date-time) for the full behavior and examples. diff --git a/docs/date-time.md b/docs/date-time.md new file mode 100644 index 000000000..eef73e557 --- /dev/null +++ b/docs/date-time.md @@ -0,0 +1,85 @@ +--- +summary: "Date and time handling across envelopes, prompts, tools, and connectors" +read_when: + - You are changing how timestamps are shown to the model or users + - You are debugging time formatting in messages or system prompt output +--- + +# Date & Time + +Clawdbot uses **UTC for transport timestamps** and **user-local time only in the system prompt**. +We avoid rewriting provider timestamps so tools keep their native semantics. + +## Message envelopes (UTC) + +Inbound messages are wrapped with a UTC timestamp (minute precision): + +``` +[Provider ... 2026-01-05T21:26Z] message text +``` + +This envelope timestamp is **always UTC**, regardless of the host timezone. + +## System prompt: Current Date & Time + +If the user timezone or local time is known, the system prompt includes a dedicated +**Current Date & Time** section: + +``` +Thursday, January 15th, 2026 — 3:07 PM (America/Chicago) +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) + +Queued system events inserted into agent context are prefixed with a UTC timestamp: + +``` +System: [2026-01-12T20:19:17Z] Model switched. +``` + +### Configure user timezone + format + +```json5 +{ + agents: { + defaults: { + userTimezone: "America/Chicago", + timeFormat: "auto" // auto | 12 | 24 + } + } +} +``` + +- `userTimezone` sets the **user-local timezone** for prompt context. +- `timeFormat` controls **12h/24h display** in the prompt. `auto` follows OS prefs. + +## Time format detection (auto) + +When `timeFormat: "auto"`, Clawdbot inspects the OS preference (macOS/Windows) +and falls back to locale formatting. The detected value is **cached per process** +to avoid repeated system calls. + +## Tool payloads + connectors (raw provider time + normalized fields) + +Channel tools return **provider-native timestamps** and add normalized fields for consistency: + +- `timestampMs`: epoch milliseconds (UTC) +- `timestampUtc`: ISO 8601 UTC string + +Raw provider fields are preserved so nothing is lost. + +- Slack: epoch-like strings from the API +- Discord: UTC ISO timestamps +- Telegram/WhatsApp: provider-specific numeric/ISO timestamps + +If you need local time, convert it downstream using the known timezone. + +## Related docs + +- [System Prompt](/concepts/system-prompt) +- [Timezones](/concepts/timezone) +- [Messages](/concepts/messages) diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 5e3d280ed..790e93036 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -1211,6 +1211,17 @@ message envelopes). If unset, Clawdbot uses the host timezone at runtime. } ``` +### `agents.defaults.timeFormat` + +Controls the **time format** shown in the system prompt’s Current Date & Time section. +Default: `auto` (OS preference). + +```json5 +{ + agents: { defaults: { timeFormat: "auto" } } // auto | 12 | 24 +} +``` + ### `messages` Controls inbound/outbound prefixes and optional ack reactions. diff --git a/src/agents/cli-runner/helpers.ts b/src/agents/cli-runner/helpers.ts index 268251fb3..6b3af4858 100644 --- a/src/agents/cli-runner/helpers.ts +++ b/src/agents/cli-runner/helpers.ts @@ -9,6 +9,7 @@ import type { ThinkLevel } from "../../auto-reply/thinking.js"; import type { ClawdbotConfig } from "../../config/config.js"; import type { CliBackendConfig } from "../../config/types.js"; import { runExec } from "../../process/exec.js"; +import { formatUserTime, resolveUserTimeFormat, resolveUserTimezone } from "../date-time.js"; import type { EmbeddedContextFile } from "../pi-embedded-helpers.js"; import { buildAgentSystemPrompt } from "../system-prompt.js"; @@ -69,44 +70,6 @@ export type CliOutput = { usage?: CliUsage; }; -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, - weekday: "long", - 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.weekday || !map.year || !map.month || !map.day || !map.hour || !map.minute) - return undefined; - return `${map.weekday} ${map.year}-${map.month}-${map.day} ${map.hour}:${map.minute}`; - } catch { - return undefined; - } -} - function buildModelAliasLines(cfg?: ClawdbotConfig) { const models = cfg?.agents?.defaults?.models ?? {}; const entries: Array<{ alias: string; model: string }> = []; @@ -134,7 +97,8 @@ export function buildSystemPrompt(params: { modelDisplay: string; }) { const userTimezone = resolveUserTimezone(params.config?.agents?.defaults?.userTimezone); - const userTime = formatUserTime(new Date(), userTimezone); + const userTimeFormat = resolveUserTimeFormat(params.config?.agents?.defaults?.timeFormat); + const userTime = formatUserTime(new Date(), userTimezone, userTimeFormat); return buildAgentSystemPrompt({ workspaceDir: params.workspaceDir, defaultThinkLevel: params.defaultThinkLevel, @@ -153,6 +117,7 @@ export function buildSystemPrompt(params: { modelAliasLines: buildModelAliasLines(params.config), userTimezone, userTime, + userTimeFormat, contextFiles: params.contextFiles, }); } diff --git a/src/agents/date-time.ts b/src/agents/date-time.ts new file mode 100644 index 000000000..1ee5c2d5b --- /dev/null +++ b/src/agents/date-time.ts @@ -0,0 +1,164 @@ +import { execSync } from "node:child_process"; + +export type TimeFormatPreference = "auto" | "12" | "24"; +export type ResolvedTimeFormat = "12" | "24"; + +let cachedTimeFormat: ResolvedTimeFormat | undefined; + +export 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"; +} + +export function resolveUserTimeFormat(preference?: TimeFormatPreference): ResolvedTimeFormat { + if (preference === "12" || preference === "24") return preference; + if (cachedTimeFormat) return cachedTimeFormat; + cachedTimeFormat = detectSystemTimeFormat() ? "24" : "12"; + return cachedTimeFormat; +} + +export function normalizeTimestamp( + raw: unknown, +): { timestampMs: number; timestampUtc: string } | undefined { + if (raw == null) return undefined; + let timestampMs: number | undefined; + + if (raw instanceof Date) { + timestampMs = raw.getTime(); + } else if (typeof raw === "number" && Number.isFinite(raw)) { + timestampMs = raw < 1_000_000_000_000 ? Math.round(raw * 1000) : Math.round(raw); + } else if (typeof raw === "string") { + const trimmed = raw.trim(); + if (!trimmed) return undefined; + if (/^\d+(\.\d+)?$/.test(trimmed)) { + const num = Number(trimmed); + if (Number.isFinite(num)) { + if (trimmed.includes(".")) { + timestampMs = Math.round(num * 1000); + } else if (trimmed.length >= 13) { + timestampMs = Math.round(num); + } else { + timestampMs = Math.round(num * 1000); + } + } + } else { + const parsed = Date.parse(trimmed); + if (!Number.isNaN(parsed)) timestampMs = parsed; + } + } + + if (timestampMs === undefined || !Number.isFinite(timestampMs)) return undefined; + return { timestampMs, timestampUtc: new Date(timestampMs).toISOString() }; +} + +export function withNormalizedTimestamp>( + value: T, + rawTimestamp: unknown, +): T & { timestampMs?: number; timestampUtc?: string } { + const normalized = normalizeTimestamp(rawTimestamp); + if (!normalized) return value; + return { + ...value, + timestampMs: + typeof value.timestampMs === "number" && Number.isFinite(value.timestampMs) + ? value.timestampMs + : normalized.timestampMs, + timestampUtc: + typeof value.timestampUtc === "string" && value.timestampUtc.trim() + ? value.timestampUtc + : normalized.timestampUtc, + }; +} + +function detectSystemTimeFormat(): boolean { + if (process.platform === "darwin") { + try { + const result = execSync("defaults read -g AppleICUForce24HourTime 2>/dev/null", { + encoding: "utf8", + timeout: 500, + }).trim(); + if (result === "1") return true; + if (result === "0") return false; + } catch { + // Not set, fall through + } + } + + if (process.platform === "win32") { + try { + const result = execSync( + 'powershell -Command "(Get-Culture).DateTimeFormat.ShortTimePattern"', + { encoding: "utf8", timeout: 1000 }, + ).trim(); + if (result.startsWith("H")) return true; + if (result.startsWith("h")) return false; + } catch { + // Fall through + } + } + + try { + const sample = new Date(2000, 0, 1, 13, 0); + const formatted = new Intl.DateTimeFormat(undefined, { hour: "numeric" }).format(sample); + return formatted.includes("13"); + } catch { + return false; + } +} + +function ordinalSuffix(day: number): string { + if (day >= 11 && day <= 13) return "th"; + switch (day % 10) { + case 1: + return "st"; + case 2: + return "nd"; + case 3: + return "rd"; + default: + return "th"; + } +} + +export function formatUserTime( + date: Date, + timeZone: string, + format: ResolvedTimeFormat, +): string | undefined { + const use24Hour = format === "24"; + try { + const parts = new Intl.DateTimeFormat("en-US", { + timeZone, + weekday: "long", + year: "numeric", + month: "long", + day: "numeric", + hour: use24Hour ? "2-digit" : "numeric", + minute: "2-digit", + hourCycle: use24Hour ? "h23" : "h12", + }).formatToParts(date); + const map: Record = {}; + for (const part of parts) { + if (part.type !== "literal") map[part.type] = part.value; + } + if (!map.weekday || !map.year || !map.month || !map.day || !map.hour || !map.minute) + return undefined; + const dayNum = parseInt(map.day, 10); + const suffix = ordinalSuffix(dayNum); + const timePart = use24Hour + ? `${map.hour}:${map.minute}` + : `${map.hour}:${map.minute} ${map.dayPeriod ?? ""}`.trim(); + return `${map.weekday}, ${map.month} ${dayNum}${suffix}, ${map.year} — ${timePart}`; + } catch { + return undefined; + } +} diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index de871ebee..3cffa1c08 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -53,12 +53,11 @@ import { prewarmSessionFile, trackSessionManagerAccess } from "./session-manager import { buildEmbeddedSystemPrompt, createSystemPromptOverride } from "./system-prompt.js"; import { splitSdkTools } from "./tool-split.js"; import type { EmbeddedPiCompactResult } from "./types.js"; +import { formatUserTime, resolveUserTimeFormat, resolveUserTimezone } from "../date-time.js"; import { describeUnknownError, - formatUserTime, mapThinkingLevel, resolveExecToolDefaults, - resolveUserTimezone, } from "./utils.js"; export async function compactEmbeddedPiSession(params: { @@ -228,7 +227,10 @@ export async function compactEmbeddedPiSession(params: { const sandboxInfo = buildEmbeddedSandboxInfo(sandbox, params.bashElevated); const reasoningTagHint = isReasoningTagProvider(provider); const userTimezone = resolveUserTimezone(params.config?.agents?.defaults?.userTimezone); - const userTime = formatUserTime(new Date(), userTimezone); + const userTimeFormat = resolveUserTimeFormat( + params.config?.agents?.defaults?.timeFormat, + ); + const userTime = formatUserTime(new Date(), userTimezone, userTimeFormat); const { defaultAgentId, sessionAgentId } = resolveSessionAgentIds({ sessionKey: params.sessionKey, config: params.config, @@ -251,6 +253,7 @@ export async function compactEmbeddedPiSession(params: { modelAliasLines: buildModelAliasLines(params.config), userTimezone, userTime, + userTimeFormat, contextFiles, }); const systemPrompt = createSystemPromptOverride(appendPrompt); diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 1cd259205..ca7a38adb 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -58,12 +58,8 @@ import { prewarmSessionFile, trackSessionManagerAccess } from "../session-manage import { prepareSessionManagerForRun } from "../session-manager-init.js"; import { buildEmbeddedSystemPrompt, createSystemPromptOverride } from "../system-prompt.js"; import { splitSdkTools } from "../tool-split.js"; -import { - formatUserTime, - mapThinkingLevel, - resolveExecToolDefaults, - resolveUserTimezone, -} from "../utils.js"; +import { formatUserTime, resolveUserTimeFormat, resolveUserTimezone } from "../../date-time.js"; +import { mapThinkingLevel, resolveExecToolDefaults } from "../utils.js"; import { resolveSandboxRuntimeStatus } from "../../sandbox/runtime-status.js"; import type { EmbeddedRunAttemptParams, EmbeddedRunAttemptResult } from "./types.js"; @@ -186,7 +182,8 @@ export async function runEmbeddedAttempt( const sandboxInfo = buildEmbeddedSandboxInfo(sandbox, params.bashElevated); const reasoningTagHint = isReasoningTagProvider(params.provider); const userTimezone = resolveUserTimezone(params.config?.agents?.defaults?.userTimezone); - const userTime = formatUserTime(new Date(), userTimezone); + const userTimeFormat = resolveUserTimeFormat(params.config?.agents?.defaults?.timeFormat); + const userTime = formatUserTime(new Date(), userTimezone, userTimeFormat); const { defaultAgentId, sessionAgentId } = resolveSessionAgentIds({ sessionKey: params.sessionKey, config: params.config, @@ -211,6 +208,7 @@ export async function runEmbeddedAttempt( modelAliasLines: buildModelAliasLines(params.config), userTimezone, userTime, + userTimeFormat, contextFiles, }); const systemPromptReport = buildSystemPromptReport({ diff --git a/src/agents/pi-embedded-runner/system-prompt.ts b/src/agents/pi-embedded-runner/system-prompt.ts index 0c07006ce..ea0163f44 100644 --- a/src/agents/pi-embedded-runner/system-prompt.ts +++ b/src/agents/pi-embedded-runner/system-prompt.ts @@ -1,4 +1,5 @@ import type { AgentTool } from "@mariozechner/pi-agent-core"; +import type { ResolvedTimeFormat } from "../date-time.js"; import type { EmbeddedContextFile } from "../pi-embedded-helpers.js"; import { buildAgentSystemPrompt } from "../system-prompt.js"; import { buildToolSummaryMap } from "../tool-summaries.js"; @@ -33,6 +34,7 @@ export function buildEmbeddedSystemPrompt(params: { modelAliasLines: string[]; userTimezone: string; userTime?: string; + userTimeFormat?: ResolvedTimeFormat; contextFiles?: EmbeddedContextFile[]; }): string { return buildAgentSystemPrompt({ @@ -52,6 +54,7 @@ export function buildEmbeddedSystemPrompt(params: { modelAliasLines: params.modelAliasLines, userTimezone: params.userTimezone, userTime: params.userTime, + userTimeFormat: params.userTimeFormat, contextFiles: params.contextFiles, }); } diff --git a/src/agents/pi-embedded-runner/utils.ts b/src/agents/pi-embedded-runner/utils.ts index ebb72ed4c..4da0b9a2b 100644 --- a/src/agents/pi-embedded-runner/utils.ts +++ b/src/agents/pi-embedded-runner/utils.ts @@ -17,45 +17,6 @@ export function resolveExecToolDefaults(config?: ClawdbotConfig): ExecToolDefaul return { ...tools.bash, ...tools.exec }; } -export 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"; -} - -export function formatUserTime(date: Date, timeZone: string): string | undefined { - try { - const parts = new Intl.DateTimeFormat("en-CA", { - timeZone, - weekday: "long", - 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.weekday || !map.year || !map.month || !map.day || !map.hour || !map.minute) { - return undefined; - } - return `${map.weekday} ${map.year}-${map.month}-${map.day} ${map.hour}:${map.minute}`; - } catch { - return undefined; - } -} - export function describeUnknownError(error: unknown): string { if (error instanceof Error) return error.message; if (typeof error === "string") return error; diff --git a/src/agents/system-prompt.test.ts b/src/agents/system-prompt.test.ts index 7a43b17dc..499480089 100644 --- a/src/agents/system-prompt.test.ts +++ b/src/agents/system-prompt.test.ts @@ -71,15 +71,42 @@ describe("buildAgentSystemPrompt", () => { ); }); - it("includes user time when provided", () => { + it("includes user time when provided (12-hour)", () => { const prompt = buildAgentSystemPrompt({ workspaceDir: "/tmp/clawd", userTimezone: "America/Chicago", - userTime: "Monday 2026-01-05 15:26", + userTime: "Monday, January 5th, 2026 — 3:26 PM", + userTimeFormat: "12", }); + expect(prompt).toContain("## Current Date & Time"); + expect(prompt).toContain("Monday, January 5th, 2026 — 3:26 PM (America/Chicago)"); + expect(prompt).toContain("Time format: 12-hour"); + }); + + it("includes user time when provided (24-hour)", () => { + const prompt = buildAgentSystemPrompt({ + workspaceDir: "/tmp/clawd", + userTimezone: "America/Chicago", + userTime: "Monday, January 5th, 2026 — 15:26", + userTimeFormat: "24", + }); + + expect(prompt).toContain("## Current Date & Time"); + expect(prompt).toContain("Monday, January 5th, 2026 — 15:26 (America/Chicago)"); + expect(prompt).toContain("Time format: 24-hour"); + }); + + it("shows UTC fallback when only timezone is provided", () => { + const prompt = buildAgentSystemPrompt({ + workspaceDir: "/tmp/clawd", + userTimezone: "America/Chicago", + userTimeFormat: "24", + }); + + expect(prompt).toContain("## Current Date & Time"); expect(prompt).toContain( - "Time: assume UTC unless stated. User time zone: America/Chicago. Current user time (local, 24-hour): Monday 2026-01-05 15:26 (America/Chicago).", + "Time zone: America/Chicago. Current time unknown; assume UTC for date/time references.", ); }); diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index 650620410..083f93b1f 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -1,6 +1,7 @@ import type { ReasoningLevel, ThinkLevel } from "../auto-reply/thinking.js"; import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js"; import { listDeliverableMessageChannels } from "../utils/message-channel.js"; +import type { ResolvedTimeFormat } from "./date-time.js"; import type { EmbeddedContextFile } from "./pi-embedded-helpers.js"; export function buildAgentSystemPrompt(params: { @@ -15,6 +16,7 @@ export function buildAgentSystemPrompt(params: { modelAliasLines?: string[]; userTimezone?: string; userTime?: string; + userTimeFormat?: ResolvedTimeFormat; contextFiles?: EmbeddedContextFile[]; skillsPrompt?: string; heartbeatPrompt?: string; @@ -312,17 +314,21 @@ export function buildAgentSystemPrompt(params: { ownerLine ? "## User Identity" : "", ownerLine ?? "", ownerLine ? "" : "", + ...(userTimezone || userTime + ? [ + "## Current Date & Time", + userTime + ? `${userTime} (${userTimezone ?? "unknown"})` + : `Time zone: ${userTimezone}. Current time unknown; assume UTC for date/time references.`, + params.userTimeFormat + ? `Time format: ${params.userTimeFormat === "24" ? "24-hour" : "12-hour"}` + : "", + "", + ] + : []), "## Workspace Files (injected)", "These user-editable files are loaded by Clawdbot and included below in Project Context.", "", - userTimezone || userTime - ? `Time: assume UTC unless stated. User time zone: ${ - userTimezone ?? "unknown" - }. Current user time (local, 24-hour): ${userTime ?? "unknown"} (${ - userTimezone ?? "unknown" - }).` - : "", - 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/agents/tools/discord-actions-messaging.ts b/src/agents/tools/discord-actions-messaging.ts index 80843bfbb..df1cc7d1c 100644 --- a/src/agents/tools/discord-actions-messaging.ts +++ b/src/agents/tools/discord-actions-messaging.ts @@ -27,32 +27,7 @@ import { readStringArrayParam, readStringParam, } from "./common.js"; - -function formatDiscordTimestamp(ts?: string | null): string | undefined { - if (!ts) return undefined; - const date = 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"); - - // 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}`; -} +import { withNormalizedTimestamp } from "../date-time.js"; function parseDiscordMessageLink(link: string) { const normalized = link.trim(); @@ -76,6 +51,13 @@ export async function handleDiscordMessagingAction( params: Record, isActionEnabled: ActionGate, ): Promise> { + const normalizeMessage = (message: unknown) => { + if (!message || typeof message !== "object") return message; + return withNormalizedTimestamp( + message as Record, + (message as { timestamp?: unknown }).timestamp, + ); + }; switch (action) { case "react": { if (!isActionEnabled("reactions")) { @@ -189,7 +171,13 @@ export async function handleDiscordMessagingAction( ); } const message = await fetchMessageDiscord(channelId, messageId); - return jsonResult({ ok: true, message, guildId, channelId, messageId }); + return jsonResult({ + ok: true, + message: normalizeMessage(message), + guildId, + channelId, + messageId, + }); } case "readMessages": { if (!isActionEnabled("messages")) { @@ -207,11 +195,10 @@ export async function handleDiscordMessagingAction( after: readStringParam(params, "after"), around: readStringParam(params, "around"), }); - const formattedMessages = messages.map((message) => ({ - ...message, - timestamp: formatDiscordTimestamp(message.timestamp) ?? message.timestamp, - })); - return jsonResult({ ok: true, messages: formattedMessages }); + return jsonResult({ + ok: true, + messages: messages.map((message) => normalizeMessage(message)), + }); } case "sendMessage": { if (!isActionEnabled("messages")) { @@ -357,7 +344,7 @@ export async function handleDiscordMessagingAction( required: true, }); const pins = await listPinsDiscord(channelId); - return jsonResult({ ok: true, pins }); + return jsonResult({ ok: true, pins: pins.map((pin) => normalizeMessage(pin)) }); } case "searchMessages": { if (!isActionEnabled("search")) { @@ -386,7 +373,23 @@ export async function handleDiscordMessagingAction( authorIds: authorIdList.length ? authorIdList : undefined, limit, }); - return jsonResult({ ok: true, results }); + if (!results || typeof results !== "object") { + return jsonResult({ ok: true, results }); + } + const resultsRecord = results as Record; + const messages = resultsRecord.messages; + const normalizedMessages = Array.isArray(messages) + ? messages.map((group) => + Array.isArray(group) ? group.map((msg) => normalizeMessage(msg)) : group, + ) + : messages; + return jsonResult({ + ok: true, + results: { + ...resultsRecord, + messages: normalizedMessages, + }, + }); } default: throw new Error(`Unknown action: ${action}`); diff --git a/src/agents/tools/discord-actions.test.ts b/src/agents/tools/discord-actions.test.ts index a49c4e724..74059ba57 100644 --- a/src/agents/tools/discord-actions.test.ts +++ b/src/agents/tools/discord-actions.test.ts @@ -17,6 +17,7 @@ const editChannelDiscord = vi.fn(async () => ({ name: "edited", })); const editMessageDiscord = vi.fn(async () => ({})); +const fetchMessageDiscord = vi.fn(async () => ({})); const fetchChannelPermissionsDiscord = vi.fn(async () => ({})); const fetchReactionsDiscord = vi.fn(async () => ({})); const listPinsDiscord = vi.fn(async () => ({})); @@ -42,6 +43,7 @@ vi.mock("../../discord/send.js", () => ({ deleteMessageDiscord: (...args: unknown[]) => deleteMessageDiscord(...args), editChannelDiscord: (...args: unknown[]) => editChannelDiscord(...args), editMessageDiscord: (...args: unknown[]) => editMessageDiscord(...args), + fetchMessageDiscord: (...args: unknown[]) => fetchMessageDiscord(...args), fetchChannelPermissionsDiscord: (...args: unknown[]) => fetchChannelPermissionsDiscord(...args), fetchReactionsDiscord: (...args: unknown[]) => fetchReactionsDiscord(...args), listPinsDiscord: (...args: unknown[]) => listPinsDiscord(...args), @@ -134,6 +136,80 @@ describe("handleDiscordMessagingAction", () => { ), ).rejects.toThrow(/Discord reactions are disabled/); }); + + it("adds normalized timestamps to readMessages payloads", async () => { + readMessagesDiscord.mockResolvedValueOnce([ + { id: "1", timestamp: "2026-01-15T10:00:00.000Z" }, + ]); + + const result = await handleDiscordMessagingAction( + "readMessages", + { channelId: "C1" }, + enableAllActions, + ); + const payload = result.details as { messages: Array<{ timestampMs?: number; timestampUtc?: string }> }; + + const expectedMs = Date.parse("2026-01-15T10:00:00.000Z"); + expect(payload.messages[0].timestampMs).toBe(expectedMs); + expect(payload.messages[0].timestampUtc).toBe(new Date(expectedMs).toISOString()); + }); + + it("adds normalized timestamps to fetchMessage payloads", async () => { + fetchMessageDiscord.mockResolvedValueOnce({ + id: "1", + timestamp: "2026-01-15T11:00:00.000Z", + }); + + const result = await handleDiscordMessagingAction( + "fetchMessage", + { guildId: "G1", channelId: "C1", messageId: "M1" }, + enableAllActions, + ); + const payload = result.details as { message?: { timestampMs?: number; timestampUtc?: string } }; + + const expectedMs = Date.parse("2026-01-15T11:00:00.000Z"); + expect(payload.message?.timestampMs).toBe(expectedMs); + expect(payload.message?.timestampUtc).toBe(new Date(expectedMs).toISOString()); + }); + + it("adds normalized timestamps to listPins payloads", async () => { + listPinsDiscord.mockResolvedValueOnce([ + { id: "1", timestamp: "2026-01-15T12:00:00.000Z" }, + ]); + + const result = await handleDiscordMessagingAction( + "listPins", + { channelId: "C1" }, + enableAllActions, + ); + const payload = result.details as { pins: Array<{ timestampMs?: number; timestampUtc?: string }> }; + + const expectedMs = Date.parse("2026-01-15T12:00:00.000Z"); + expect(payload.pins[0].timestampMs).toBe(expectedMs); + expect(payload.pins[0].timestampUtc).toBe(new Date(expectedMs).toISOString()); + }); + + it("adds normalized timestamps to searchMessages payloads", async () => { + searchMessagesDiscord.mockResolvedValueOnce({ + total_results: 1, + messages: [[{ id: "1", timestamp: "2026-01-15T13:00:00.000Z" }]], + }); + + const result = await handleDiscordMessagingAction( + "searchMessages", + { guildId: "G1", content: "hi" }, + enableAllActions, + ); + const payload = result.details as { + results?: { messages?: Array> }; + }; + + const expectedMs = Date.parse("2026-01-15T13:00:00.000Z"); + expect(payload.results?.messages?.[0]?.[0]?.timestampMs).toBe(expectedMs); + expect(payload.results?.messages?.[0]?.[0]?.timestampUtc).toBe( + new Date(expectedMs).toISOString(), + ); + }); }); const channelsEnabled = (key: keyof DiscordActionConfig) => key === "channels"; diff --git a/src/agents/tools/slack-actions.test.ts b/src/agents/tools/slack-actions.test.ts index bba2f1d07..f9ffe72f0 100644 --- a/src/agents/tools/slack-actions.test.ts +++ b/src/agents/tools/slack-actions.test.ts @@ -325,4 +325,38 @@ describe("handleSlackAction", () => { threadTs: "1111111111.111111", }); }); + + it("adds normalized timestamps to readMessages payloads", async () => { + const cfg = { channels: { slack: { botToken: "tok" } } } as ClawdbotConfig; + readSlackMessages.mockResolvedValueOnce({ + messages: [{ ts: "1735689600.456", text: "hi" }], + hasMore: false, + }); + + const result = await handleSlackAction({ action: "readMessages", channelId: "C1" }, cfg); + const payload = result.details as { messages: Array<{ timestampMs?: number; timestampUtc?: string }> }; + + const expectedMs = Math.round(1735689600.456 * 1000); + expect(payload.messages[0].timestampMs).toBe(expectedMs); + expect(payload.messages[0].timestampUtc).toBe(new Date(expectedMs).toISOString()); + }); + + it("adds normalized timestamps to pin payloads", async () => { + const cfg = { channels: { slack: { botToken: "tok" } } } as ClawdbotConfig; + listSlackPins.mockResolvedValueOnce([ + { + type: "message", + message: { ts: "1735689600.789", text: "pinned" }, + }, + ]); + + const result = await handleSlackAction({ action: "listPins", channelId: "C1" }, cfg); + const payload = result.details as { + pins: Array<{ message?: { timestampMs?: number; timestampUtc?: string } }>; + }; + + const expectedMs = Math.round(1735689600.789 * 1000); + expect(payload.pins[0].message?.timestampMs).toBe(expectedMs); + expect(payload.pins[0].message?.timestampUtc).toBe(new Date(expectedMs).toISOString()); + }); }); diff --git a/src/agents/tools/slack-actions.ts b/src/agents/tools/slack-actions.ts index a4e6e0add..9b14bd850 100644 --- a/src/agents/tools/slack-actions.ts +++ b/src/agents/tools/slack-actions.ts @@ -17,6 +17,7 @@ import { sendSlackMessage, unpinSlackMessage, } from "../../slack/actions.js"; +import { withNormalizedTimestamp } from "../date-time.js"; import { createActionGate, jsonResult, readReactionParams, readStringParam } from "./common.js"; const messagingActions = new Set(["sendMessage", "editMessage", "deleteMessage", "readMessages"]); @@ -197,7 +198,13 @@ export async function handleSlackAction( before: before ?? undefined, after: after ?? undefined, }); - return jsonResult({ ok: true, ...result }); + const messages = result.messages.map((message) => + withNormalizedTimestamp( + message as Record, + (message as { ts?: unknown }).ts, + ), + ); + return jsonResult({ ok: true, messages, hasMore: result.hasMore }); } default: break; @@ -234,7 +241,16 @@ export async function handleSlackAction( const pins = accountOpts ? await listSlackPins(channelId, accountOpts) : await listSlackPins(channelId); - return jsonResult({ ok: true, pins }); + const normalizedPins = pins.map((pin) => { + const message = pin.message + ? withNormalizedTimestamp( + pin.message as Record, + (pin.message as { ts?: unknown }).ts, + ) + : pin.message; + return message ? { ...pin, message } : pin; + }); + return jsonResult({ ok: true, pins: normalizedPins }); } if (action === "memberInfo") { diff --git a/src/auto-reply/reply/session-updates.test.ts b/src/auto-reply/reply/session-updates.test.ts index 1583e887a..4287c9c9e 100644 --- a/src/auto-reply/reply/session-updates.test.ts +++ b/src/auto-reply/reply/session-updates.test.ts @@ -5,7 +5,7 @@ import { enqueueSystemEvent, resetSystemEventsForTest } from "../../infra/system import { prependSystemEvents } from "./session-updates.js"; describe("prependSystemEvents", () => { - it("adds a local timestamp to queued system events", async () => { + it("adds a UTC timestamp to queued system events", async () => { vi.useFakeTimers(); const timestamp = new Date("2026-01-12T20:19:17"); vi.setSystemTime(timestamp); @@ -20,15 +20,7 @@ describe("prependSystemEvents", () => { prefixedBodyBase: "User: hi", }); - const expectedTimestamp = timestamp.toLocaleString("en-US", { - hour12: false, - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - }); + const expectedTimestamp = "2026-01-12T20:19:17Z"; expect(result).toContain(`System: [${expectedTimestamp}] Model switched.`); diff --git a/src/auto-reply/reply/session-updates.ts b/src/auto-reply/reply/session-updates.ts index 896044908..c4149e646 100644 --- a/src/auto-reply/reply/session-updates.ts +++ b/src/auto-reply/reply/session-updates.ts @@ -25,16 +25,17 @@ export async function prependSystemEvents(params: { return trimmed; }; - const formatSystemEventTimestamp = (ts: number) => - new Date(ts).toLocaleString("en-US", { - hour12: false, - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - }); + const formatSystemEventTimestamp = (ts: number) => { + const date = new Date(ts); + if (Number.isNaN(date.getTime())) return "unknown-time"; + 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"); + const sec = String(date.getUTCSeconds()).padStart(2, "0"); + return `${yyyy}-${mm}-${dd}T${hh}:${min}:${sec}Z`; + }; const systemLines: string[] = []; const queued = drainSystemEventEntries(params.sessionKey); diff --git a/src/config/types.agent-defaults.ts b/src/config/types.agent-defaults.ts index 4caeda1c7..a42292984 100644 --- a/src/config/types.agent-defaults.ts +++ b/src/config/types.agent-defaults.ts @@ -103,6 +103,8 @@ export type AgentDefaultsConfig = { bootstrapMaxChars?: number; /** Optional IANA timezone for the user (used in system prompt; defaults to host timezone). */ userTimezone?: string; + /** Time format in system prompt: auto (OS preference), 12-hour, or 24-hour. */ + timeFormat?: "auto" | "12" | "24"; /** Optional display-only context window override (used for % in status UIs). */ contextTokens?: number; /** Optional CLI backends for text-only fallback (claude-cli, etc.). */ diff --git a/src/config/zod-schema.agent-defaults.ts b/src/config/zod-schema.agent-defaults.ts index 7050f398f..d868de080 100644 --- a/src/config/zod-schema.agent-defaults.ts +++ b/src/config/zod-schema.agent-defaults.ts @@ -41,6 +41,7 @@ export const AgentDefaultsSchema = z skipBootstrap: z.boolean().optional(), bootstrapMaxChars: z.number().int().positive().optional(), userTimezone: z.string().optional(), + timeFormat: z.union([z.literal("auto"), z.literal("12"), z.literal("24")]).optional(), contextTokens: z.number().int().positive().optional(), cliBackends: z.record(z.string(), CliBackendSchema).optional(), memorySearch: MemorySearchSchema,