Skip to content

Commit 632713b

Browse files
gabi-simonsshawny011717Lafunamorclaude
authored
feat: timezone-aware context injection for agent prompts (qwibitai#691)
* feat: per-group timezone architecture with context injection (qwibitai#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 qwibitai#483 Closes qwibitai#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>
1 parent 47ad2e6 commit 632713b

7 files changed

Lines changed: 114 additions & 41 deletions

File tree

src/formatting.test.ts

Lines changed: 35 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -58,13 +58,14 @@ describe('escapeXml', () => {
5858
// --- formatMessages ---
5959

6060
describe('formatMessages', () => {
61-
it('formats a single message as XML', () => {
62-
const result = formatMessages([makeMsg()]);
63-
expect(result).toBe(
64-
'<messages>\n' +
65-
'<message sender="Alice" time="2024-01-01T00:00:00.000Z">hello</message>\n' +
66-
'</messages>',
67-
);
61+
const TZ = 'UTC';
62+
63+
it('formats a single message as XML with context header', () => {
64+
const result = formatMessages([makeMsg()], TZ);
65+
expect(result).toContain('<context timezone="UTC" />');
66+
expect(result).toContain('<message sender="Alice"');
67+
expect(result).toContain('>hello</message>');
68+
expect(result).toContain('Jan 1, 2024');
6869
});
6970

7071
it('formats multiple messages', () => {
@@ -73,34 +74,52 @@ describe('formatMessages', () => {
7374
id: '1',
7475
sender_name: 'Alice',
7576
content: 'hi',
76-
timestamp: 't1',
77+
timestamp: '2024-01-01T00:00:00.000Z',
78+
}),
79+
makeMsg({
80+
id: '2',
81+
sender_name: 'Bob',
82+
content: 'hey',
83+
timestamp: '2024-01-01T01:00:00.000Z',
7784
}),
78-
makeMsg({ id: '2', sender_name: 'Bob', content: 'hey', timestamp: 't2' }),
7985
];
80-
const result = formatMessages(msgs);
86+
const result = formatMessages(msgs, TZ);
8187
expect(result).toContain('sender="Alice"');
8288
expect(result).toContain('sender="Bob"');
8389
expect(result).toContain('>hi</message>');
8490
expect(result).toContain('>hey</message>');
8591
});
8692

8793
it('escapes special characters in sender names', () => {
88-
const result = formatMessages([makeMsg({ sender_name: 'A & B <Co>' })]);
94+
const result = formatMessages([makeMsg({ sender_name: 'A & B <Co>' })], TZ);
8995
expect(result).toContain('sender="A &amp; B &lt;Co&gt;"');
9096
});
9197

9298
it('escapes special characters in content', () => {
93-
const result = formatMessages([
94-
makeMsg({ content: '<script>alert("xss")</script>' }),
95-
]);
99+
const result = formatMessages(
100+
[makeMsg({ content: '<script>alert("xss")</script>' })],
101+
TZ,
102+
);
96103
expect(result).toContain(
97104
'&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;',
98105
);
99106
});
100107

101108
it('handles empty array', () => {
102-
const result = formatMessages([]);
103-
expect(result).toBe('<messages>\n\n</messages>');
109+
const result = formatMessages([], TZ);
110+
expect(result).toContain('<context timezone="UTC" />');
111+
expect(result).toContain('<messages>\n\n</messages>');
112+
});
113+
114+
it('converts timestamps to local time for given timezone', () => {
115+
// 2024-01-01T18:30:00Z in America/New_York (EST) = 1:30 PM
116+
const result = formatMessages(
117+
[makeMsg({ timestamp: '2024-01-01T18:30:00.000Z' })],
118+
'America/New_York',
119+
);
120+
expect(result).toContain('1:30');
121+
expect(result).toContain('PM');
122+
expect(result).toContain('<context timezone="America/New_York" />');
104123
});
105124
});
106125

src/index.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
ASSISTANT_NAME,
66
IDLE_TIMEOUT,
77
POLL_INTERVAL,
8+
TIMEZONE,
89
TRIGGER_PATTERN,
910
} from './config.js';
1011
import './channels/index.js';
@@ -29,6 +30,7 @@ import {
2930
getAllTasks,
3031
getMessagesSince,
3132
getNewMessages,
33+
getRegisteredGroup,
3234
getRouterState,
3335
initDatabase,
3436
setRegisteredGroup,
@@ -170,7 +172,7 @@ async function processGroupMessages(chatJid: string): Promise<boolean> {
170172
if (!hasTrigger) return true;
171173
}
172174

173-
const prompt = formatMessages(missedMessages);
175+
const prompt = formatMessages(missedMessages, TIMEZONE);
174176

175177
// Advance cursor so the piping path in startMessageLoop won't re-fetch
176178
// these messages. Save the old cursor so we can roll back on error.
@@ -408,7 +410,7 @@ async function startMessageLoop(): Promise<void> {
408410
);
409411
const messagesToSend =
410412
allPending.length > 0 ? allPending : groupMessages;
411-
const formatted = formatMessages(messagesToSend);
413+
const formatted = formatMessages(messagesToSend, TIMEZONE);
412414

413415
if (queue.sendMessage(chatJid, formatted)) {
414416
logger.debug(

src/ipc-auth.test.ts

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ describe('schedule_task authorization', () => {
7474
type: 'schedule_task',
7575
prompt: 'do something',
7676
schedule_type: 'once',
77-
schedule_value: '2025-06-01T00:00:00.000Z',
77+
schedule_value: '2025-06-01T00:00:00',
7878
targetJid: 'other@g.us',
7979
},
8080
'whatsapp_main',
@@ -94,7 +94,7 @@ describe('schedule_task authorization', () => {
9494
type: 'schedule_task',
9595
prompt: 'self task',
9696
schedule_type: 'once',
97-
schedule_value: '2025-06-01T00:00:00.000Z',
97+
schedule_value: '2025-06-01T00:00:00',
9898
targetJid: 'other@g.us',
9999
},
100100
'other-group',
@@ -113,7 +113,7 @@ describe('schedule_task authorization', () => {
113113
type: 'schedule_task',
114114
prompt: 'unauthorized',
115115
schedule_type: 'once',
116-
schedule_value: '2025-06-01T00:00:00.000Z',
116+
schedule_value: '2025-06-01T00:00:00',
117117
targetJid: 'main@g.us',
118118
},
119119
'other-group',
@@ -131,7 +131,7 @@ describe('schedule_task authorization', () => {
131131
type: 'schedule_task',
132132
prompt: 'no target',
133133
schedule_type: 'once',
134-
schedule_value: '2025-06-01T00:00:00.000Z',
134+
schedule_value: '2025-06-01T00:00:00',
135135
targetJid: 'unknown@g.us',
136136
},
137137
'whatsapp_main',
@@ -154,7 +154,7 @@ describe('pause_task authorization', () => {
154154
chat_jid: 'main@g.us',
155155
prompt: 'main task',
156156
schedule_type: 'once',
157-
schedule_value: '2025-06-01T00:00:00.000Z',
157+
schedule_value: '2025-06-01T00:00:00',
158158
context_mode: 'isolated',
159159
next_run: '2025-06-01T00:00:00.000Z',
160160
status: 'active',
@@ -166,7 +166,7 @@ describe('pause_task authorization', () => {
166166
chat_jid: 'other@g.us',
167167
prompt: 'other task',
168168
schedule_type: 'once',
169-
schedule_value: '2025-06-01T00:00:00.000Z',
169+
schedule_value: '2025-06-01T00:00:00',
170170
context_mode: 'isolated',
171171
next_run: '2025-06-01T00:00:00.000Z',
172172
status: 'active',
@@ -215,7 +215,7 @@ describe('resume_task authorization', () => {
215215
chat_jid: 'other@g.us',
216216
prompt: 'paused task',
217217
schedule_type: 'once',
218-
schedule_value: '2025-06-01T00:00:00.000Z',
218+
schedule_value: '2025-06-01T00:00:00',
219219
context_mode: 'isolated',
220220
next_run: '2025-06-01T00:00:00.000Z',
221221
status: 'paused',
@@ -264,7 +264,7 @@ describe('cancel_task authorization', () => {
264264
chat_jid: 'other@g.us',
265265
prompt: 'cancel me',
266266
schedule_type: 'once',
267-
schedule_value: '2025-06-01T00:00:00.000Z',
267+
schedule_value: '2025-06-01T00:00:00',
268268
context_mode: 'isolated',
269269
next_run: null,
270270
status: 'active',
@@ -287,7 +287,7 @@ describe('cancel_task authorization', () => {
287287
chat_jid: 'other@g.us',
288288
prompt: 'my task',
289289
schedule_type: 'once',
290-
schedule_value: '2025-06-01T00:00:00.000Z',
290+
schedule_value: '2025-06-01T00:00:00',
291291
context_mode: 'isolated',
292292
next_run: null,
293293
status: 'active',
@@ -310,7 +310,7 @@ describe('cancel_task authorization', () => {
310310
chat_jid: 'main@g.us',
311311
prompt: 'not yours',
312312
schedule_type: 'once',
313-
schedule_value: '2025-06-01T00:00:00.000Z',
313+
schedule_value: '2025-06-01T00:00:00',
314314
context_mode: 'isolated',
315315
next_run: null,
316316
status: 'active',
@@ -565,7 +565,7 @@ describe('schedule_task context_mode', () => {
565565
type: 'schedule_task',
566566
prompt: 'group context',
567567
schedule_type: 'once',
568-
schedule_value: '2025-06-01T00:00:00.000Z',
568+
schedule_value: '2025-06-01T00:00:00',
569569
context_mode: 'group',
570570
targetJid: 'other@g.us',
571571
},
@@ -584,7 +584,7 @@ describe('schedule_task context_mode', () => {
584584
type: 'schedule_task',
585585
prompt: 'isolated context',
586586
schedule_type: 'once',
587-
schedule_value: '2025-06-01T00:00:00.000Z',
587+
schedule_value: '2025-06-01T00:00:00',
588588
context_mode: 'isolated',
589589
targetJid: 'other@g.us',
590590
},
@@ -603,7 +603,7 @@ describe('schedule_task context_mode', () => {
603603
type: 'schedule_task',
604604
prompt: 'bad context',
605605
schedule_type: 'once',
606-
schedule_value: '2025-06-01T00:00:00.000Z',
606+
schedule_value: '2025-06-01T00:00:00',
607607
context_mode: 'bogus' as any,
608608
targetJid: 'other@g.us',
609609
},
@@ -622,7 +622,7 @@ describe('schedule_task context_mode', () => {
622622
type: 'schedule_task',
623623
prompt: 'no context mode',
624624
schedule_type: 'once',
625-
schedule_value: '2025-06-01T00:00:00.000Z',
625+
schedule_value: '2025-06-01T00:00:00',
626626
targetJid: 'other@g.us',
627627
},
628628
'whatsapp_main',

src/ipc.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -236,15 +236,15 @@ export async function processTaskIpc(
236236
}
237237
nextRun = new Date(Date.now() + ms).toISOString();
238238
} else if (scheduleType === 'once') {
239-
const scheduled = new Date(data.schedule_value);
240-
if (isNaN(scheduled.getTime())) {
239+
const date = new Date(data.schedule_value);
240+
if (isNaN(date.getTime())) {
241241
logger.warn(
242242
{ scheduleValue: data.schedule_value },
243243
'Invalid timestamp',
244244
);
245245
break;
246246
}
247-
nextRun = scheduled.toISOString();
247+
nextRun = date.toISOString();
248248
}
249249

250250
const taskId =

src/router.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Channel, NewMessage } from './types.js';
2+
import { formatLocalTime } from './timezone.js';
23

34
export function escapeXml(s: string): string {
45
if (!s) return '';
@@ -9,12 +10,18 @@ export function escapeXml(s: string): string {
910
.replace(/"/g, '&quot;');
1011
}
1112

12-
export function formatMessages(messages: NewMessage[]): string {
13-
const lines = messages.map(
14-
(m) =>
15-
`<message sender="${escapeXml(m.sender_name)}" time="${m.timestamp}">${escapeXml(m.content)}</message>`,
16-
);
17-
return `<messages>\n${lines.join('\n')}\n</messages>`;
13+
export function formatMessages(
14+
messages: NewMessage[],
15+
timezone: string,
16+
): string {
17+
const lines = messages.map((m) => {
18+
const displayTime = formatLocalTime(m.timestamp, timezone);
19+
return `<message sender="${escapeXml(m.sender_name)}" time="${escapeXml(displayTime)}">${escapeXml(m.content)}</message>`;
20+
});
21+
22+
const header = `<context timezone="${escapeXml(timezone)}" />\n`;
23+
24+
return `${header}<messages>\n${lines.join('\n')}\n</messages>`;
1825
}
1926

2027
export function stripInternalTags(text: string): string {

src/timezone.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { describe, it, expect } from 'vitest';
2+
3+
import { formatLocalTime } from './timezone.js';
4+
5+
// --- formatLocalTime ---
6+
7+
describe('formatLocalTime', () => {
8+
it('converts UTC to local time display', () => {
9+
// 2026-02-04T18:30:00Z in America/New_York (EST, UTC-5) = 1:30 PM
10+
const result = formatLocalTime(
11+
'2026-02-04T18:30:00.000Z',
12+
'America/New_York',
13+
);
14+
expect(result).toContain('1:30');
15+
expect(result).toContain('PM');
16+
expect(result).toContain('Feb');
17+
expect(result).toContain('2026');
18+
});
19+
20+
it('handles different timezones', () => {
21+
// Same UTC time should produce different local times
22+
const utc = '2026-06-15T12:00:00.000Z';
23+
const ny = formatLocalTime(utc, 'America/New_York');
24+
const tokyo = formatLocalTime(utc, 'Asia/Tokyo');
25+
// NY is UTC-4 in summer (EDT), Tokyo is UTC+9
26+
expect(ny).toContain('8:00');
27+
expect(tokyo).toContain('9:00');
28+
});
29+
});

src/timezone.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
/**
2+
* Convert a UTC ISO timestamp to a localized display string.
3+
* Uses the Intl API (no external dependencies).
4+
*/
5+
export function formatLocalTime(utcIso: string, timezone: string): string {
6+
const date = new Date(utcIso);
7+
return date.toLocaleString('en-US', {
8+
timeZone: timezone,
9+
year: 'numeric',
10+
month: 'short',
11+
day: 'numeric',
12+
hour: 'numeric',
13+
minute: '2-digit',
14+
hour12: true,
15+
});
16+
}

0 commit comments

Comments
 (0)