feat: standardize timestamps to UTC
This commit is contained in:
@@ -4,6 +4,9 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Breaking
|
||||
- Timestamps in agent envelopes are now UTC (compact `YYYY-MM-DDTHH:mmZ`); removed `messages.timestampPrefix`. Add `agent.userTimezone` to tell the model the user’s local time (system prompt only).
|
||||
|
||||
### Fixes
|
||||
- Onboarding: resolve CLI entrypoint when running via `npx` so gateway daemon install works without a build step.
|
||||
- Linux: auto-attempt lingering during onboarding (try without sudo, fallback to sudo) and prompt on install/restart to keep the gateway alive after logout/idle. Thanks @tobiasbischoff for PR #237.
|
||||
|
||||
@@ -432,16 +432,26 @@ Default: `~/clawd`.
|
||||
If `agent.sandbox` is enabled, non-main sessions can override this with their
|
||||
own per-session workspaces under `agent.sandbox.workspaceRoot`.
|
||||
|
||||
### `agent.userTimezone`
|
||||
|
||||
Sets the user’s timezone for **system prompt context** (not for timestamps in
|
||||
message envelopes). If unset, Clawdbot uses the host timezone at runtime.
|
||||
|
||||
```json5
|
||||
{
|
||||
agent: { userTimezone: "America/Chicago" }
|
||||
}
|
||||
```
|
||||
|
||||
### `messages`
|
||||
|
||||
Controls inbound/outbound prefixes and timestamps.
|
||||
Controls inbound/outbound prefixes.
|
||||
|
||||
```json5
|
||||
{
|
||||
messages: {
|
||||
messagePrefix: "[clawdbot]",
|
||||
responsePrefix: "🦞",
|
||||
timestampPrefix: "Europe/London"
|
||||
responsePrefix: "🦞"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
40
docs/timezone.md
Normal file
40
docs/timezone.md
Normal 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`
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ vi.mock("../config/config.js", async (importOriginal) => {
|
||||
messages: {
|
||||
messagePrefix: undefined,
|
||||
responsePrefix: undefined,
|
||||
timestampPrefix: false,
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ const DEFAULT_CONFIG = {
|
||||
messages: {
|
||||
messagePrefix: undefined,
|
||||
responsePrefix: undefined,
|
||||
timestampPrefix: false,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user