From 632713b20806db2342b7f4359dd11c38866f0ec4 Mon Sep 17 00:00:00 2001 From: Gabi Simons Date: Fri, 6 Mar 2026 18:28:29 +0200 Subject: [PATCH] feat: timezone-aware context injection for agent prompts (#691) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: per-group timezone architecture with context injection (#483) Implement a comprehensive timezone consistency layer so the AI agent always receives timestamps in the user's local timezone. The framework handles all UTC↔local conversion transparently — the agent never performs manual timezone math. Key changes: - Per-group timezone stored in containerConfig (no DB migration needed) - Context injection: header prepended to every agent prompt with local time and IANA timezone - Message timestamps converted from UTC to local display in formatMessages() - schedule_task translation layer: agent writes local times, framework converts to UTC using per-group timezone for cron, once, and interval types - Container TZ env var now uses per-group timezone instead of global constant - New set_timezone MCP tool for users to update their timezone dynamically - NANOCLAW_TIMEZONE passed to MCP server environment for tool confirmations Architecture: Store UTC everywhere, convert at boundaries (display to agent, parse from agent). Groups without timezone configured fall back to the server TIMEZONE constant for full backward compatibility. Closes #483 Closes #526 Co-authored-by: shawnYJ Co-authored-by: Adrian Co-authored-by: Claude Opus 4.6 * style: apply prettier formatting Co-authored-by: Claude Opus 4.6 * refactor: strip to minimalist context injection — global TIMEZONE only Remove per-group timezone support, set_timezone MCP tool, and all related IPC handlers. The implementation now uses the global system TIMEZONE for all groups, keeping the diff focused on the message formatting layer: mandatory timezone param in formatMessages(), header injection, and formatLocalTime/formatCurrentTime helpers. Co-Authored-By: Claude Opus 4.6 * refactor: drop formatCurrentTime and simplify context header Address PR review: remove redundant formatCurrentTime() since message timestamps already carry localized times. Simplify header to only include timezone name. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: shawnYJ Co-authored-by: Adrian Co-authored-by: Claude Opus 4.6 --- src/formatting.test.ts | 51 +++++++++++++++++++++++++++++------------- src/index.ts | 6 +++-- src/ipc-auth.test.ts | 28 +++++++++++------------ src/ipc.ts | 6 ++--- src/router.ts | 19 +++++++++++----- src/timezone.test.ts | 29 ++++++++++++++++++++++++ src/timezone.ts | 16 +++++++++++++ 7 files changed, 114 insertions(+), 41 deletions(-) create mode 100644 src/timezone.test.ts create mode 100644 src/timezone.ts diff --git a/src/formatting.test.ts b/src/formatting.test.ts index ea85b9d..8a2160c 100644 --- a/src/formatting.test.ts +++ b/src/formatting.test.ts @@ -58,13 +58,14 @@ describe('escapeXml', () => { // --- formatMessages --- describe('formatMessages', () => { - it('formats a single message as XML', () => { - const result = formatMessages([makeMsg()]); - expect(result).toBe( - '\n' + - 'hello\n' + - '', - ); + const TZ = 'UTC'; + + it('formats a single message as XML with context header', () => { + const result = formatMessages([makeMsg()], TZ); + expect(result).toContain(''); + expect(result).toContain('hello'); + expect(result).toContain('Jan 1, 2024'); }); it('formats multiple messages', () => { @@ -73,11 +74,16 @@ describe('formatMessages', () => { id: '1', sender_name: 'Alice', content: 'hi', - timestamp: 't1', + timestamp: '2024-01-01T00:00:00.000Z', + }), + makeMsg({ + id: '2', + sender_name: 'Bob', + content: 'hey', + timestamp: '2024-01-01T01:00:00.000Z', }), - makeMsg({ id: '2', sender_name: 'Bob', content: 'hey', timestamp: 't2' }), ]; - const result = formatMessages(msgs); + const result = formatMessages(msgs, TZ); expect(result).toContain('sender="Alice"'); expect(result).toContain('sender="Bob"'); expect(result).toContain('>hi'); @@ -85,22 +91,35 @@ describe('formatMessages', () => { }); it('escapes special characters in sender names', () => { - const result = formatMessages([makeMsg({ sender_name: 'A & B ' })]); + const result = formatMessages([makeMsg({ sender_name: 'A & B ' })], TZ); expect(result).toContain('sender="A & B <Co>"'); }); it('escapes special characters in content', () => { - const result = formatMessages([ - makeMsg({ content: '' }), - ]); + const result = formatMessages( + [makeMsg({ content: '' })], + TZ, + ); expect(result).toContain( '<script>alert("xss")</script>', ); }); it('handles empty array', () => { - const result = formatMessages([]); - expect(result).toBe('\n\n'); + const result = formatMessages([], TZ); + expect(result).toContain(''); + expect(result).toContain('\n\n'); + }); + + it('converts timestamps to local time for given timezone', () => { + // 2024-01-01T18:30:00Z in America/New_York (EST) = 1:30 PM + const result = formatMessages( + [makeMsg({ timestamp: '2024-01-01T18:30:00.000Z' })], + 'America/New_York', + ); + expect(result).toContain('1:30'); + expect(result).toContain('PM'); + expect(result).toContain(''); }); }); diff --git a/src/index.ts b/src/index.ts index 85aba50..c35261e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,7 @@ import { ASSISTANT_NAME, IDLE_TIMEOUT, POLL_INTERVAL, + TIMEZONE, TRIGGER_PATTERN, } from './config.js'; import './channels/index.js'; @@ -29,6 +30,7 @@ import { getAllTasks, getMessagesSince, getNewMessages, + getRegisteredGroup, getRouterState, initDatabase, setRegisteredGroup, @@ -170,7 +172,7 @@ async function processGroupMessages(chatJid: string): Promise { if (!hasTrigger) return true; } - const prompt = formatMessages(missedMessages); + const prompt = formatMessages(missedMessages, TIMEZONE); // Advance cursor so the piping path in startMessageLoop won't re-fetch // these messages. Save the old cursor so we can roll back on error. @@ -408,7 +410,7 @@ async function startMessageLoop(): Promise { ); const messagesToSend = allPending.length > 0 ? allPending : groupMessages; - const formatted = formatMessages(messagesToSend); + const formatted = formatMessages(messagesToSend, TIMEZONE); if (queue.sendMessage(chatJid, formatted)) { logger.debug( diff --git a/src/ipc-auth.test.ts b/src/ipc-auth.test.ts index 7edc7db..1aa681e 100644 --- a/src/ipc-auth.test.ts +++ b/src/ipc-auth.test.ts @@ -74,7 +74,7 @@ describe('schedule_task authorization', () => { type: 'schedule_task', prompt: 'do something', schedule_type: 'once', - schedule_value: '2025-06-01T00:00:00.000Z', + schedule_value: '2025-06-01T00:00:00', targetJid: 'other@g.us', }, 'whatsapp_main', @@ -94,7 +94,7 @@ describe('schedule_task authorization', () => { type: 'schedule_task', prompt: 'self task', schedule_type: 'once', - schedule_value: '2025-06-01T00:00:00.000Z', + schedule_value: '2025-06-01T00:00:00', targetJid: 'other@g.us', }, 'other-group', @@ -113,7 +113,7 @@ describe('schedule_task authorization', () => { type: 'schedule_task', prompt: 'unauthorized', schedule_type: 'once', - schedule_value: '2025-06-01T00:00:00.000Z', + schedule_value: '2025-06-01T00:00:00', targetJid: 'main@g.us', }, 'other-group', @@ -131,7 +131,7 @@ describe('schedule_task authorization', () => { type: 'schedule_task', prompt: 'no target', schedule_type: 'once', - schedule_value: '2025-06-01T00:00:00.000Z', + schedule_value: '2025-06-01T00:00:00', targetJid: 'unknown@g.us', }, 'whatsapp_main', @@ -154,7 +154,7 @@ describe('pause_task authorization', () => { chat_jid: 'main@g.us', prompt: 'main task', schedule_type: 'once', - schedule_value: '2025-06-01T00:00:00.000Z', + schedule_value: '2025-06-01T00:00:00', context_mode: 'isolated', next_run: '2025-06-01T00:00:00.000Z', status: 'active', @@ -166,7 +166,7 @@ describe('pause_task authorization', () => { chat_jid: 'other@g.us', prompt: 'other task', schedule_type: 'once', - schedule_value: '2025-06-01T00:00:00.000Z', + schedule_value: '2025-06-01T00:00:00', context_mode: 'isolated', next_run: '2025-06-01T00:00:00.000Z', status: 'active', @@ -215,7 +215,7 @@ describe('resume_task authorization', () => { chat_jid: 'other@g.us', prompt: 'paused task', schedule_type: 'once', - schedule_value: '2025-06-01T00:00:00.000Z', + schedule_value: '2025-06-01T00:00:00', context_mode: 'isolated', next_run: '2025-06-01T00:00:00.000Z', status: 'paused', @@ -264,7 +264,7 @@ describe('cancel_task authorization', () => { chat_jid: 'other@g.us', prompt: 'cancel me', schedule_type: 'once', - schedule_value: '2025-06-01T00:00:00.000Z', + schedule_value: '2025-06-01T00:00:00', context_mode: 'isolated', next_run: null, status: 'active', @@ -287,7 +287,7 @@ describe('cancel_task authorization', () => { chat_jid: 'other@g.us', prompt: 'my task', schedule_type: 'once', - schedule_value: '2025-06-01T00:00:00.000Z', + schedule_value: '2025-06-01T00:00:00', context_mode: 'isolated', next_run: null, status: 'active', @@ -310,7 +310,7 @@ describe('cancel_task authorization', () => { chat_jid: 'main@g.us', prompt: 'not yours', schedule_type: 'once', - schedule_value: '2025-06-01T00:00:00.000Z', + schedule_value: '2025-06-01T00:00:00', context_mode: 'isolated', next_run: null, status: 'active', @@ -565,7 +565,7 @@ describe('schedule_task context_mode', () => { type: 'schedule_task', prompt: 'group context', schedule_type: 'once', - schedule_value: '2025-06-01T00:00:00.000Z', + schedule_value: '2025-06-01T00:00:00', context_mode: 'group', targetJid: 'other@g.us', }, @@ -584,7 +584,7 @@ describe('schedule_task context_mode', () => { type: 'schedule_task', prompt: 'isolated context', schedule_type: 'once', - schedule_value: '2025-06-01T00:00:00.000Z', + schedule_value: '2025-06-01T00:00:00', context_mode: 'isolated', targetJid: 'other@g.us', }, @@ -603,7 +603,7 @@ describe('schedule_task context_mode', () => { type: 'schedule_task', prompt: 'bad context', schedule_type: 'once', - schedule_value: '2025-06-01T00:00:00.000Z', + schedule_value: '2025-06-01T00:00:00', context_mode: 'bogus' as any, targetJid: 'other@g.us', }, @@ -622,7 +622,7 @@ describe('schedule_task context_mode', () => { type: 'schedule_task', prompt: 'no context mode', schedule_type: 'once', - schedule_value: '2025-06-01T00:00:00.000Z', + schedule_value: '2025-06-01T00:00:00', targetJid: 'other@g.us', }, 'whatsapp_main', diff --git a/src/ipc.ts b/src/ipc.ts index e5614ce..7a972c0 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -236,15 +236,15 @@ export async function processTaskIpc( } nextRun = new Date(Date.now() + ms).toISOString(); } else if (scheduleType === 'once') { - const scheduled = new Date(data.schedule_value); - if (isNaN(scheduled.getTime())) { + const date = new Date(data.schedule_value); + if (isNaN(date.getTime())) { logger.warn( { scheduleValue: data.schedule_value }, 'Invalid timestamp', ); break; } - nextRun = scheduled.toISOString(); + nextRun = date.toISOString(); } const taskId = diff --git a/src/router.ts b/src/router.ts index 3c9fbc0..c14ca89 100644 --- a/src/router.ts +++ b/src/router.ts @@ -1,4 +1,5 @@ import { Channel, NewMessage } from './types.js'; +import { formatLocalTime } from './timezone.js'; export function escapeXml(s: string): string { if (!s) return ''; @@ -9,12 +10,18 @@ export function escapeXml(s: string): string { .replace(/"/g, '"'); } -export function formatMessages(messages: NewMessage[]): string { - const lines = messages.map( - (m) => - `${escapeXml(m.content)}`, - ); - return `\n${lines.join('\n')}\n`; +export function formatMessages( + messages: NewMessage[], + timezone: string, +): string { + const lines = messages.map((m) => { + const displayTime = formatLocalTime(m.timestamp, timezone); + return `${escapeXml(m.content)}`; + }); + + const header = `\n`; + + return `${header}\n${lines.join('\n')}\n`; } export function stripInternalTags(text: string): string { diff --git a/src/timezone.test.ts b/src/timezone.test.ts new file mode 100644 index 0000000..df0525f --- /dev/null +++ b/src/timezone.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from 'vitest'; + +import { formatLocalTime } from './timezone.js'; + +// --- formatLocalTime --- + +describe('formatLocalTime', () => { + it('converts UTC to local time display', () => { + // 2026-02-04T18:30:00Z in America/New_York (EST, UTC-5) = 1:30 PM + const result = formatLocalTime( + '2026-02-04T18:30:00.000Z', + 'America/New_York', + ); + expect(result).toContain('1:30'); + expect(result).toContain('PM'); + expect(result).toContain('Feb'); + expect(result).toContain('2026'); + }); + + it('handles different timezones', () => { + // Same UTC time should produce different local times + const utc = '2026-06-15T12:00:00.000Z'; + const ny = formatLocalTime(utc, 'America/New_York'); + const tokyo = formatLocalTime(utc, 'Asia/Tokyo'); + // NY is UTC-4 in summer (EDT), Tokyo is UTC+9 + expect(ny).toContain('8:00'); + expect(tokyo).toContain('9:00'); + }); +}); diff --git a/src/timezone.ts b/src/timezone.ts new file mode 100644 index 0000000..e7569f4 --- /dev/null +++ b/src/timezone.ts @@ -0,0 +1,16 @@ +/** + * Convert a UTC ISO timestamp to a localized display string. + * Uses the Intl API (no external dependencies). + */ +export function formatLocalTime(utcIso: string, timezone: string): string { + const date = new Date(utcIso); + return date.toLocaleString('en-US', { + timeZone: timezone, + year: 'numeric', + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + hour12: true, + }); +}