feat: standardize timestamps to UTC

This commit is contained in:
Peter Steinberger
2026-01-05 23:02:13 +00:00
parent f790f3f3ba
commit ac3dedaa1b
15 changed files with 140 additions and 54 deletions

View File

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

View File

@@ -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 users 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: "🦞"
}
}
```

40
docs/timezone.md Normal file
View File

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

View File

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

View File

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

View File

@@ -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: {
"<final>Hey there! What would you like to do next?</final>",
].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.",

View File

@@ -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", () => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -16,7 +16,6 @@ vi.mock("../config/config.js", async (importOriginal) => {
messages: {
messagePrefix: undefined,
responsePrefix: undefined,
timestampPrefix: false,
},
}),
};

View File

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

View File

@@ -13,7 +13,6 @@ const DEFAULT_CONFIG = {
messages: {
messagePrefix: undefined,
responsePrefix: undefined,
timestampPrefix: false,
},
};