# v1 Timezone + Formatting — Recreation Spec ## Source commits **Parent of deletion**: `86becf8^ = 27c52205f9fdeac0483600b2663f1c4d80aba45d` **Deletion commit**: `86becf8` (chore: delete v1 reference code) ### Relevant v1 files at commit 27c5220 (v1^): - `src/v1/router.ts` — message formatting logic (escapeXml, formatMessages, stripInternalTags, formatOutbound) - `src/v1/timezone.ts` — timezone utility functions (isValidTimezone, resolveTimezone, formatLocalTime) - `src/v1/config.ts` — configuration and trigger patterns (buildTriggerPattern, getTriggerPattern, TIMEZONE resolution) - `src/v1/task-scheduler.ts` — scheduled task timezone handling (computeNextRun with cron-parser) - `src/v1/types.ts` — data structures (NewMessage interface) - `src/v1/formatting.test.ts` — comprehensive test suite for all formatting behavior - `src/v1/timezone.test.ts` — timezone utility tests - `src/v1/task-scheduler.test.ts` — scheduler tests --- ## 1. Timestamp formatting on inbound messages ### v1 behavior (exact) **Function**: `formatLocalTime()` in `src/v1/timezone.ts:26-36` ```typescript export function formatLocalTime(utcIso: string, timezone: string): string { const date = new Date(utcIso); return date.toLocaleString('en-US', { timeZone: resolveTimezone(timezone), year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', hour12: true, }); } ``` **Input**: UTC ISO 8601 timestamp (e.g., `'2024-01-01T00:00:00.000Z'`) + timezone name (e.g., `'America/New_York'`) **Output format example**: - Input: `'2024-01-01T18:30:00.000Z'` with timezone `'America/New_York'` (EST, UTC-5) - Output: `'1:30 PM'` (with additional date components: month short name, day, year, hour, 2-digit minute, 12-hour format) - Full example output: `"Jan 1, 2024, 1:30 PM"` (exact format depends on browser/Node locale) **Critical Details**: - Uses JavaScript's `Intl.DateTimeFormat` API with `en-US` locale - Format options: `{ year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', hour12: true }` - Handles invalid timezone gracefully by calling `resolveTimezone(timezone)` which falls back to UTC - No external dependencies (no moment.js, date-fns, or day.js) **Where it's called**: - `src/v1/router.ts:11` in `formatMessages()` function to convert each message's `m.timestamp` to display time - The display time is then placed in the `time="..."` attribute of the XML message element ### Test coverage From `src/v1/formatting.test.ts:51-84`: 1. **Basic formatting with context header** - Input: Single message with timestamp `'2024-01-01T00:00:00.000Z'`, timezone `'UTC'` - Asserts: `result.toContain('Jan 1, 2024')` and `''` - File:line: `src/v1/formatting.test.ts:51-56` 2. **Timezone conversion to local time** - Input: Timestamp `'2024-01-01T18:30:00.000Z'` with timezone `'America/New_York'` (EST) - Asserts: Result contains `'1:30'` and `'PM'` (correct EST conversion, UTC-5) - File:line: `src/v1/formatting.test.ts:74-78` From `src/v1/timezone.test.ts:10-30`: 3. **formatLocalTime with timezone conversion** - Input: `'2026-02-04T18:30:00.000Z'` with `'America/New_York'` - Asserts: Contains `'1:30'`, `'PM'`, `'Feb'`, `'2026'` - File:line: `src/v1/timezone.test.ts:10-16` 4. **Multiple timezones comparison** - Input: Same UTC time with different timezones (`'America/New_York'`, `'Asia/Tokyo'`) - Asserts: NY shows `'8:00'` (EDT, UTC-4 in summer), Tokyo shows `'9:00'` (UTC+9) - File:line: `src/v1/timezone.test.ts:18-26` 5. **Invalid timezone fallback** - Input: Invalid timezone `'IST-2'` - Asserts: Does not throw, formats as UTC (falls back) - File:line: `src/v1/timezone.test.ts:28-33` --- ## 2. Context timezone header ### v1 behavior (exact) **Location**: Prepended at the START of the formatted message block in `src/v1/router.ts:20-22` **Format**: ```xml ``` **Code**: ```typescript const header = `\n`; return `${header}\n${lines.join('\n')}\n`; ``` **What it includes**: - Only the timezone name (IANA identifier, e.g., `'UTC'`, `'America/New_York'`) - **NOT** the current time (that's in each individual message's `time="..."` attribute) - XML-escaped to prevent injection (via `escapeXml()`) **Per-message vs per-turn**: - The header appears **once per call to `formatMessages()`**, which formats a batch of messages - The entire batch (header + all messages) is passed to the agent as a single unit - The `timezone` parameter is passed in from the caller (`src/v1/router.ts:9` line signature) **Where it's wired**: - `src/v1/router.ts:9` — `formatMessages(messages: NewMessage[], timezone: string)` accepts timezone as a parameter - This function is called from the channel message processing loop (inbound message handler) - The caller supplies the `TIMEZONE` constant from `src/v1/config.ts:62` ### Test coverage From `src/v1/formatting.test.ts:51-56`: 1. **Context header is included in output** - Input: Any message list with timezone `'UTC'` - Asserts: `result.toContain('')` - File:line: `src/v1/formatting.test.ts:51-56` 2. **Context header with non-UTC timezone** - Input: Timezone `'America/New_York'` - Asserts: `result.toContain('')` - File:line: `src/v1/formatting.test.ts:74-78` 3. **Context header with empty message list** - Input: Empty array with timezone `'UTC'` - Asserts: `result.toContain('')` even when no messages - File:line: `src/v1/formatting.test.ts:80-83` --- ## 3. Reply-to handling with message IDs ### v1 behavior (exact) **Location**: In the message formatting loop in `src/v1/router.ts:10-18` **Code**: ```typescript const replyAttr = m.reply_to_message_id ? ` reply_to="${escapeXml(m.reply_to_message_id)}"` : ''; const replySnippet = m.reply_to_message_content && m.reply_to_sender_name ? `\n ${escapeXml(m.reply_to_message_content)}` : ''; return `${replySnippet}${escapeXml(m.content)}`; ``` **Format of reply-to**: - Attribute: `reply_to=""` on the `` tag (if `m.reply_to_message_id` is present) - The ID is XML-escaped via `escapeXml()` - Nested element: `` (if both sender and content are present) - Both sender name and content are XML-escaped **What it contains**: - `reply_to=""` attribute with the exact message ID from `m.reply_to_message_id` - Sender name from `m.reply_to_sender_name` - Original message content from `m.reply_to_message_content` - **No timestamp** of the referenced message **Conditional rendering**: 1. If `m.reply_to_message_id` is present: include `reply_to=""` attribute 2. If `m.reply_to_message_id` is present but content/sender missing: include attribute only, no `` element 3. If only content and sender (no ID): only `` element, no attribute **Example output**: ```xml Are you coming tonight? Yes, on my way! ``` ### Test coverage From `src/v1/formatting.test.ts:96-139`: 1. **Reply with both ID and quoted content** - Input: Message with `reply_to_message_id: '42'`, `reply_to_sender_name: 'Bob'`, `reply_to_message_content: 'Are you coming tonight?'`, content: `'Yes, on my way!'` - Asserts: - `result.toContain('reply_to="42"')` - `result.toContain('Are you coming tonight?')` - `result.toContain('Yes, on my way!')` - File:line: `src/v1/formatting.test.ts:96-112` 2. **No reply context when missing** - Input: Message without reply fields - Asserts: - `result.not.toContain('reply_to')` - `result.not.toContain('quoted_message')` - File:line: `src/v1/formatting.test.ts:114-119` 3. **ID present but content missing** - Input: `reply_to_message_id: '42'`, `reply_to_sender_name: 'Bob'`, but NO `reply_to_message_content` - Asserts: - `result.toContain('reply_to="42"')` - `result.not.toContain('quoted_message')` - File:line: `src/v1/formatting.test.ts:121-130` 4. **XML escape in reply context** - Input: `reply_to_message_id: '1'`, `reply_to_sender_name: 'A & B'`, `reply_to_message_content: ''` - Asserts: - `result.toContain('from="A & B"')` - `result.toContain('<script>alert("xss")</script>')` - File:line: `src/v1/formatting.test.ts:131-139` --- ## 4. Internal tag stripping ### v1 behavior (exact) **Function name**: `stripInternalTags()` in `src/v1/router.ts:25-27` **Implementation**: ```typescript export function stripInternalTags(text: string): string { return text.replace(/[\s\S]*?<\/internal>/g, '').trim(); } ``` **Regex pattern**: `/[\s\S]*?<\/internal>/g` - `` — literal opening tag - `[\s\S]*?` — match any character (whitespace or non-whitespace) non-greedily - `<\/internal>` — literal closing tag - `g` flag — global (all matches) **Post-processing**: `.trim()` removes leading/trailing whitespace after all tags are stripped **Where it's called**: - `src/v1/router.ts:30` in `formatOutbound()` function - Called AFTER the tag removal to clean the output before returning **Used for**: Stripping internal thinking/reasoning from outbound messages before sending to channel **Input/Output examples**: 1. Single-line internal tag: - Input: `'hello secret world'` - Output: `'hello world'` (then `.trim()` would be `'hello world'`) 2. Multi-line internal tags: - Input: `'hello \nsecret\nstuff\n world'` - Output: `'hello world'` 3. Multiple blocks: - Input: `'ahellob'` - Output: `'hello'` 4. Only internal content: - Input: `'only this'` - Output: `''` (empty after trim) ### Test coverage From `src/v1/formatting.test.ts:163-181`: 1. **Single-line tag stripping** - Input: `'hello secret world'` - Asserts: Result is `'hello world'` (two spaces, then `.trim()` removes outer whitespace) - Expected (with trim): `'hello world'` - File:line: `src/v1/formatting.test.ts:163-165` 2. **Multi-line tag stripping** - Input: `'hello \nsecret\nstuff\n world'` - Asserts: Result is `'hello world'` (after trim) - File:line: `src/v1/formatting.test.ts:167-169` 3. **Multiple internal blocks** - Input: `'ahellob'` - Asserts: Result is `'hello'` - File:line: `src/v1/formatting.test.ts:171-173` 4. **Only internal content** - Input: `'only this'` - Asserts: Result is `''` (empty string) - File:line: `src/v1/formatting.test.ts:175-177` From `src/v1/formatting.test.ts:183-194`: 5. **formatOutbound with no internal tags** - Input: `'hello world'` - Asserts: Result is `'hello world'` - File:line: `src/v1/formatting.test.ts:183-185` 6. **formatOutbound with all internal content** - Input: `'hidden'` - Asserts: Result is `''` (returns early after strip) - File:line: `src/v1/formatting.test.ts:187-189` 7. **formatOutbound strips and returns remaining** - Input: `'thinkingThe answer is 42'` - Asserts: Result is `'The answer is 42'` - File:line: `src/v1/formatting.test.ts:191-194` --- ## 5. Timezone handling for scheduled tasks ### v1 behavior (exact) **Location**: `src/v1/task-scheduler.ts:20-49` **Key function**: `computeNextRun(task: ScheduledTask): string | null` **Cron timezone handling**: ```typescript if (task.schedule_type === 'cron') { const interval = CronExpressionParser.parse(task.schedule_value, { tz: TIMEZONE, }); return interval.next().toISOString(); } ``` **Critical details**: - Uses `cron-parser` library's `CronExpressionParser.parse()` method - Passes timezone option as `{ tz: TIMEZONE }` (e.g., `{ tz: 'America/New_York' }`) - `TIMEZONE` is imported from `src/v1/config.ts:62` and resolved via `resolveConfigTimezone()` - The cron expression is interpreted in the **user's timezone**, not UTC - Example: cron `'0 9 * * *'` with `tz: 'America/New_York'` means 9 AM ET every day **Interval task handling**: ```typescript if (task.schedule_type === 'interval') { const ms = parseInt(task.schedule_value, 10); if (!ms || ms <= 0) { logger.warn({ taskId: task.id, value: task.schedule_value }, 'Invalid interval value'); return new Date(now + 60_000).toISOString(); } let next = new Date(task.next_run!).getTime() + ms; while (next <= now) { next += ms; } return new Date(next).toISOString(); } ``` **Interval specifics**: - Intervals are timezone-agnostic (pure millisecond-based) - Anchored to the task's `next_run` time to prevent cumulative drift - If intervals have been missed, the loop skips forward to land in the future while maintaining the original schedule grid **Once-only tasks**: ```typescript if (task.schedule_type === 'once') return null; ``` **MCP tool description**: - v1 did not expose cron task scheduling directly to the agent (it was a server-side feature) - The scheduling was configured in group config files, not via agent tool calls ### Test coverage From `src/v1/task-scheduler.test.ts:33-60`: 1. **computeNextRun returns null for once-tasks** - Input: Task with `schedule_type: 'once'` - Asserts: `computeNextRun(task)` returns `null` - File:line: `src/v1/task-scheduler.test.ts:40-49` 2. **Interval task anchoring to prevent drift** - Input: Task scheduled 2s ago with interval `60000` (1 minute) - Asserts: Next run = `scheduledTime + 60s`, not `now + 60s` - Expected: Exact alignment to the scheduled time grid - File:line: `src/v1/task-scheduler.test.ts:33-39` 3. **Interval task catches up without infinite loop** - Input: Task with 10 missed intervals (missed by 10 * 60000ms) - Asserts: Next run is in the future and aligned to original schedule grid - File:line: `src/v1/task-scheduler.test.ts:51-60` --- ## 6. Complete test inventory (formatting.test.ts) ### All test cases from src/v1/formatting.test.ts (lines 1-254): #### Block 1: escapeXml tests (lines 22-46) | Test name | Input | Expected output | |-----------|-------|-----------------| | escapes ampersands | `'a & b'` | `'a & b'` | | escapes less-than | `'a < b'` | `'a < b'` | | escapes greater-than | `'a > b'` | `'a > b'` | | escapes double quotes | `'"hello"'` | `'"hello"'` | | handles multiple special characters together | `'a & b < c > d "e"'` | `'a & b < c > d "e"'` | | passes through strings with no special chars | `'hello world'` | `'hello world'` | | handles empty string | `''` | `''` | #### Block 2: formatMessages tests (lines 48-159) | Test name | Input | Key asserts | |-----------|-------|------------| | formats a single message as XML with context header (line 51) | Single message with timestamp `'2024-01-01T00:00:00.000Z'`, TZ `'UTC'` | Contains `''`, `'hello'`, `'Jan 1, 2024'` | | formats multiple messages (line 59) | 2 messages: Alice at 00:00, Bob at 01:00 | Contains both sender names and contents | | escapes special characters in sender names (line 72) | Sender `'A & B '` | Contains `'sender="A & B <Co>"'` | | escapes special characters in content (line 79) | Content `''` | Contains escaped script tags `'<script>...'` | | handles empty array (line 85) | Empty message list, TZ `'UTC'` | Contains header and `'\n\n'` | | renders reply context as quoted_message element (line 96) | Message with `reply_to_message_id: '42'`, `reply_to_sender_name: 'Bob'`, `reply_to_message_content: 'Are you coming tonight?'` | Contains `'reply_to="42"'`, `'Are you coming tonight?'` | | omits reply attributes when no reply context (line 114) | Message without reply fields | Does NOT contain `'reply_to'` or `'quoted_message'` | | omits quoted_message when content is missing but id is present (line 121) | Message with `reply_to_message_id: '42'` but no `reply_to_message_content` | Contains `'reply_to="42"'` but NOT `'alert("xss")'` | Contains `'from="A & B"'` and escaped script | | converts timestamps to local time for given timezone (line 140) | Timestamp `'2024-01-01T18:30:00.000Z'` with TZ `'America/New_York'` (EST, UTC-5) | Contains `'1:30'`, `'PM'`, header has `'America/New_York'` | #### Block 3: TRIGGER_PATTERN tests (lines 146-169) | Test name | Input | Expected result | |-----------|-------|-----------------| | matches @name at start of message (line 152) | `'@Andy hello'` (assuming ASSISTANT_NAME='Andy') | `true` | | matches case-insensitively (line 156) | `'@andy hello'` or `'@ANDY hello'` | `true` | | does not match when not at start of message (line 160) | `'hello @Andy'` | `false` | | does not match partial name like @NameExtra (word boundary) (line 164) | `'@Andyextra hello'` | `false` | | matches with word boundary before apostrophe (line 168) | `'@Andy\'s thing'` | `true` | | matches @name alone (end of string is a word boundary) (line 172) | `'@Andy'` | `true` | | matches with leading whitespace after trim (line 175) | `' @Andy hey'` (after `.trim()`) | `true` | #### Block 4: getTriggerPattern tests (lines 177-196) | Test name | Input | Expected behavior | |-----------|-------|-------------------| | uses the configured per-group trigger when provided (line 180) | `getTriggerPattern('@Claw')` | Matches `'@Claw hello'`, does NOT match `'@Andy hello'` | | falls back to the default trigger when group trigger is missing (line 186) | `getTriggerPattern(undefined)` | Matches default trigger `'@Andy hello'` | | treats regex characters in custom triggers literally (line 192) | `getTriggerPattern('@C.L.A.U.D.E')` | Matches literal dots, NOT wildcard (does NOT match `'@CXLXAUXDXE'`) | #### Block 5: stripInternalTags tests (lines 198-210) | Test name | Input | Expected output | |-----------|-------|-----------------| | strips single-line internal tags (line 199) | `'hello secret world'` | `'hello world'` (then `.trim()` makes it `'hello world'`) | | strips multi-line internal tags (line 203) | `'hello \nsecret\nstuff\n world'` | `'hello world'` | | strips multiple internal tag blocks (line 207) | `'ahellob'` | `'hello'` | | returns empty string when text is only internal tags (line 211) | `'only this'` | `''` | #### Block 6: formatOutbound tests (lines 213-226) | Test name | Input | Expected output | |-----------|-------|-----------------| | returns text with internal tags stripped (line 214) | `'hello world'` | `'hello world'` | | returns empty string when all text is internal (line 218) | `'hidden'` | `''` | | strips internal tags from remaining text (line 222) | `'thinkingThe answer is 42'` | `'The answer is 42'` | #### Block 7: trigger gating (requiresTrigger interaction) tests (lines 228-254) | Test name | Input | Expected result | |-----------|-------|-----------------| | main group always processes (no trigger needed) (line 239) | `isMainGroup: true`, message without trigger | `true` | | main group processes even with requiresTrigger=true (line 244) | `isMainGroup: true`, `requiresTrigger: true`, no trigger | `true` | | non-main group with requiresTrigger=undefined requires trigger (line 249) | `isMainGroup: false`, `requiresTrigger: undefined`, no trigger | `false` | | non-main group with requiresTrigger=true requires trigger (line 254) | `isMainGroup: false`, `requiresTrigger: true`, no trigger | `false` | | non-main group with requiresTrigger=true processes when trigger present (line 259) | `isMainGroup: false`, trigger in message | `true` | | non-main group uses per-group trigger instead of default (line 264) | `isMainGroup: false`, `trigger: '@Claw'`, message `'@Claw do something'` | `true` | | non-main group does not process when only default trigger is present for custom-trigger group (line 269) | `isMainGroup: false`, `trigger: '@Claw'`, message `'@Andy do something'` | `false` | | non-main group with requiresTrigger=false always processes (line 274) | `isMainGroup: false`, `requiresTrigger: false`, no trigger | `true` | --- ## v2 porting plan ### For each of sections 1–5: the specific change to make in v2 #### 1. Timestamp formatting **v2 file to modify**: (Unknown — search for where v2 formats inbound messages to the agent) **Change needed**: 1. Find where v2 currently formats message timestamps for the agent 2. Replace any custom date formatting with the v1 pattern: - Call `new Date(timestamp).toLocaleString('en-US', { timeZone, year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', hour12: true })` 3. Ensure the timezone parameter is sourced from `config.TIMEZONE` (or equivalent in v2) **Test to port**: `src/v1/formatting.test.ts:51-56` (basic formatting) and `src/v1/formatting.test.ts:74-78` (timezone conversion) #### 2. Context timezone header **v2 file to modify**: (Unknown — search for where v2 constructs the XML/prompt for inbound messages) **Change needed**: 1. Prepend `\n` to the formatted message block 2. The timezone should be the resolved IANA identifier (e.g., `'UTC'`, `'America/New_York'`) 3. Ensure it's placed BEFORE the `` element **Test to port**: `src/v1/formatting.test.ts:51-56` and `src/v1/formatting.test.ts:80-83` (empty array still has header) #### 3. Reply-to with message ID **v2 file to modify**: (Unknown — search for where v2 formats message metadata) **Change needed**: 1. If `message.reply_to_message_id` is present, add ` reply_to=""` attribute to the `` element 2. If BOTH `message.reply_to_message_content` AND `message.reply_to_sender_name` are present, include a nested `` element 3. XML-escape all three values (ID, sender name, content) **Test to port**: - `src/v1/formatting.test.ts:96-112` (full reply context) - `src/v1/formatting.test.ts:121-130` (ID only, no content) - `src/v1/formatting.test.ts:131-139` (XML escaping in reply) #### 4. Internal tag stripping **v2 file to modify**: (Unknown — search for where v2 processes outbound messages before sending) **Change needed**: 1. Apply the regex `/[\s\S]*?<\/internal>/g` to strip all internal thinking/reasoning blocks 2. Call `.trim()` on the result after stripping 3. Return empty string if result is empty after stripping **Test to port**: - `src/v1/formatting.test.ts:163-177` (stripInternalTags) - `src/v1/formatting.test.ts:183-194` (formatOutbound) #### 5. Scheduled task timezone handling **v2 file to modify**: (Unknown — search for where v2 handles cron task scheduling) **Change needed**: 1. When parsing cron expressions, pass the timezone option to cron-parser: ```typescript const interval = CronExpressionParser.parse(cronExpression, { tz: TIMEZONE }); ``` 2. For interval-based tasks, anchor to the original `next_run` time, not `Date.now()`, to prevent drift 3. Ensure the TIMEZONE constant is resolved at startup via a function like: ```typescript function resolveConfigTimezone(): string { const candidates = [process.env.TZ, envConfig.TZ, Intl.DateTimeFormat().resolvedOptions().timeZone]; for (const tz of candidates) { if (tz && isValidTimezone(tz)) return tz; } return 'UTC'; } ``` **Test to port**: - `src/v1/task-scheduler.test.ts:33-39` (interval anchoring) - `src/v1/task-scheduler.test.ts:40-49` (once-task returns null) - `src/v1/task-scheduler.test.ts:51-60` (interval catch-up) --- ## Git references for verification All code snippets above can be verified with: ```bash git show 27c5220:src/v1/router.ts git show 27c5220:src/v1/timezone.ts git show 27c5220:src/v1/config.ts git show 27c5220:src/v1/task-scheduler.ts git show 27c5220:src/v1/types.ts git show 27c5220:src/v1/formatting.test.ts git show 27c5220:src/v1/timezone.test.ts git show 27c5220:src/v1/task-scheduler.test.ts ``` Or from the deletion parent commit: ```bash git show 86becf8^:src/v1/ ```