refactor: use compact formatZonedTimestamp for injection
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.
This commit is contained in:
parent
9d28763753
commit
ff713a41e0
@ -97,7 +97,7 @@ function formatUtcTimestamp(date: Date): string {
|
|||||||
return `${yyyy}-${mm}-${dd}T${hh}:${min}Z`;
|
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", {
|
const parts = new Intl.DateTimeFormat("en-US", {
|
||||||
timeZone,
|
timeZone,
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
|
|||||||
@ -1,10 +1,11 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { injectTimestamp, timestampOptsFromConfig } from "./agent-timestamp.js";
|
import { injectTimestamp, timestampOptsFromConfig } from "./agent-timestamp.js";
|
||||||
|
import { formatZonedTimestamp } from "../../auto-reply/envelope.js";
|
||||||
|
|
||||||
describe("injectTimestamp", () => {
|
describe("injectTimestamp", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.useFakeTimers();
|
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"));
|
vi.setSystemTime(new Date("2026-01-29T01:30:00.000Z"));
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -12,45 +13,43 @@ describe("injectTimestamp", () => {
|
|||||||
vi.useRealTimers();
|
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?", {
|
const result = injectTimestamp("Is it the weekend?", {
|
||||||
timezone: "America/New_York",
|
timezone: "America/New_York",
|
||||||
timeFormat: "12",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result).toMatch(/^\[.+\] Is it the weekend\?$/);
|
expect(result).toMatch(/^\[2026-01-28 20:30 EST\] Is it the weekend\?$/);
|
||||||
expect(result).toContain("Wednesday");
|
|
||||||
expect(result).toContain("January 28");
|
|
||||||
expect(result).toContain("2026");
|
|
||||||
expect(result).toContain("8:30 PM");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("formats in 24-hour time when configured", () => {
|
it("uses the same format as channel envelope timestamps", () => {
|
||||||
const result = injectTimestamp("hello", {
|
const now = new Date();
|
||||||
timezone: "America/New_York",
|
const expected = formatZonedTimestamp(now, "America/New_York");
|
||||||
timeFormat: "24",
|
|
||||||
});
|
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).toContain("20:30");
|
||||||
expect(result).not.toContain("PM");
|
expect(result).not.toContain("PM");
|
||||||
|
expect(result).not.toContain("AM");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses the configured timezone", () => {
|
it("uses the configured timezone", () => {
|
||||||
const result = injectTimestamp("hello", {
|
const result = injectTimestamp("hello", { timezone: "America/Chicago" });
|
||||||
timezone: "America/Chicago",
|
|
||||||
timeFormat: "12",
|
|
||||||
});
|
|
||||||
|
|
||||||
// 8:30 PM EST = 7:30 PM CST
|
// 8:30 PM EST = 7:30 PM CST = 19:30
|
||||||
expect(result).toContain("7:30 PM");
|
expect(result).toMatch(/^\[2026-01-28 19:30 CST\]/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("defaults to UTC when no timezone specified", () => {
|
it("defaults to UTC when no timezone specified", () => {
|
||||||
const result = injectTimestamp("hello", {});
|
const result = injectTimestamp("hello", {});
|
||||||
|
|
||||||
// 2026-01-29T01:30:00Z
|
// 2026-01-29T01:30:00Z
|
||||||
expect(result).toContain("January 29"); // UTC date, not EST
|
expect(result).toMatch(/^\[2026-01-29 01:30/);
|
||||||
expect(result).toContain("1:30 AM");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns empty/whitespace messages unchanged", () => {
|
it("returns empty/whitespace messages unchanged", () => {
|
||||||
@ -65,6 +64,13 @@ describe("injectTimestamp", () => {
|
|||||||
expect(result).toBe(enveloped);
|
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", () => {
|
it("does NOT double-stamp messages with cron-injected timestamps", () => {
|
||||||
const cronMessage =
|
const cronMessage =
|
||||||
"[cron:abc123 my-job] do the thing\nCurrent time: Wednesday, January 28th, 2026 — 8:30 PM (America/New_York)";
|
"[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", () => {
|
it("handles midnight correctly", () => {
|
||||||
vi.setSystemTime(new Date("2026-02-01T05:00:00.000Z")); // midnight EST
|
vi.setSystemTime(new Date("2026-02-01T05:00:00.000Z")); // midnight EST
|
||||||
|
|
||||||
const result = injectTimestamp("hello", {
|
const result = injectTimestamp("hello", { timezone: "America/New_York" });
|
||||||
timezone: "America/New_York",
|
|
||||||
timeFormat: "12",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toContain("February 1");
|
expect(result).toMatch(/^\[2026-02-01 00:00 EST\]/);
|
||||||
expect(result).toContain("12:00 AM");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles date boundaries (just before midnight)", () => {
|
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", {
|
const result = injectTimestamp("hello", { timezone: "America/New_York" });
|
||||||
timezone: "America/New_York",
|
|
||||||
timeFormat: "12",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toContain("January 31");
|
expect(result).toMatch(/^\[2026-01-31 23:59 EST\]/);
|
||||||
expect(result).toContain("11:59 PM");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles DST correctly (same UTC hour, different local time)", () => {
|
it("handles DST correctly (same UTC hour, different local time)", () => {
|
||||||
// EST (winter): UTC-5 → 2026-01-15T05:00Z = midnight Jan 15
|
// EST (winter): UTC-5 → 2026-01-15T05:00Z = midnight Jan 15
|
||||||
vi.setSystemTime(new Date("2026-01-15T05:00:00.000Z"));
|
vi.setSystemTime(new Date("2026-01-15T05:00:00.000Z"));
|
||||||
const winter = injectTimestamp("winter", {
|
const winter = injectTimestamp("winter", { timezone: "America/New_York" });
|
||||||
timezone: "America/New_York",
|
expect(winter).toMatch(/^\[2026-01-15 00:00 EST\]/);
|
||||||
timeFormat: "12",
|
|
||||||
});
|
|
||||||
expect(winter).toContain("January 15");
|
|
||||||
expect(winter).toContain("12:00 AM");
|
|
||||||
|
|
||||||
// EDT (summer): UTC-4 → 2026-07-15T04:00Z = midnight Jul 15
|
// EDT (summer): UTC-4 → 2026-07-15T04:00Z = midnight Jul 15
|
||||||
vi.setSystemTime(new Date("2026-07-15T04:00:00.000Z"));
|
vi.setSystemTime(new Date("2026-07-15T04:00:00.000Z"));
|
||||||
const summer = injectTimestamp("summer", {
|
const summer = injectTimestamp("summer", { timezone: "America/New_York" });
|
||||||
timezone: "America/New_York",
|
expect(summer).toMatch(/^\[2026-07-15 00:00 EDT\]/);
|
||||||
timeFormat: "12",
|
|
||||||
});
|
|
||||||
expect(summer).toContain("July 15");
|
|
||||||
expect(summer).toContain("12:00 AM");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("accepts a custom now date", () => {
|
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?", {
|
const result = injectTimestamp("fireworks?", {
|
||||||
timezone: "America/New_York",
|
timezone: "America/New_York",
|
||||||
timeFormat: "12",
|
|
||||||
now: customDate,
|
now: customDate,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result).toContain("July 4");
|
expect(result).toMatch(/^\[2025-07-04 12:00 EDT\]/);
|
||||||
expect(result).toContain("2025");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("timestampOptsFromConfig", () => {
|
describe("timestampOptsFromConfig", () => {
|
||||||
it("extracts timezone and timeFormat from config", () => {
|
it("extracts timezone from config", () => {
|
||||||
const opts = timestampOptsFromConfig({
|
const opts = timestampOptsFromConfig({
|
||||||
agents: {
|
agents: {
|
||||||
defaults: {
|
defaults: {
|
||||||
userTimezone: "America/Chicago",
|
userTimezone: "America/Chicago",
|
||||||
timeFormat: "24",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
expect(opts.timezone).toBe("America/Chicago");
|
expect(opts.timezone).toBe("America/Chicago");
|
||||||
expect(opts.timeFormat).toBe("24");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back gracefully with empty config", () => {
|
it("falls back gracefully with empty config", () => {
|
||||||
const opts = timestampOptsFromConfig({} as any);
|
const opts = timestampOptsFromConfig({} as any);
|
||||||
|
|
||||||
expect(opts.timezone).toBeDefined(); // resolveUserTimezone provides a default
|
expect(opts.timezone).toBeDefined(); // resolveUserTimezone provides a default
|
||||||
expect(opts.timeFormat).toBeUndefined();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,58 +1,56 @@
|
|||||||
import {
|
import { resolveUserTimezone } from "../../agents/date-time.js";
|
||||||
formatUserTime,
|
import { formatZonedTimestamp } from "../../auto-reply/envelope.js";
|
||||||
resolveUserTimeFormat,
|
|
||||||
resolveUserTimezone,
|
|
||||||
} from "../../agents/date-time.js";
|
|
||||||
import type { MoltbotConfig } from "../../config/types.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.
|
* Cron jobs inject "Current time: ..." into their messages.
|
||||||
* Skip injection for those too.
|
* Skip injection for those.
|
||||||
*/
|
*/
|
||||||
const CRON_TIME_PATTERN = /Current time: /;
|
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 {
|
export interface TimestampInjectionOptions {
|
||||||
timezone?: string;
|
timezone?: string;
|
||||||
timeFormat?: "12" | "24";
|
|
||||||
now?: Date;
|
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,
|
* Used by the gateway `agent` and `chat.send` handlers to give TUI, web,
|
||||||
* spawned subagents, sessions_send, heartbeats) date/time awareness without
|
* spawned subagents, `sessions_send`, and heartbeat wake events date/time
|
||||||
* modifying the system prompt (which is cached for stability).
|
* awareness — without modifying the system prompt (which is cached).
|
||||||
*
|
*
|
||||||
* Channel messages (Discord, Telegram, etc.) already have timestamps via
|
* Channel messages (Discord, Telegram, etc.) already have timestamps via
|
||||||
* envelope formatting and take a separate code path — they never reach
|
* 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
|
* @see https://github.com/moltbot/moltbot/issues/3658
|
||||||
*/
|
*/
|
||||||
export function injectTimestamp(message: string, opts?: TimestampInjectionOptions): string {
|
export function injectTimestamp(message: string, opts?: TimestampInjectionOptions): string {
|
||||||
if (!message.trim()) return message;
|
if (!message.trim()) return message;
|
||||||
|
|
||||||
// Already has a channel envelope timestamp
|
// Already has an envelope or injected timestamp
|
||||||
if (ENVELOPE_PATTERN.test(message)) return message;
|
if (TIMESTAMP_ENVELOPE_PATTERN.test(message)) return message;
|
||||||
|
|
||||||
// Already has a cron-injected timestamp
|
// Already has a cron-injected timestamp
|
||||||
if (CRON_TIME_PATTERN.test(message)) return message;
|
if (CRON_TIME_PATTERN.test(message)) return message;
|
||||||
|
|
||||||
const now = opts?.now ?? new Date();
|
const now = opts?.now ?? new Date();
|
||||||
const timezone = opts?.timezone ?? "UTC";
|
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;
|
if (!formatted) return message;
|
||||||
|
|
||||||
return `[${formatted}] ${message}`;
|
return `[${formatted}] ${message}`;
|
||||||
@ -64,6 +62,5 @@ export function injectTimestamp(message: string, opts?: TimestampInjectionOption
|
|||||||
export function timestampOptsFromConfig(cfg: MoltbotConfig): TimestampInjectionOptions {
|
export function timestampOptsFromConfig(cfg: MoltbotConfig): TimestampInjectionOptions {
|
||||||
return {
|
return {
|
||||||
timezone: resolveUserTimezone(cfg.agents?.defaults?.userTimezone),
|
timezone: resolveUserTimezone(cfg.agents?.defaults?.userTimezone),
|
||||||
timeFormat: cfg.agents?.defaults?.timeFormat as "12" | "24" | undefined,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -125,7 +125,6 @@ describe("gateway agent handler", () => {
|
|||||||
agents: {
|
agents: {
|
||||||
defaults: {
|
defaults: {
|
||||||
userTimezone: "America/New_York",
|
userTimezone: "America/New_York",
|
||||||
timeFormat: "12",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@ -164,9 +163,7 @@ describe("gateway agent handler", () => {
|
|||||||
await vi.waitFor(() => expect(mocks.agentCommand).toHaveBeenCalled());
|
await vi.waitFor(() => expect(mocks.agentCommand).toHaveBeenCalled());
|
||||||
|
|
||||||
const callArgs = mocks.agentCommand.mock.calls[0][0];
|
const callArgs = mocks.agentCommand.mock.calls[0][0];
|
||||||
expect(callArgs.message).toMatch(
|
expect(callArgs.message).toBe("[2026-01-28 20:30 EST] Is it the weekend?");
|
||||||
/^\[.*Wednesday.*January 28.*2026.*8:30 PM.*\] Is it the weekend\?$/,
|
|
||||||
);
|
|
||||||
|
|
||||||
mocks.loadConfigReturn = {};
|
mocks.loadConfigReturn = {};
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user