diff --git a/src/auto-reply/envelope.ts b/src/auto-reply/envelope.ts index a50235f26..544ec916b 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 new file mode 100644 index 000000000..9ac3f7d13 --- /dev/null +++ b/src/gateway/server-methods/agent-timestamp.test.ts @@ -0,0 +1,141 @@ +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 (01:30 UTC Jan 29) + vi.setSystemTime(new Date("2026-01-29T01:30:00.000Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("prepends a compact timestamp matching formatZonedTimestamp", () => { + const result = injectTimestamp("Is it the weekend?", { + timezone: "America/New_York", + }); + + expect(result).toMatch(/^\[Wed 2026-01-28 20:30 EST\] Is it the weekend\?$/); + }); + + it("uses channel envelope format with DOW prefix", () => { + const now = new Date(); + const expected = formatZonedTimestamp(now, "America/New_York"); + + const result = injectTimestamp("hello", { timezone: "America/New_York" }); + + // DOW prefix + formatZonedTimestamp format + expect(result).toBe(`[Wed ${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" }); + + // 8:30 PM EST = 7:30 PM CST = 19:30 + expect(result).toMatch(/^\[Wed 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).toMatch(/^\[Thu 2026-01-29 01:30/); + }); + + it("returns empty/whitespace messages unchanged", () => { + expect(injectTimestamp("", { timezone: "UTC" })).toBe(""); + expect(injectTimestamp(" ", { timezone: "UTC" })).toBe(" "); + }); + + it("does NOT double-stamp messages with channel envelope timestamps", () => { + const enveloped = "[Discord user1 2026-01-28 20:30 EST] hello there"; + const result = injectTimestamp(enveloped, { timezone: "America/New_York" }); + + expect(result).toBe(enveloped); + }); + + it("does NOT double-stamp messages already injected by us", () => { + const alreadyStamped = "[Wed 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)"; + const result = injectTimestamp(cronMessage, { timezone: "America/New_York" }); + + expect(result).toBe(cronMessage); + }); + + it("handles midnight correctly", () => { + vi.setSystemTime(new Date("2026-02-01T05:00:00.000Z")); // midnight EST + + const result = injectTimestamp("hello", { timezone: "America/New_York" }); + + expect(result).toMatch(/^\[Sun 2026-02-01 00:00 EST\]/); + }); + + it("handles date boundaries (just before midnight)", () => { + vi.setSystemTime(new Date("2026-02-01T04:59:00.000Z")); // 23:59 Jan 31 EST + + const result = injectTimestamp("hello", { timezone: "America/New_York" }); + + expect(result).toMatch(/^\[Sat 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" }); + expect(winter).toMatch(/^\[Thu 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" }); + expect(summer).toMatch(/^\[Wed 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 ET + + const result = injectTimestamp("fireworks?", { + timezone: "America/New_York", + now: customDate, + }); + + expect(result).toMatch(/^\[Fri 2025-07-04 12:00 EDT\]/); + }); +}); + +describe("timestampOptsFromConfig", () => { + it("extracts timezone from config", () => { + const opts = timestampOptsFromConfig({ + agents: { + defaults: { + userTimezone: "America/Chicago", + }, + }, + } as any); + + expect(opts.timezone).toBe("America/Chicago"); + }); + + it("falls back gracefully with empty config", () => { + const opts = timestampOptsFromConfig({} as any); + + expect(opts.timezone).toBeDefined(); // resolveUserTimezone provides a default + }); +}); diff --git a/src/gateway/server-methods/agent-timestamp.ts b/src/gateway/server-methods/agent-timestamp.ts new file mode 100644 index 000000000..3dcb71834 --- /dev/null +++ b/src/gateway/server-methods/agent-timestamp.ts @@ -0,0 +1,72 @@ +import { resolveUserTimezone } from "../../agents/date-time.js"; +import { formatZonedTimestamp } from "../../auto-reply/envelope.js"; +import type { MoltbotConfig } from "../../config/types.js"; + +/** + * Cron jobs inject "Current time: ..." into their messages. + * 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; + now?: Date; +} + +/** + * 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` 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 + * 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 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 formatted = formatZonedTimestamp(now, timezone); + if (!formatted) return message; + + // 3-letter DOW: small models (8B) can't reliably derive day-of-week from + // a date, and may treat a bare "Wed" as a typo. Costs ~1 token. + const dow = new Intl.DateTimeFormat("en-US", { timeZone: timezone, weekday: "short" }).format( + now, + ); + + return `[${dow} ${formatted}] ${message}`; +} + +/** + * Build TimestampInjectionOptions from a MoltbotConfig. + */ +export function timestampOptsFromConfig(cfg: MoltbotConfig): TimestampInjectionOptions { + return { + timezone: resolveUserTimezone(cfg.agents?.defaults?.userTimezone), + }; +} diff --git a/src/gateway/server-methods/agent.test.ts b/src/gateway/server-methods/agent.test.ts index 149ab4a67..669a8aa07 100644 --- a/src/gateway/server-methods/agent.test.ts +++ b/src/gateway/server-methods/agent.test.ts @@ -8,6 +8,7 @@ const mocks = vi.hoisted(() => ({ updateSessionStore: vi.fn(), agentCommand: vi.fn(), registerAgentRunContext: vi.fn(), + loadConfigReturn: {} as Record, })); vi.mock("../session-utils.js", () => ({ @@ -32,7 +33,7 @@ vi.mock("../../commands/agent.js", () => ({ })); vi.mock("../../config/config.js", () => ({ - loadConfig: () => ({}), + loadConfig: () => mocks.loadConfigReturn, })); vi.mock("../../agents/agent-scope.js", () => ({ @@ -115,6 +116,59 @@ describe("gateway agent handler", () => { expect(capturedEntry?.claudeCliSessionId).toBe(existingClaudeCliSessionId); }); + it("injects a timestamp into the message passed to agentCommand", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-29T01:30:00.000Z")); // Wed Jan 28, 8:30 PM EST + mocks.agentCommand.mockReset(); + + mocks.loadConfigReturn = { + agents: { + defaults: { + userTimezone: "America/New_York", + }, + }, + }; + + mocks.loadSessionEntry.mockReturnValue({ + cfg: mocks.loadConfigReturn, + storePath: "/tmp/sessions.json", + entry: { + sessionId: "existing-session-id", + updatedAt: Date.now(), + }, + canonicalKey: "agent:main:main", + }); + mocks.updateSessionStore.mockResolvedValue(undefined); + mocks.agentCommand.mockResolvedValue({ + payloads: [{ text: "ok" }], + meta: { durationMs: 100 }, + }); + + const respond = vi.fn(); + await agentHandlers.agent({ + params: { + message: "Is it the weekend?", + agentId: "main", + sessionKey: "agent:main:main", + idempotencyKey: "test-timestamp-inject", + }, + respond, + context: makeContext(), + req: { type: "req", id: "ts-1", method: "agent" }, + client: null, + isWebchatConnect: () => false, + }); + + // Wait for the async agentCommand call + await vi.waitFor(() => expect(mocks.agentCommand).toHaveBeenCalled()); + + const callArgs = mocks.agentCommand.mock.calls[0][0]; + expect(callArgs.message).toBe("[Wed 2026-01-28 20:30 EST] Is it the weekend?"); + + mocks.loadConfigReturn = {}; + vi.useRealTimers(); + }); + it("handles missing cliSessionIds gracefully", async () => { mocks.loadSessionEntry.mockReturnValue({ cfg: {}, diff --git a/src/gateway/server-methods/agent.ts b/src/gateway/server-methods/agent.ts index d159d1f78..8888dcbc4 100644 --- a/src/gateway/server-methods/agent.ts +++ b/src/gateway/server-methods/agent.ts @@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto"; import { agentCommand } from "../../commands/agent.js"; import { listAgentIds } from "../../agents/agent-scope.js"; import { loadConfig } from "../../config/config.js"; +import { injectTimestamp, timestampOptsFromConfig } from "./agent-timestamp.js"; import { resolveAgentIdFromSessionKey, resolveExplicitAgentSessionKey, @@ -138,6 +139,13 @@ export const agentHandlers: GatewayRequestHandlers = { return; } } + + // Inject timestamp into messages that don't already have one. + // Channel messages (Discord, Telegram, etc.) get timestamps via envelope + // formatting in a separate code path — they never reach this handler. + // See: https://github.com/moltbot/moltbot/issues/3658 + message = injectTimestamp(message, timestampOptsFromConfig(cfg)); + const isKnownGatewayChannel = (value: string): boolean => isGatewayMessageChannel(value); const channelHints = [request.channel, request.replyChannel] .filter((value): value is string => typeof value === "string") diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index 9010a6f21..53a992d3d 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -5,6 +5,7 @@ import path from "node:path"; import { CURRENT_SESSION_VERSION } from "@mariozechner/pi-coding-agent"; import { resolveSessionAgentId } from "../../agents/agent-scope.js"; import { resolveEffectiveMessagesConfig, resolveIdentityName } from "../../agents/identity.js"; +import { injectTimestamp, timestampOptsFromConfig } from "./agent-timestamp.js"; import { resolveThinkingDefault } from "../../agents/model-selection.js"; import { resolveAgentTimeoutMs } from "../../agents/timeout.js"; import { dispatchInboundMessage } from "../../auto-reply/dispatch.js"; @@ -443,9 +444,14 @@ export const chatHandlers: GatewayRequestHandlers = { ); const commandBody = injectThinking ? `/think ${p.thinking} ${parsedMessage}` : parsedMessage; const clientInfo = client?.connect?.client; + // Inject timestamp so agents know the current date/time. + // Only BodyForAgent gets the timestamp — Body stays raw for UI display. + // See: https://github.com/moltbot/moltbot/issues/3658 + const stampedMessage = injectTimestamp(parsedMessage, timestampOptsFromConfig(cfg)); + const ctx: MsgContext = { Body: parsedMessage, - BodyForAgent: parsedMessage, + BodyForAgent: stampedMessage, BodyForCommands: commandBody, RawBody: parsedMessage, CommandBody: commandBody,