feat(date-time): standardize time context and tool timestamps

This commit is contained in:
Peter Steinberger
2026-01-15 22:26:31 +00:00
parent 634a429c50
commit 8b89980a89
23 changed files with 534 additions and 165 deletions

View File

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

View File

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

View File

@@ -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.<id>.allowBots`.
- Warning: If you allow replies to other bots (`channels.slack.allowBots=true` or `channels.slack.channels.<id>.allowBots=true`), prevent bot-to-bot reply loops with `requireMention`, `channels.slack.channels.<id>.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.

View File

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

View File

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

85
docs/date-time.md Normal file
View File

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

View File

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

View File

@@ -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<string, string> = {};
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,
});
}

164
src/agents/date-time.ts Normal file
View File

@@ -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<T extends Record<string, unknown>>(
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<string, string> = {};
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;
}
}

View File

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

View File

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

View File

@@ -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,
});
}

View File

@@ -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<string, string> = {};
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;

View File

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

View File

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

View File

@@ -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<string, unknown>,
isActionEnabled: ActionGate<DiscordActionConfig>,
): Promise<AgentToolResult<unknown>> {
const normalizeMessage = (message: unknown) => {
if (!message || typeof message !== "object") return message;
return withNormalizedTimestamp(
message as Record<string, unknown>,
(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<string, unknown>;
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}`);

View File

@@ -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<Array<{ timestampMs?: number; timestampUtc?: string }>> };
};
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";

View File

@@ -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());
});
});

View File

@@ -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<string, unknown>,
(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<string, unknown>,
(pin.message as { ts?: unknown }).ts,
)
: pin.message;
return message ? { ...pin, message } : pin;
});
return jsonResult({ ok: true, pins: normalizedPins });
}
if (action === "memberInfo") {

View File

@@ -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.`);

View File

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

View File

@@ -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.). */

View File

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