feat: timezone-aware context injection for agent prompts (#691)

* 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: <context timezone="..." current_time="..." /> 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 <shawny011717@users.noreply.github.com>
Co-authored-by: Adrian <Lafunamor@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* style: apply prettier formatting

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* 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(),
<context> header injection, and formatLocalTime/formatCurrentTime
helpers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: drop formatCurrentTime and simplify context header

Address PR review: remove redundant formatCurrentTime() since message
timestamps already carry localized times. Simplify <context> header to
only include timezone name.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: shawnYJ <shawny011717@users.noreply.github.com>
Co-authored-by: Adrian <Lafunamor@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Gabi Simons
2026-03-06 18:28:29 +02:00
committed by GitHub
parent 47ad2e654c
commit 632713b208
7 changed files with 114 additions and 41 deletions

View File

@@ -58,13 +58,14 @@ describe('escapeXml', () => {
// --- formatMessages --- // --- formatMessages ---
describe('formatMessages', () => { describe('formatMessages', () => {
it('formats a single message as XML', () => { const TZ = 'UTC';
const result = formatMessages([makeMsg()]);
expect(result).toBe( it('formats a single message as XML with context header', () => {
'<messages>\n' + const result = formatMessages([makeMsg()], TZ);
'<message sender="Alice" time="2024-01-01T00:00:00.000Z">hello</message>\n' + expect(result).toContain('<context timezone="UTC" />');
'</messages>', expect(result).toContain('<message sender="Alice"');
); expect(result).toContain('>hello</message>');
expect(result).toContain('Jan 1, 2024');
}); });
it('formats multiple messages', () => { it('formats multiple messages', () => {
@@ -73,11 +74,16 @@ describe('formatMessages', () => {
id: '1', id: '1',
sender_name: 'Alice', sender_name: 'Alice',
content: 'hi', 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="Alice"');
expect(result).toContain('sender="Bob"'); expect(result).toContain('sender="Bob"');
expect(result).toContain('>hi</message>'); expect(result).toContain('>hi</message>');
@@ -85,22 +91,35 @@ describe('formatMessages', () => {
}); });
it('escapes special characters in sender names', () => { it('escapes special characters in sender names', () => {
const result = formatMessages([makeMsg({ sender_name: 'A & B <Co>' })]); const result = formatMessages([makeMsg({ sender_name: 'A & B <Co>' })], TZ);
expect(result).toContain('sender="A &amp; B &lt;Co&gt;"'); expect(result).toContain('sender="A &amp; B &lt;Co&gt;"');
}); });
it('escapes special characters in content', () => { it('escapes special characters in content', () => {
const result = formatMessages([ const result = formatMessages(
makeMsg({ content: '<script>alert("xss")</script>' }), [makeMsg({ content: '<script>alert("xss")</script>' })],
]); TZ,
);
expect(result).toContain( expect(result).toContain(
'&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;', '&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;',
); );
}); });
it('handles empty array', () => { it('handles empty array', () => {
const result = formatMessages([]); const result = formatMessages([], TZ);
expect(result).toBe('<messages>\n\n</messages>'); expect(result).toContain('<context timezone="UTC" />');
expect(result).toContain('<messages>\n\n</messages>');
});
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('<context timezone="America/New_York" />');
}); });
}); });

View File

@@ -5,6 +5,7 @@ import {
ASSISTANT_NAME, ASSISTANT_NAME,
IDLE_TIMEOUT, IDLE_TIMEOUT,
POLL_INTERVAL, POLL_INTERVAL,
TIMEZONE,
TRIGGER_PATTERN, TRIGGER_PATTERN,
} from './config.js'; } from './config.js';
import './channels/index.js'; import './channels/index.js';
@@ -29,6 +30,7 @@ import {
getAllTasks, getAllTasks,
getMessagesSince, getMessagesSince,
getNewMessages, getNewMessages,
getRegisteredGroup,
getRouterState, getRouterState,
initDatabase, initDatabase,
setRegisteredGroup, setRegisteredGroup,
@@ -170,7 +172,7 @@ async function processGroupMessages(chatJid: string): Promise<boolean> {
if (!hasTrigger) return true; 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 // 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. // these messages. Save the old cursor so we can roll back on error.
@@ -408,7 +410,7 @@ async function startMessageLoop(): Promise<void> {
); );
const messagesToSend = const messagesToSend =
allPending.length > 0 ? allPending : groupMessages; allPending.length > 0 ? allPending : groupMessages;
const formatted = formatMessages(messagesToSend); const formatted = formatMessages(messagesToSend, TIMEZONE);
if (queue.sendMessage(chatJid, formatted)) { if (queue.sendMessage(chatJid, formatted)) {
logger.debug( logger.debug(

View File

@@ -74,7 +74,7 @@ describe('schedule_task authorization', () => {
type: 'schedule_task', type: 'schedule_task',
prompt: 'do something', prompt: 'do something',
schedule_type: 'once', schedule_type: 'once',
schedule_value: '2025-06-01T00:00:00.000Z', schedule_value: '2025-06-01T00:00:00',
targetJid: 'other@g.us', targetJid: 'other@g.us',
}, },
'whatsapp_main', 'whatsapp_main',
@@ -94,7 +94,7 @@ describe('schedule_task authorization', () => {
type: 'schedule_task', type: 'schedule_task',
prompt: 'self task', prompt: 'self task',
schedule_type: 'once', schedule_type: 'once',
schedule_value: '2025-06-01T00:00:00.000Z', schedule_value: '2025-06-01T00:00:00',
targetJid: 'other@g.us', targetJid: 'other@g.us',
}, },
'other-group', 'other-group',
@@ -113,7 +113,7 @@ describe('schedule_task authorization', () => {
type: 'schedule_task', type: 'schedule_task',
prompt: 'unauthorized', prompt: 'unauthorized',
schedule_type: 'once', schedule_type: 'once',
schedule_value: '2025-06-01T00:00:00.000Z', schedule_value: '2025-06-01T00:00:00',
targetJid: 'main@g.us', targetJid: 'main@g.us',
}, },
'other-group', 'other-group',
@@ -131,7 +131,7 @@ describe('schedule_task authorization', () => {
type: 'schedule_task', type: 'schedule_task',
prompt: 'no target', prompt: 'no target',
schedule_type: 'once', schedule_type: 'once',
schedule_value: '2025-06-01T00:00:00.000Z', schedule_value: '2025-06-01T00:00:00',
targetJid: 'unknown@g.us', targetJid: 'unknown@g.us',
}, },
'whatsapp_main', 'whatsapp_main',
@@ -154,7 +154,7 @@ describe('pause_task authorization', () => {
chat_jid: 'main@g.us', chat_jid: 'main@g.us',
prompt: 'main task', prompt: 'main task',
schedule_type: 'once', schedule_type: 'once',
schedule_value: '2025-06-01T00:00:00.000Z', schedule_value: '2025-06-01T00:00:00',
context_mode: 'isolated', context_mode: 'isolated',
next_run: '2025-06-01T00:00:00.000Z', next_run: '2025-06-01T00:00:00.000Z',
status: 'active', status: 'active',
@@ -166,7 +166,7 @@ describe('pause_task authorization', () => {
chat_jid: 'other@g.us', chat_jid: 'other@g.us',
prompt: 'other task', prompt: 'other task',
schedule_type: 'once', schedule_type: 'once',
schedule_value: '2025-06-01T00:00:00.000Z', schedule_value: '2025-06-01T00:00:00',
context_mode: 'isolated', context_mode: 'isolated',
next_run: '2025-06-01T00:00:00.000Z', next_run: '2025-06-01T00:00:00.000Z',
status: 'active', status: 'active',
@@ -215,7 +215,7 @@ describe('resume_task authorization', () => {
chat_jid: 'other@g.us', chat_jid: 'other@g.us',
prompt: 'paused task', prompt: 'paused task',
schedule_type: 'once', schedule_type: 'once',
schedule_value: '2025-06-01T00:00:00.000Z', schedule_value: '2025-06-01T00:00:00',
context_mode: 'isolated', context_mode: 'isolated',
next_run: '2025-06-01T00:00:00.000Z', next_run: '2025-06-01T00:00:00.000Z',
status: 'paused', status: 'paused',
@@ -264,7 +264,7 @@ describe('cancel_task authorization', () => {
chat_jid: 'other@g.us', chat_jid: 'other@g.us',
prompt: 'cancel me', prompt: 'cancel me',
schedule_type: 'once', schedule_type: 'once',
schedule_value: '2025-06-01T00:00:00.000Z', schedule_value: '2025-06-01T00:00:00',
context_mode: 'isolated', context_mode: 'isolated',
next_run: null, next_run: null,
status: 'active', status: 'active',
@@ -287,7 +287,7 @@ describe('cancel_task authorization', () => {
chat_jid: 'other@g.us', chat_jid: 'other@g.us',
prompt: 'my task', prompt: 'my task',
schedule_type: 'once', schedule_type: 'once',
schedule_value: '2025-06-01T00:00:00.000Z', schedule_value: '2025-06-01T00:00:00',
context_mode: 'isolated', context_mode: 'isolated',
next_run: null, next_run: null,
status: 'active', status: 'active',
@@ -310,7 +310,7 @@ describe('cancel_task authorization', () => {
chat_jid: 'main@g.us', chat_jid: 'main@g.us',
prompt: 'not yours', prompt: 'not yours',
schedule_type: 'once', schedule_type: 'once',
schedule_value: '2025-06-01T00:00:00.000Z', schedule_value: '2025-06-01T00:00:00',
context_mode: 'isolated', context_mode: 'isolated',
next_run: null, next_run: null,
status: 'active', status: 'active',
@@ -565,7 +565,7 @@ describe('schedule_task context_mode', () => {
type: 'schedule_task', type: 'schedule_task',
prompt: 'group context', prompt: 'group context',
schedule_type: 'once', schedule_type: 'once',
schedule_value: '2025-06-01T00:00:00.000Z', schedule_value: '2025-06-01T00:00:00',
context_mode: 'group', context_mode: 'group',
targetJid: 'other@g.us', targetJid: 'other@g.us',
}, },
@@ -584,7 +584,7 @@ describe('schedule_task context_mode', () => {
type: 'schedule_task', type: 'schedule_task',
prompt: 'isolated context', prompt: 'isolated context',
schedule_type: 'once', schedule_type: 'once',
schedule_value: '2025-06-01T00:00:00.000Z', schedule_value: '2025-06-01T00:00:00',
context_mode: 'isolated', context_mode: 'isolated',
targetJid: 'other@g.us', targetJid: 'other@g.us',
}, },
@@ -603,7 +603,7 @@ describe('schedule_task context_mode', () => {
type: 'schedule_task', type: 'schedule_task',
prompt: 'bad context', prompt: 'bad context',
schedule_type: 'once', schedule_type: 'once',
schedule_value: '2025-06-01T00:00:00.000Z', schedule_value: '2025-06-01T00:00:00',
context_mode: 'bogus' as any, context_mode: 'bogus' as any,
targetJid: 'other@g.us', targetJid: 'other@g.us',
}, },
@@ -622,7 +622,7 @@ describe('schedule_task context_mode', () => {
type: 'schedule_task', type: 'schedule_task',
prompt: 'no context mode', prompt: 'no context mode',
schedule_type: 'once', schedule_type: 'once',
schedule_value: '2025-06-01T00:00:00.000Z', schedule_value: '2025-06-01T00:00:00',
targetJid: 'other@g.us', targetJid: 'other@g.us',
}, },
'whatsapp_main', 'whatsapp_main',

View File

@@ -236,15 +236,15 @@ export async function processTaskIpc(
} }
nextRun = new Date(Date.now() + ms).toISOString(); nextRun = new Date(Date.now() + ms).toISOString();
} else if (scheduleType === 'once') { } else if (scheduleType === 'once') {
const scheduled = new Date(data.schedule_value); const date = new Date(data.schedule_value);
if (isNaN(scheduled.getTime())) { if (isNaN(date.getTime())) {
logger.warn( logger.warn(
{ scheduleValue: data.schedule_value }, { scheduleValue: data.schedule_value },
'Invalid timestamp', 'Invalid timestamp',
); );
break; break;
} }
nextRun = scheduled.toISOString(); nextRun = date.toISOString();
} }
const taskId = const taskId =

View File

@@ -1,4 +1,5 @@
import { Channel, NewMessage } from './types.js'; import { Channel, NewMessage } from './types.js';
import { formatLocalTime } from './timezone.js';
export function escapeXml(s: string): string { export function escapeXml(s: string): string {
if (!s) return ''; if (!s) return '';
@@ -9,12 +10,18 @@ export function escapeXml(s: string): string {
.replace(/"/g, '&quot;'); .replace(/"/g, '&quot;');
} }
export function formatMessages(messages: NewMessage[]): string { export function formatMessages(
const lines = messages.map( messages: NewMessage[],
(m) => timezone: string,
`<message sender="${escapeXml(m.sender_name)}" time="${m.timestamp}">${escapeXml(m.content)}</message>`, ): string {
); const lines = messages.map((m) => {
return `<messages>\n${lines.join('\n')}\n</messages>`; const displayTime = formatLocalTime(m.timestamp, timezone);
return `<message sender="${escapeXml(m.sender_name)}" time="${escapeXml(displayTime)}">${escapeXml(m.content)}</message>`;
});
const header = `<context timezone="${escapeXml(timezone)}" />\n`;
return `${header}<messages>\n${lines.join('\n')}\n</messages>`;
} }
export function stripInternalTags(text: string): string { export function stripInternalTags(text: string): string {

29
src/timezone.test.ts Normal file
View File

@@ -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');
});
});

16
src/timezone.ts Normal file
View File

@@ -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,
});
}