From ff713a41e01602339ea2fcf0ae8f863c1e39a41d Mon Sep 17 00:00:00 2001 From: Conroy Whitney Date: Wed, 28 Jan 2026 21:59:21 -0500 Subject: [PATCH] refactor: use compact formatZonedTimestamp for injection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace verbose formatUserTime (Wednesday, January 28th, 2026 — 8:30 PM) with the same formatZonedTimestamp used by channel envelopes (2026-01-28 20:30 EST). This: - Saves ~4 tokens per message (~7 vs ~11) - Uses globally unambiguous YYYY-MM-DD 24h format - Removes 12/24h config option (always 24h, agent-facing) - Anchors envelope detection to the actual format function — if channels change their timestamp format, our injection + detection change too - Adds test that compares injection output to formatZonedTimestamp directly Exported formatZonedTimestamp from auto-reply/envelope.ts for reuse. --- src/auto-reply/envelope.ts | 2 +- .../server-methods/agent-timestamp.test.ts | 93 ++++++++----------- src/gateway/server-methods/agent-timestamp.ts | 49 +++++----- src/gateway/server-methods/agent.test.ts | 5 +- 4 files changed, 64 insertions(+), 85 deletions(-) diff --git a/src/auto-reply/envelope.ts b/src/auto-reply/envelope.ts index 357ae3d1f..ae5bf1985 100644 --- a/src/auto-reply/envelope.ts +++ b/src/auto-reply/envelope.ts @@ -97,7 +97,7 @@ function formatUtcTimestamp(date: Date): string { return `${yyyy}-${mm}-${dd}T${hh}:${min}Z`; } -function formatZonedTimestamp(date: Date, timeZone?: string): string | undefined { +export function formatZonedTimestamp(date: Date, timeZone?: string): string | undefined { const parts = new Intl.DateTimeFormat("en-US", { timeZone, year: "numeric", diff --git a/src/gateway/server-methods/agent-timestamp.test.ts b/src/gateway/server-methods/agent-timestamp.test.ts index eb24e02eb..85398c4e4 100644 --- a/src/gateway/server-methods/agent-timestamp.test.ts +++ b/src/gateway/server-methods/agent-timestamp.test.ts @@ -1,10 +1,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { injectTimestamp, timestampOptsFromConfig } from "./agent-timestamp.js"; +import { formatZonedTimestamp } from "../../auto-reply/envelope.js"; describe("injectTimestamp", () => { beforeEach(() => { vi.useFakeTimers(); - // Wednesday, January 28, 2026 at 8:30 PM EST + // Wednesday, January 28, 2026 at 8:30 PM EST (01:30 UTC Jan 29) vi.setSystemTime(new Date("2026-01-29T01:30:00.000Z")); }); @@ -12,45 +13,43 @@ describe("injectTimestamp", () => { vi.useRealTimers(); }); - it("prepends a formatted timestamp to a plain message", () => { + it("prepends a compact timestamp matching formatZonedTimestamp", () => { const result = injectTimestamp("Is it the weekend?", { timezone: "America/New_York", - timeFormat: "12", }); - expect(result).toMatch(/^\[.+\] Is it the weekend\?$/); - expect(result).toContain("Wednesday"); - expect(result).toContain("January 28"); - expect(result).toContain("2026"); - expect(result).toContain("8:30 PM"); + expect(result).toMatch(/^\[2026-01-28 20:30 EST\] Is it the weekend\?$/); }); - it("formats in 24-hour time when configured", () => { - const result = injectTimestamp("hello", { - timezone: "America/New_York", - timeFormat: "24", - }); + it("uses the same format as channel envelope timestamps", () => { + const now = new Date(); + const expected = formatZonedTimestamp(now, "America/New_York"); + + const result = injectTimestamp("hello", { timezone: "America/New_York" }); + + expect(result).toBe(`[${expected}] hello`); + }); + + it("always uses 24-hour format", () => { + const result = injectTimestamp("hello", { timezone: "America/New_York" }); expect(result).toContain("20:30"); expect(result).not.toContain("PM"); + expect(result).not.toContain("AM"); }); it("uses the configured timezone", () => { - const result = injectTimestamp("hello", { - timezone: "America/Chicago", - timeFormat: "12", - }); + const result = injectTimestamp("hello", { timezone: "America/Chicago" }); - // 8:30 PM EST = 7:30 PM CST - expect(result).toContain("7:30 PM"); + // 8:30 PM EST = 7:30 PM CST = 19:30 + expect(result).toMatch(/^\[2026-01-28 19:30 CST\]/); }); it("defaults to UTC when no timezone specified", () => { const result = injectTimestamp("hello", {}); // 2026-01-29T01:30:00Z - expect(result).toContain("January 29"); // UTC date, not EST - expect(result).toContain("1:30 AM"); + expect(result).toMatch(/^\[2026-01-29 01:30/); }); it("returns empty/whitespace messages unchanged", () => { @@ -65,6 +64,13 @@ describe("injectTimestamp", () => { expect(result).toBe(enveloped); }); + it("does NOT double-stamp messages already injected by us", () => { + const alreadyStamped = "[2026-01-28 20:30 EST] hello there"; + const result = injectTimestamp(alreadyStamped, { timezone: "America/New_York" }); + + expect(result).toBe(alreadyStamped); + }); + it("does NOT double-stamp messages with cron-injected timestamps", () => { const cronMessage = "[cron:abc123 my-job] do the thing\nCurrent time: Wednesday, January 28th, 2026 — 8:30 PM (America/New_York)"; @@ -76,80 +82,59 @@ describe("injectTimestamp", () => { it("handles midnight correctly", () => { vi.setSystemTime(new Date("2026-02-01T05:00:00.000Z")); // midnight EST - const result = injectTimestamp("hello", { - timezone: "America/New_York", - timeFormat: "12", - }); + const result = injectTimestamp("hello", { timezone: "America/New_York" }); - expect(result).toContain("February 1"); - expect(result).toContain("12:00 AM"); + expect(result).toMatch(/^\[2026-02-01 00:00 EST\]/); }); it("handles date boundaries (just before midnight)", () => { - vi.setSystemTime(new Date("2026-02-01T04:59:00.000Z")); // 11:59 PM Jan 31 EST + vi.setSystemTime(new Date("2026-02-01T04:59:00.000Z")); // 23:59 Jan 31 EST - const result = injectTimestamp("hello", { - timezone: "America/New_York", - timeFormat: "12", - }); + const result = injectTimestamp("hello", { timezone: "America/New_York" }); - expect(result).toContain("January 31"); - expect(result).toContain("11:59 PM"); + expect(result).toMatch(/^\[2026-01-31 23:59 EST\]/); }); it("handles DST correctly (same UTC hour, different local time)", () => { // EST (winter): UTC-5 → 2026-01-15T05:00Z = midnight Jan 15 vi.setSystemTime(new Date("2026-01-15T05:00:00.000Z")); - const winter = injectTimestamp("winter", { - timezone: "America/New_York", - timeFormat: "12", - }); - expect(winter).toContain("January 15"); - expect(winter).toContain("12:00 AM"); + const winter = injectTimestamp("winter", { timezone: "America/New_York" }); + expect(winter).toMatch(/^\[2026-01-15 00:00 EST\]/); // EDT (summer): UTC-4 → 2026-07-15T04:00Z = midnight Jul 15 vi.setSystemTime(new Date("2026-07-15T04:00:00.000Z")); - const summer = injectTimestamp("summer", { - timezone: "America/New_York", - timeFormat: "12", - }); - expect(summer).toContain("July 15"); - expect(summer).toContain("12:00 AM"); + const summer = injectTimestamp("summer", { timezone: "America/New_York" }); + expect(summer).toMatch(/^\[2026-07-15 00:00 EDT\]/); }); it("accepts a custom now date", () => { - const customDate = new Date("2025-07-04T16:00:00.000Z"); // July 4, noon EST + const customDate = new Date("2025-07-04T16:00:00.000Z"); // July 4, noon ET const result = injectTimestamp("fireworks?", { timezone: "America/New_York", - timeFormat: "12", now: customDate, }); - expect(result).toContain("July 4"); - expect(result).toContain("2025"); + expect(result).toMatch(/^\[2025-07-04 12:00 EDT\]/); }); }); describe("timestampOptsFromConfig", () => { - it("extracts timezone and timeFormat from config", () => { + it("extracts timezone from config", () => { const opts = timestampOptsFromConfig({ agents: { defaults: { userTimezone: "America/Chicago", - timeFormat: "24", }, }, } as any); expect(opts.timezone).toBe("America/Chicago"); - expect(opts.timeFormat).toBe("24"); }); it("falls back gracefully with empty config", () => { const opts = timestampOptsFromConfig({} as any); expect(opts.timezone).toBeDefined(); // resolveUserTimezone provides a default - expect(opts.timeFormat).toBeUndefined(); }); }); diff --git a/src/gateway/server-methods/agent-timestamp.ts b/src/gateway/server-methods/agent-timestamp.ts index e436f1ebb..c8b397734 100644 --- a/src/gateway/server-methods/agent-timestamp.ts +++ b/src/gateway/server-methods/agent-timestamp.ts @@ -1,58 +1,56 @@ -import { - formatUserTime, - resolveUserTimeFormat, - resolveUserTimezone, -} from "../../agents/date-time.js"; +import { resolveUserTimezone } from "../../agents/date-time.js"; +import { formatZonedTimestamp } from "../../auto-reply/envelope.js"; import type { MoltbotConfig } from "../../config/types.js"; -/** - * Envelope pattern used by channel plugins (Discord, Telegram, etc.): - * [Channel sender 2026-01-28 20:31 EST] message text - * - * Messages arriving through channels already have timestamps. - * We skip injection for those to avoid double-stamping. - */ -const ENVELOPE_PATTERN = /^\[[\w]+ .+ \d{4}-\d{2}-\d{2}/; - /** * Cron jobs inject "Current time: ..." into their messages. - * Skip injection for those too. + * Skip injection for those. */ const CRON_TIME_PATTERN = /Current time: /; +/** + * Matches a leading `[... YYYY-MM-DD HH:MM ...]` envelope — either from + * channel plugins or from a previous injection. Uses the same YYYY-MM-DD + * HH:MM format as {@link formatZonedTimestamp}, so detection stays in sync + * with the formatting. + */ +const TIMESTAMP_ENVELOPE_PATTERN = /^\[.*\d{4}-\d{2}-\d{2} \d{2}:\d{2}/; + export interface TimestampInjectionOptions { timezone?: string; - timeFormat?: "12" | "24"; now?: Date; } /** - * Injects a timestamp prefix into a message if one isn't already present. + * Injects a compact timestamp prefix into a message if one isn't already + * present. Uses the same `YYYY-MM-DD HH:MM TZ` format as channel envelope + * timestamps ({@link formatZonedTimestamp}), keeping token cost low (~7 + * tokens) and format consistent across all agent contexts. * - * Used by the gateway agent handler to give all agent contexts (TUI, web, - * spawned subagents, sessions_send, heartbeats) date/time awareness without - * modifying the system prompt (which is cached for stability). + * Used by the gateway `agent` and `chat.send` handlers to give TUI, web, + * spawned subagents, `sessions_send`, and heartbeat wake events date/time + * awareness — without modifying the system prompt (which is cached). * * Channel messages (Discord, Telegram, etc.) already have timestamps via * envelope formatting and take a separate code path — they never reach - * the agent handler, so there's no double-stamping risk. + * these handlers, so there is no double-stamping risk. The detection + * pattern is a safety net for edge cases. * * @see https://github.com/moltbot/moltbot/issues/3658 */ export function injectTimestamp(message: string, opts?: TimestampInjectionOptions): string { if (!message.trim()) return message; - // Already has a channel envelope timestamp - if (ENVELOPE_PATTERN.test(message)) return message; + // Already has an envelope or injected timestamp + if (TIMESTAMP_ENVELOPE_PATTERN.test(message)) return message; // Already has a cron-injected timestamp if (CRON_TIME_PATTERN.test(message)) return message; const now = opts?.now ?? new Date(); const timezone = opts?.timezone ?? "UTC"; - const timeFormat = opts?.timeFormat ?? "12"; - const formatted = formatUserTime(now, timezone, resolveUserTimeFormat(timeFormat)); + const formatted = formatZonedTimestamp(now, timezone); if (!formatted) return message; return `[${formatted}] ${message}`; @@ -64,6 +62,5 @@ export function injectTimestamp(message: string, opts?: TimestampInjectionOption export function timestampOptsFromConfig(cfg: MoltbotConfig): TimestampInjectionOptions { return { timezone: resolveUserTimezone(cfg.agents?.defaults?.userTimezone), - timeFormat: cfg.agents?.defaults?.timeFormat as "12" | "24" | undefined, }; } diff --git a/src/gateway/server-methods/agent.test.ts b/src/gateway/server-methods/agent.test.ts index e5509b85f..a78131631 100644 --- a/src/gateway/server-methods/agent.test.ts +++ b/src/gateway/server-methods/agent.test.ts @@ -125,7 +125,6 @@ describe("gateway agent handler", () => { agents: { defaults: { userTimezone: "America/New_York", - timeFormat: "12", }, }, }; @@ -164,9 +163,7 @@ describe("gateway agent handler", () => { await vi.waitFor(() => expect(mocks.agentCommand).toHaveBeenCalled()); const callArgs = mocks.agentCommand.mock.calls[0][0]; - expect(callArgs.message).toMatch( - /^\[.*Wednesday.*January 28.*2026.*8:30 PM.*\] Is it the weekend\?$/, - ); + expect(callArgs.message).toBe("[2026-01-28 20:30 EST] Is it the weekend?"); mocks.loadConfigReturn = {}; vi.useRealTimers();