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:
@@ -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 & B <Co>"');
|
expect(result).toContain('sender="A & B <Co>"');
|
||||||
});
|
});
|
||||||
|
|
||||||
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(
|
||||||
'<script>alert("xss")</script>',
|
'<script>alert("xss")</script>',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
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" />');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
@@ -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, '"');
|
.replace(/"/g, '"');
|
||||||
}
|
}
|
||||||
|
|
||||||
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
29
src/timezone.test.ts
Normal 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
16
src/timezone.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user