From 744d1329cb492d0cfb254ad34b5ee2453f1d3687 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 18 Jan 2026 18:42:34 +0000 Subject: [PATCH] feat: make inbound envelopes configurable Co-authored-by: Shiva Prasad --- CHANGELOG.md | 2 +- docs/concepts/timezone.md | 43 ++++- docs/date-time.md | 48 +++++- extensions/bluebubbles/src/monitor.ts | 10 ++ extensions/matrix/src/matrix/monitor/index.ts | 40 +++-- .../src/monitor-handler/message-handler.ts | 36 ++-- extensions/zalo/src/monitor.ts | 13 +- extensions/zalouser/src/monitor.ts | 13 +- src/auto-reply/envelope.test.ts | 95 ++++++++++- src/auto-reply/envelope.ts | 157 +++++++++++++++++- src/config/schema.ts | 9 + src/config/sessions/store.ts | 12 ++ src/config/types.agent-defaults.ts | 12 ++ src/config/zod-schema.agent-defaults.ts | 3 + .../monitor/message-handler.process.ts | 26 ++- src/discord/monitor/reply-context.ts | 4 +- src/gateway/server-methods/chat.ts | 13 +- src/imessage/monitor/monitor-provider.ts | 21 ++- src/plugins/runtime/index.ts | 10 +- src/plugins/runtime/types.ts | 7 + src/signal/monitor/event-handler.ts | 40 +++-- src/slack/monitor/message-handler/prepare.ts | 44 +++-- src/telegram/bot-message-context.ts | 17 +- ...patterns-match-without-botusername.test.ts | 4 +- ...gram-bot.installs-grammy-throttler.test.ts | 2 +- src/telegram/bot.test.ts | 6 +- ....reconnects-after-connection-close.test.ts | 8 +- src/web/auto-reply/monitor/message-line.ts | 11 +- src/web/auto-reply/monitor/process-message.ts | 25 ++- ui/src/ui/chat/message-extract.test.ts | 34 ++++ ui/src/ui/chat/message-extract.ts | 40 ++++- ui/src/ui/controllers/chat.ts | 28 +--- 32 files changed, 688 insertions(+), 145 deletions(-) create mode 100644 ui/src/ui/chat/message-extract.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c0a8ca14..93ea9893f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ Docs: https://docs.clawd.bot ### Changes - Dependencies: update core + plugin deps (grammy, vitest, openai, Microsoft agents hosting, etc.). -- CLI: show Telegram bot username in channel status (probe/runtime). +- Agents: make inbound message envelopes configurable (timezone/timestamp/elapsed) and surface elapsed gaps. (#1150) — thanks @shiv19. ### Fixes - Configure: hide OpenRouter auto routing model from the model picker. (#1182) — thanks @zerone0x. diff --git a/docs/concepts/timezone.md b/docs/concepts/timezone.md index 9d88f1c43..3a6d3a4dd 100644 --- a/docs/concepts/timezone.md +++ b/docs/concepts/timezone.md @@ -9,7 +9,7 @@ read_when: Clawdbot standardizes timestamps so the model sees a **single reference time**. -## Message envelopes (UTC) +## Message envelopes (UTC by default) Inbound messages are wrapped in an envelope like: @@ -17,7 +17,46 @@ Inbound messages are wrapped in an envelope like: [Provider ... 2026-01-05T21:26Z] message text ``` -The timestamp in the envelope is **always UTC**, with minutes precision. +The timestamp in the envelope is **UTC by default**, with minutes precision. + +You can override this with: + +```json5 +{ + agents: { + defaults: { + envelopeTimezone: "user", // "utc" | "local" | "user" | IANA timezone + envelopeTimestamp: "on", // "on" | "off" + envelopeElapsed: "on" // "on" | "off" + } + } +} +``` + +- `envelopeTimezone: "user"` uses `agents.defaults.userTimezone` (falls back to host timezone). +- Use an explicit IANA timezone (e.g., `"Europe/Vienna"`) for a fixed offset. +- `envelopeTimestamp: "off"` removes absolute timestamps from envelope headers. +- `envelopeElapsed: "off"` removes elapsed time suffixes (the `+2m` style). + +### Examples + +**UTC (default):** + +``` +[Signal Alice +1555 2026-01-18T05:19Z] hello +``` + +**Fixed timezone:** + +``` +[Signal Alice +1555 2026-01-18 06:19 GMT+1] hello +``` + +**Elapsed time:** + +``` +[Signal Alice +1555 +2m 2026-01-18T05:19Z] follow-up +``` ## Tool payloads (raw provider data + normalized fields) diff --git a/docs/date-time.md b/docs/date-time.md index eef73e557..99da67630 100644 --- a/docs/date-time.md +++ b/docs/date-time.md @@ -7,10 +7,10 @@ read_when: # Date & Time -Clawdbot uses **UTC for transport timestamps** and **user-local time only in the system prompt**. -We avoid rewriting provider timestamps so tools keep their native semantics. +Clawdbot defaults to **UTC for transport timestamps** and **user-local time only in the system prompt**. +Provider timestamps are preserved so tools keep their native semantics. -## Message envelopes (UTC) +## Message envelopes (UTC by default) Inbound messages are wrapped with a UTC timestamp (minute precision): @@ -18,7 +18,47 @@ Inbound messages are wrapped with a UTC timestamp (minute precision): [Provider ... 2026-01-05T21:26Z] message text ``` -This envelope timestamp is **always UTC**, regardless of the host timezone. +This envelope timestamp is **UTC by default**, regardless of the host timezone. + +You can override this behavior: + +```json5 +{ + agents: { + defaults: { + envelopeTimezone: "utc", // "utc" | "local" | "user" | IANA timezone + envelopeTimestamp: "on", // "on" | "off" + envelopeElapsed: "on" // "on" | "off" + } + } +} +``` + +- `envelopeTimezone: "local"` uses the host timezone. +- `envelopeTimezone: "user"` uses `agents.defaults.userTimezone` (falls back to host timezone). +- Use an explicit IANA timezone (e.g., `"America/Chicago"`) for a fixed zone. +- `envelopeTimestamp: "off"` removes absolute timestamps from envelope headers. +- `envelopeElapsed: "off"` removes elapsed time suffixes (the `+2m` style). + +### Examples + +**UTC (default):** + +``` +[WhatsApp +1555 2026-01-18T05:19Z] hello +``` + +**User timezone:** + +``` +[WhatsApp +1555 2026-01-18 00:19 CST] hello +``` + +**Elapsed time enabled:** + +``` +[WhatsApp +1555 +30s 2026-01-18T05:19Z] follow-up +``` ## System prompt: Current Date & Time diff --git a/extensions/bluebubbles/src/monitor.ts b/extensions/bluebubbles/src/monitor.ts index ccd2cf7a8..9625d91a8 100644 --- a/extensions/bluebubbles/src/monitor.ts +++ b/extensions/bluebubbles/src/monitor.ts @@ -836,10 +836,20 @@ async function processMessage( const fromLabel = message.isGroup ? `group:${peerId}` : message.senderName || `user:${message.senderId}`; + const storePath = core.channel.session.resolveStorePath(config.session?.store, { + agentId: route.agentId, + }); + const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config); + const previousTimestamp = core.channel.session.readSessionUpdatedAt({ + storePath, + sessionKey: route.sessionKey, + }); const body = core.channel.reply.formatAgentEnvelope({ channel: "BlueBubbles", from: fromLabel, timestamp: message.timestamp, + previousTimestamp, + envelope: envelopeOptions, body: rawBody, }); let chatGuidForActions = chatGuid; diff --git a/extensions/matrix/src/matrix/monitor/index.ts b/extensions/matrix/src/matrix/monitor/index.ts index db6db5508..4b8ee51fd 100644 --- a/extensions/matrix/src/matrix/monitor/index.ts +++ b/extensions/matrix/src/matrix/monitor/index.ts @@ -466,25 +466,34 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi isThreadRoot: event.isThreadRoot, }); + const route = core.channel.routing.resolveAgentRoute({ + cfg, + channel: "matrix", + peer: { + kind: isDirectMessage ? "dm" : "channel", + id: isDirectMessage ? senderId : roomId, + }, + }); const envelopeFrom = isDirectMessage ? senderName : (roomName ?? roomId); const textWithId = `${bodyText}\n[matrix event id: ${messageId} room: ${roomId}]`; + const storePath = core.channel.session.resolveStorePath(cfg.session?.store, { + agentId: route.agentId, + }); + const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg); + const previousTimestamp = core.channel.session.readSessionUpdatedAt({ + storePath, + sessionKey: route.sessionKey, + }); const body = core.channel.reply.formatAgentEnvelope({ channel: "Matrix", from: envelopeFrom, timestamp: event.getTs() ?? undefined, + previousTimestamp, + envelope: envelopeOptions, body: textWithId, }); - const route = core.channel.routing.resolveAgentRoute({ - cfg, - channel: "matrix", - peer: { - kind: isDirectMessage ? "dm" : "channel", - id: isDirectMessage ? senderId : roomId, - }, - }); - - const groupSystemPrompt = roomConfigInfo.config?.systemPrompt?.trim() || undefined; + const groupSystemPrompt = roomConfigInfo.config?.systemPrompt?.trim() || undefined; const ctxPayload = core.channel.reply.finalizeInboundContext({ Body: body, RawBody: bodyText, @@ -517,13 +526,10 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi OriginatingTo: `room:${roomId}`, }); - const storePath = core.channel.session.resolveStorePath(cfg.session?.store, { - agentId: route.agentId, - }); - void core.channel.session.recordSessionMetaFromInbound({ - storePath, - sessionKey: ctxPayload.SessionKey ?? route.sessionKey, - ctx: ctxPayload, + void core.channel.session.recordSessionMetaFromInbound({ + storePath, + sessionKey: ctxPayload.SessionKey ?? route.sessionKey, + ctx: ctxPayload, }).catch((err) => { logger.warn( { error: String(err), storePath, sessionKey: ctxPayload.SessionKey ?? route.sessionKey }, diff --git a/extensions/msteams/src/monitor-handler/message-handler.ts b/extensions/msteams/src/monitor-handler/message-handler.ts index 6addc74fe..ee48b82ca 100644 --- a/extensions/msteams/src/monitor-handler/message-handler.ts +++ b/extensions/msteams/src/monitor-handler/message-handler.ts @@ -406,10 +406,20 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { const mediaPayload = buildMSTeamsMediaPayload(mediaList); const envelopeFrom = isDirectMessage ? senderName : conversationType; + const storePath = core.channel.session.resolveStorePath(cfg.session?.store, { + agentId: route.agentId, + }); + const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg); + const previousTimestamp = core.channel.session.readSessionUpdatedAt({ + storePath, + sessionKey: route.sessionKey, + }); const body = core.channel.reply.formatAgentEnvelope({ channel: "Teams", from: envelopeFrom, timestamp, + previousTimestamp, + envelope: envelopeOptions, body: rawBody, }); let combinedBody = body; @@ -421,15 +431,16 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { historyKey, limit: historyLimit, currentMessage: combinedBody, - formatEntry: (entry) => - core.channel.reply.formatAgentEnvelope({ - channel: "Teams", - from: conversationType, - timestamp: entry.timestamp, - body: `${entry.sender}: ${entry.body}${entry.messageId ? ` [id:${entry.messageId}]` : ""}`, - }), - }); - } + formatEntry: (entry) => + core.channel.reply.formatAgentEnvelope({ + channel: "Teams", + from: conversationType, + timestamp: entry.timestamp, + body: `${entry.sender}: ${entry.body}${entry.messageId ? ` [id:${entry.messageId}]` : ""}`, + envelope: envelopeOptions, + }), + }); + } const ctxPayload = core.channel.reply.finalizeInboundContext({ Body: combinedBody, @@ -455,11 +466,8 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { ...mediaPayload, }); - const storePath = core.channel.session.resolveStorePath(cfg.session?.store, { - agentId: route.agentId, - }); - void core.channel.session.recordSessionMetaFromInbound({ - storePath, + void core.channel.session.recordSessionMetaFromInbound({ + storePath, sessionKey: ctxPayload.SessionKey ?? route.sessionKey, ctx: ctxPayload, }).catch((err) => { diff --git a/extensions/zalo/src/monitor.ts b/extensions/zalo/src/monitor.ts index f460d23a0..cb68388cf 100644 --- a/extensions/zalo/src/monitor.ts +++ b/extensions/zalo/src/monitor.ts @@ -530,10 +530,20 @@ async function processMessageWithPipeline(params: { } const fromLabel = isGroup ? `group:${chatId}` : senderName || `user:${senderId}`; + const storePath = core.channel.session.resolveStorePath(config.session?.store, { + agentId: route.agentId, + }); + const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config); + const previousTimestamp = core.channel.session.readSessionUpdatedAt({ + storePath, + sessionKey: route.sessionKey, + }); const body = core.channel.reply.formatAgentEnvelope({ channel: "Zalo", from: fromLabel, timestamp: date ? date * 1000 : undefined, + previousTimestamp, + envelope: envelopeOptions, body: rawBody, }); @@ -560,9 +570,6 @@ async function processMessageWithPipeline(params: { OriginatingTo: `zalo:${chatId}`, }); - const storePath = core.channel.session.resolveStorePath(config.session?.store, { - agentId: route.agentId, - }); void core.channel.session.recordSessionMetaFromInbound({ storePath, sessionKey: ctxPayload.SessionKey ?? route.sessionKey, diff --git a/extensions/zalouser/src/monitor.ts b/extensions/zalouser/src/monitor.ts index ec1cb5a60..b3ab31dd3 100644 --- a/extensions/zalouser/src/monitor.ts +++ b/extensions/zalouser/src/monitor.ts @@ -274,10 +274,20 @@ async function processMessage( }); const fromLabel = isGroup ? `group:${chatId}` : senderName || `user:${senderId}`; + const storePath = core.channel.session.resolveStorePath(config.session?.store, { + agentId: route.agentId, + }); + const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config); + const previousTimestamp = core.channel.session.readSessionUpdatedAt({ + storePath, + sessionKey: route.sessionKey, + }); const body = core.channel.reply.formatAgentEnvelope({ channel: "Zalo Personal", from: fromLabel, timestamp: timestamp ? timestamp * 1000 : undefined, + previousTimestamp, + envelope: envelopeOptions, body: rawBody, }); @@ -301,9 +311,6 @@ async function processMessage( OriginatingTo: `zalouser:${chatId}`, }); - const storePath = core.channel.session.resolveStorePath(config.session?.store, { - agentId: route.agentId, - }); void core.channel.session.recordSessionMetaFromInbound({ storePath, sessionKey: ctxPayload.SessionKey ?? route.sessionKey, diff --git a/src/auto-reply/envelope.test.ts b/src/auto-reply/envelope.test.ts index 7e8f39150..d811fbd2f 100644 --- a/src/auto-reply/envelope.test.ts +++ b/src/auto-reply/envelope.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; -import { formatAgentEnvelope, formatInboundEnvelope } from "./envelope.js"; +import { + formatAgentEnvelope, + formatInboundEnvelope, + resolveEnvelopeFormatOptions, +} from "./envelope.js"; describe("formatAgentEnvelope", () => { it("includes channel, from, ip, host, and timestamp", () => { @@ -38,6 +42,46 @@ describe("formatAgentEnvelope", () => { expect(body).toBe("[WebChat 2025-01-02T03:04Z] hello"); }); + it("formats timestamps in local timezone when configured", () => { + const originalTz = process.env.TZ; + process.env.TZ = "America/Los_Angeles"; + + const ts = Date.UTC(2025, 0, 2, 3, 4); // 2025-01-02T03:04:00Z (19:04 PST) + const body = formatAgentEnvelope({ + channel: "WebChat", + timestamp: ts, + envelope: { timezone: "local" }, + body: "hello", + }); + + process.env.TZ = originalTz; + + expect(body).toMatch(/\[WebChat 2025-01-01 19:04 [^\]]+\] hello/); + }); + + it("formats timestamps in user timezone when configured", () => { + const ts = Date.UTC(2025, 0, 2, 3, 4); // 2025-01-02T03:04:00Z (04:04 CET) + const body = formatAgentEnvelope({ + channel: "WebChat", + timestamp: ts, + envelope: { timezone: "user", userTimezone: "Europe/Vienna" }, + body: "hello", + }); + + expect(body).toMatch(/\[WebChat 2025-01-02 04:04 [^\]]+\] hello/); + }); + + it("omits timestamps when configured", () => { + const ts = Date.UTC(2025, 0, 2, 3, 4); + const body = formatAgentEnvelope({ + channel: "WebChat", + timestamp: ts, + envelope: { includeTimestamp: false }, + body: "hello", + }); + expect(body).toBe("[WebChat] hello"); + }); + it("handles missing optional fields", () => { const body = formatAgentEnvelope({ channel: "Telegram", body: "hi" }); expect(body).toBe("[Telegram] hi"); @@ -77,4 +121,53 @@ describe("formatInboundEnvelope", () => { }); expect(body).toBe("[iMessage +1555] hello"); }); + + it("includes elapsed time when previousTimestamp is provided", () => { + const now = Date.now(); + const twoMinutesAgo = now - 2 * 60 * 1000; + const body = formatInboundEnvelope({ + channel: "Telegram", + from: "Alice", + body: "follow-up message", + timestamp: now, + previousTimestamp: twoMinutesAgo, + chatType: "direct", + envelope: { includeTimestamp: false }, + }); + expect(body).toContain("Alice +2m"); + expect(body).toContain("follow-up message"); + }); + + it("omits elapsed time when disabled", () => { + const now = Date.now(); + const body = formatInboundEnvelope({ + channel: "Telegram", + from: "Alice", + body: "follow-up message", + timestamp: now, + previousTimestamp: now - 2 * 60 * 1000, + chatType: "direct", + envelope: { includeElapsed: false, includeTimestamp: false }, + }); + expect(body).toBe("[Telegram Alice] follow-up message"); + }); + + it("resolves envelope options from config", () => { + const options = resolveEnvelopeFormatOptions({ + agents: { + defaults: { + envelopeTimezone: "user", + envelopeTimestamp: "off", + envelopeElapsed: "off", + userTimezone: "Europe/Vienna", + }, + }, + }); + expect(options).toEqual({ + timezone: "user", + includeTimestamp: false, + includeElapsed: false, + userTimezone: "Europe/Vienna", + }); + }); }); diff --git a/src/auto-reply/envelope.ts b/src/auto-reply/envelope.ts index a3e0ba1fe..064533eca 100644 --- a/src/auto-reply/envelope.ts +++ b/src/auto-reply/envelope.ts @@ -1,5 +1,7 @@ +import { resolveUserTimezone } from "../agents/date-time.js"; import { normalizeChatType } from "../channels/chat-type.js"; import { resolveSenderLabel, type SenderLabelParams } from "../channels/sender-label.js"; +import type { ClawdbotConfig } from "../config/config.js"; export type AgentEnvelopeParams = { channel: string; @@ -8,31 +10,162 @@ export type AgentEnvelopeParams = { host?: string; ip?: string; body: string; + previousTimestamp?: number | Date; + envelope?: EnvelopeFormatOptions; }; -function formatTimestamp(ts?: number | Date): string | undefined { - if (!ts) return undefined; - const date = ts instanceof Date ? ts : new Date(ts); - if (Number.isNaN(date.getTime())) return undefined; +export type EnvelopeFormatOptions = { + /** + * "utc" (default), "local", "user", or an explicit IANA timezone string. + */ + timezone?: string; + /** + * Include absolute timestamps in the envelope (default: true). + */ + includeTimestamp?: boolean; + /** + * Include elapsed time suffix when previousTimestamp is provided (default: true). + */ + includeElapsed?: boolean; + /** + * Optional user timezone used when timezone="user". + */ + userTimezone?: string; +}; +type ResolvedEnvelopeTimezone = + | { mode: "utc" } + | { mode: "local" } + | { mode: "iana"; timeZone: string }; + +export function resolveEnvelopeFormatOptions(cfg?: ClawdbotConfig): EnvelopeFormatOptions { + const defaults = cfg?.agents?.defaults; + return { + timezone: defaults?.envelopeTimezone, + includeTimestamp: defaults?.envelopeTimestamp !== "off", + includeElapsed: defaults?.envelopeElapsed !== "off", + userTimezone: defaults?.userTimezone, + }; +} + +function normalizeEnvelopeOptions(options?: EnvelopeFormatOptions): Required { + const includeTimestamp = options?.includeTimestamp !== false; + const includeElapsed = options?.includeElapsed !== false; + return { + timezone: options?.timezone?.trim() || "utc", + includeTimestamp, + includeElapsed, + userTimezone: options?.userTimezone, + }; +} + +function resolveExplicitTimezone(value: string): string | undefined { + try { + new Intl.DateTimeFormat("en-US", { timeZone: value }).format(new Date()); + return value; + } catch { + return undefined; + } +} + +function resolveEnvelopeTimezone(options: EnvelopeFormatOptions): ResolvedEnvelopeTimezone { + const trimmed = options.timezone?.trim(); + if (!trimmed) return { mode: "utc" }; + const lowered = trimmed.toLowerCase(); + if (lowered === "utc" || lowered === "gmt") return { mode: "utc" }; + if (lowered === "local" || lowered === "host") return { mode: "local" }; + if (lowered === "user") { + return { mode: "iana", timeZone: resolveUserTimezone(options.userTimezone) }; + } + const explicit = resolveExplicitTimezone(trimmed); + return explicit ? { mode: "iana", timeZone: explicit } : { mode: "utc" }; +} + +function formatUtcTimestamp(date: Date): string { const yyyy = String(date.getUTCFullYear()).padStart(4, "0"); const mm = String(date.getUTCMonth() + 1).padStart(2, "0"); const dd = String(date.getUTCDate()).padStart(2, "0"); const hh = String(date.getUTCHours()).padStart(2, "0"); const min = String(date.getUTCMinutes()).padStart(2, "0"); - - // Compact ISO-like UTC timestamp with minutes precision. - // Example: 2025-01-02T03:04Z return `${yyyy}-${mm}-${dd}T${hh}:${min}Z`; } +function formatZonedTimestamp(date: Date, timeZone?: string): string | undefined { + const parts = new Intl.DateTimeFormat("en-US", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hourCycle: "h23", + timeZoneName: "short", + }).formatToParts(date); + const pick = (type: string) => parts.find((part) => part.type === type)?.value; + const yyyy = pick("year"); + const mm = pick("month"); + const dd = pick("day"); + const hh = pick("hour"); + const min = pick("minute"); + const tz = [...parts] + .reverse() + .find((part) => part.type === "timeZoneName") + ?.value?.trim(); + if (!yyyy || !mm || !dd || !hh || !min) return undefined; + return `${yyyy}-${mm}-${dd} ${hh}:${min}${tz ? ` ${tz}` : ""}`; +} + +function formatTimestamp(ts: number | Date | undefined, options?: EnvelopeFormatOptions): string | undefined { + if (!ts) return undefined; + const resolved = normalizeEnvelopeOptions(options); + if (!resolved.includeTimestamp) return undefined; + const date = ts instanceof Date ? ts : new Date(ts); + if (Number.isNaN(date.getTime())) return undefined; + const zone = resolveEnvelopeTimezone(resolved); + if (zone.mode === "utc") return formatUtcTimestamp(date); + if (zone.mode === "local") return formatZonedTimestamp(date); + return formatZonedTimestamp(date, zone.timeZone); +} + +function formatElapsedTime(currentMs: number, previousMs: number): string | undefined { + const elapsedMs = currentMs - previousMs; + if (!Number.isFinite(elapsedMs) || elapsedMs < 0) return undefined; + + const seconds = Math.floor(elapsedMs / 1000); + if (seconds < 60) return `${seconds}s`; + + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m`; + + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h`; + + const days = Math.floor(hours / 24); + return `${days}d`; +} + export function formatAgentEnvelope(params: AgentEnvelopeParams): string { const channel = params.channel?.trim() || "Channel"; const parts: string[] = [channel]; - if (params.from?.trim()) parts.push(params.from.trim()); + const resolved = normalizeEnvelopeOptions(params.envelope); + const elapsed = + resolved.includeElapsed && params.timestamp && params.previousTimestamp + ? formatElapsedTime( + params.timestamp instanceof Date ? params.timestamp.getTime() : params.timestamp, + params.previousTimestamp instanceof Date + ? params.previousTimestamp.getTime() + : params.previousTimestamp, + ) + : undefined; + if (params.from?.trim()) { + const from = params.from.trim(); + parts.push(elapsed ? `${from} +${elapsed}` : from); + } else if (elapsed) { + parts.push(`+${elapsed}`); + } if (params.host?.trim()) parts.push(params.host.trim()); if (params.ip?.trim()) parts.push(params.ip.trim()); - const ts = formatTimestamp(params.timestamp); + const ts = formatTimestamp(params.timestamp, resolved); if (ts) parts.push(ts); const header = `[${parts.join(" ")}]`; return `${header} ${params.body}`; @@ -46,6 +179,8 @@ export function formatInboundEnvelope(params: { chatType?: string; senderLabel?: string; sender?: SenderLabelParams; + previousTimestamp?: number | Date; + envelope?: EnvelopeFormatOptions; }): string { const chatType = normalizeChatType(params.chatType); const isDirect = !chatType || chatType === "direct"; @@ -55,6 +190,8 @@ export function formatInboundEnvelope(params: { channel: params.channel, from: params.from, timestamp: params.timestamp, + previousTimestamp: params.previousTimestamp, + envelope: params.envelope, body, }); } @@ -85,11 +222,13 @@ export function formatThreadStarterEnvelope(params: { author?: string; timestamp?: number | Date; body: string; + envelope?: EnvelopeFormatOptions; }): string { return formatAgentEnvelope({ channel: params.channel, from: params.author, timestamp: params.timestamp, + envelope: params.envelope, body: params.body, }); } diff --git a/src/config/schema.ts b/src/config/schema.ts index 30e643c2f..854c56986 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -172,6 +172,9 @@ const FIELD_LABELS: Record = { "skills.load.watchDebounceMs": "Skills Watch Debounce (ms)", "agents.defaults.workspace": "Workspace", "agents.defaults.bootstrapMaxChars": "Bootstrap Max Chars", + "agents.defaults.envelopeTimezone": "Envelope Timezone", + "agents.defaults.envelopeTimestamp": "Envelope Timestamp", + "agents.defaults.envelopeElapsed": "Envelope Elapsed", "agents.defaults.memorySearch": "Memory Search", "agents.defaults.memorySearch.enabled": "Enable Memory Search", "agents.defaults.memorySearch.sources": "Memory Search Sources", @@ -371,6 +374,12 @@ const FIELD_HELP: Record = { "auth.cooldowns.failureWindowHours": "Failure window (hours) for backoff counters (default: 24).", "agents.defaults.bootstrapMaxChars": "Max characters of each workspace bootstrap file injected into the system prompt before truncation (default: 20000).", + "agents.defaults.envelopeTimezone": + 'Timezone for message envelopes ("utc", "local", "user", or an IANA timezone string).', + "agents.defaults.envelopeTimestamp": + 'Include absolute timestamps in message envelopes ("on" or "off").', + "agents.defaults.envelopeElapsed": + 'Include elapsed time in message envelopes ("on" or "off").', "agents.defaults.models": "Configured model catalog (keys are full provider/model IDs).", "agents.defaults.memorySearch": "Vector search over MEMORY.md and memory/*.md (per-agent overrides supported).", diff --git a/src/config/sessions/store.ts b/src/config/sessions/store.ts index 006acc9f7..8ecf576f7 100644 --- a/src/config/sessions/store.ts +++ b/src/config/sessions/store.ts @@ -155,6 +155,18 @@ export function loadSessionStore( return structuredClone(store); } +export function readSessionUpdatedAt(params: { + storePath: string; + sessionKey: string; +}): number | undefined { + try { + const store = loadSessionStore(params.storePath); + return store[params.sessionKey]?.updatedAt; + } catch { + return undefined; + } +} + async function saveSessionStoreUnlocked( storePath: string, store: Record, diff --git a/src/config/types.agent-defaults.ts b/src/config/types.agent-defaults.ts index aa4d48b3b..85eff97f2 100644 --- a/src/config/types.agent-defaults.ts +++ b/src/config/types.agent-defaults.ts @@ -105,6 +105,18 @@ export type AgentDefaultsConfig = { userTimezone?: string; /** Time format in system prompt: auto (OS preference), 12-hour, or 24-hour. */ timeFormat?: "auto" | "12" | "24"; + /** + * Envelope timestamp timezone: "utc" (default), "local", "user", or an IANA timezone string. + */ + envelopeTimezone?: string; + /** + * Include absolute timestamps in message envelopes ("on" | "off", default: "on"). + */ + envelopeTimestamp?: "on" | "off"; + /** + * Include elapsed time in message envelopes ("on" | "off", default: "on"). + */ + envelopeElapsed?: "on" | "off"; /** Optional display-only context window override (used for % in status UIs). */ contextTokens?: number; /** Optional CLI backends for text-only fallback (claude-cli, etc.). */ diff --git a/src/config/zod-schema.agent-defaults.ts b/src/config/zod-schema.agent-defaults.ts index d32a2cb45..869f7ee21 100644 --- a/src/config/zod-schema.agent-defaults.ts +++ b/src/config/zod-schema.agent-defaults.ts @@ -42,6 +42,9 @@ export const AgentDefaultsSchema = z bootstrapMaxChars: z.number().int().positive().optional(), userTimezone: z.string().optional(), timeFormat: z.union([z.literal("auto"), z.literal("12"), z.literal("24")]).optional(), + envelopeTimezone: z.string().optional(), + envelopeTimestamp: z.union([z.literal("on"), z.literal("off")]).optional(), + envelopeElapsed: z.union([z.literal("on"), z.literal("off")]).optional(), contextTokens: z.number().int().positive().optional(), cliBackends: z.record(z.string(), CliBackendSchema).optional(), memorySearch: MemorySearchSchema, diff --git a/src/discord/monitor/message-handler.process.ts b/src/discord/monitor/message-handler.process.ts index 39d4f47d4..4838c9d44 100644 --- a/src/discord/monitor/message-handler.process.ts +++ b/src/discord/monitor/message-handler.process.ts @@ -8,7 +8,11 @@ import { extractShortModelName, type ResponsePrefixContext, } from "../../auto-reply/reply/response-prefix-template.js"; -import { formatInboundEnvelope, formatThreadStarterEnvelope } from "../../auto-reply/envelope.js"; +import { + formatInboundEnvelope, + formatThreadStarterEnvelope, + resolveEnvelopeFormatOptions, +} from "../../auto-reply/envelope.js"; import { dispatchReplyFromConfig } from "../../auto-reply/reply/dispatch-from-config.js"; import { buildPendingHistoryContextFromMap, @@ -18,6 +22,7 @@ import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.j import { createReplyDispatcherWithTyping } from "../../auto-reply/reply/reply-dispatcher.js"; import type { ReplyPayload } from "../../auto-reply/types.js"; import { + readSessionUpdatedAt, recordSessionMetaFromInbound, resolveStorePath, updateLastRoute, @@ -137,6 +142,14 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) ].filter((entry): entry is string => Boolean(entry)); const groupSystemPrompt = systemPromptParts.length > 0 ? systemPromptParts.join("\n\n") : undefined; + const storePath = resolveStorePath(cfg.session?.store, { + agentId: route.agentId, + }); + const envelopeOptions = resolveEnvelopeFormatOptions(cfg); + const previousTimestamp = readSessionUpdatedAt({ + storePath, + sessionKey: route.sessionKey, + }); let combinedBody = formatInboundEnvelope({ channel: "Discord", from: fromLabel, @@ -144,6 +157,8 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) body: text, chatType: isDirectMessage ? "direct" : "channel", senderLabel, + previousTimestamp, + envelope: envelopeOptions, }); const shouldIncludeChannelHistory = !isDirectMessage && !(isGuildMessage && channelConfig?.autoThread && !threadChannel); @@ -161,10 +176,13 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) body: `${entry.body} [id:${entry.messageId ?? "unknown"} channel:${message.channelId}]`, chatType: "channel", senderLabel: entry.sender, + envelope: envelopeOptions, }), }); } - const replyContext = resolveReplyContext(message, resolveDiscordMessageText); + const replyContext = resolveReplyContext(message, resolveDiscordMessageText, { + envelope: envelopeOptions, + }); if (replyContext) { combinedBody = `[Replied message - for context]\n${replyContext}\n\n${combinedBody}`; } @@ -186,6 +204,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) author: starter.author, timestamp: starter.timestamp, body: starter.text, + envelope: envelopeOptions, }); threadStarterBody = starterEnvelope; } @@ -268,9 +287,6 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) OriginatingTo: autoThreadContext?.OriginatingTo ?? replyTarget, }); - const storePath = resolveStorePath(cfg.session?.store, { - agentId: route.agentId, - }); void recordSessionMetaFromInbound({ storePath, sessionKey: ctxPayload.SessionKey ?? route.sessionKey, diff --git a/src/discord/monitor/reply-context.ts b/src/discord/monitor/reply-context.ts index 0106f8b8f..a3095050f 100644 --- a/src/discord/monitor/reply-context.ts +++ b/src/discord/monitor/reply-context.ts @@ -1,11 +1,12 @@ import type { Guild, Message, User } from "@buape/carbon"; -import { formatAgentEnvelope } from "../../auto-reply/envelope.js"; +import { formatAgentEnvelope, type EnvelopeFormatOptions } from "../../auto-reply/envelope.js"; import { formatDiscordUserTag, resolveTimestampMs } from "./format.js"; export function resolveReplyContext( message: Message, resolveDiscordMessageText: (message: Message, options?: { includeForwarded?: boolean }) => string, + options?: { envelope?: EnvelopeFormatOptions }, ): string | null { const referenced = message.referencedMessage; if (!referenced?.author) return null; @@ -20,6 +21,7 @@ export function resolveReplyContext( from: fromLabel, timestamp: resolveTimestampMs(referenced.timestamp), body, + envelope: options?.envelope, }); } diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index 4ac678b1f..7c2b5c581 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { resolveThinkingDefault } from "../../agents/model-selection.js"; import { resolveAgentTimeoutMs } from "../../agents/timeout.js"; +import { formatInboundEnvelope, resolveEnvelopeFormatOptions } from "../../auto-reply/envelope.js"; import { agentCommand } from "../../commands/agent.js"; import { mergeSessionEntry, updateSessionStore } from "../../config/sessions.js"; import { registerAgentRunContext } from "../../infra/agent-events.js"; @@ -300,10 +301,20 @@ export const chatHandlers: GatewayRequestHandlers = { }; respond(true, ackPayload, undefined, { runId: clientRunId }); + const envelopeOptions = resolveEnvelopeFormatOptions(cfg); + const envelopedMessage = formatInboundEnvelope({ + channel: "WebChat", + from: p.sessionKey, + timestamp: now, + body: parsedMessage, + chatType: "direct", + previousTimestamp: entry?.updatedAt, + envelope: envelopeOptions, + }); const lane = isAcpSessionKey(p.sessionKey) ? p.sessionKey : undefined; void agentCommand( { - message: parsedMessage, + message: envelopedMessage, images: parsedImages.length > 0 ? parsedImages : undefined, sessionId, sessionKey: p.sessionKey, diff --git a/src/imessage/monitor/monitor-provider.ts b/src/imessage/monitor/monitor-provider.ts index 60dea2c24..2e65848ac 100644 --- a/src/imessage/monitor/monitor-provider.ts +++ b/src/imessage/monitor/monitor-provider.ts @@ -11,7 +11,11 @@ import { } from "../../auto-reply/reply/response-prefix-template.js"; import { resolveTextChunkLimit } from "../../auto-reply/chunk.js"; import { hasControlCommand } from "../../auto-reply/command-detection.js"; -import { formatInboundEnvelope, formatInboundFromLabel } from "../../auto-reply/envelope.js"; +import { + formatInboundEnvelope, + formatInboundFromLabel, + resolveEnvelopeFormatOptions, +} from "../../auto-reply/envelope.js"; import { createInboundDebouncer, resolveInboundDebounceMs, @@ -33,6 +37,7 @@ import { resolveChannelGroupRequireMention, } from "../../config/group-policy.js"; import { + readSessionUpdatedAt, recordSessionMetaFromInbound, resolveStorePath, updateLastRoute, @@ -401,6 +406,14 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P directLabel: senderNormalized, directId: sender, }); + const storePath = resolveStorePath(cfg.session?.store, { + agentId: route.agentId, + }); + const envelopeOptions = resolveEnvelopeFormatOptions(cfg); + const previousTimestamp = readSessionUpdatedAt({ + storePath, + sessionKey: route.sessionKey, + }); const body = formatInboundEnvelope({ channel: "iMessage", from: fromLabel, @@ -408,6 +421,8 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P body: bodyText, chatType: isGroup ? "group" : "direct", sender: { name: senderNormalized, id: sender }, + previousTimestamp, + envelope: envelopeOptions, }); let combinedBody = body; if (isGroup && historyKey && historyLimit > 0) { @@ -424,6 +439,7 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P body: `${entry.body}${entry.messageId ? ` [id:${entry.messageId}]` : ""}`, chatType: "group", senderLabel: entry.sender, + envelope: envelopeOptions, }), }); } @@ -461,9 +477,6 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P OriginatingTo: imessageTo, }); - const storePath = resolveStorePath(cfg.session?.store, { - agentId: route.agentId, - }); void recordSessionMetaFromInbound({ storePath, sessionKey: ctxPayload.SessionKey ?? route.sessionKey, diff --git a/src/plugins/runtime/index.ts b/src/plugins/runtime/index.ts index 7b38fd967..51eff2734 100644 --- a/src/plugins/runtime/index.ts +++ b/src/plugins/runtime/index.ts @@ -11,7 +11,11 @@ import { createInboundDebouncer, resolveInboundDebounceMs, } from "../../auto-reply/inbound-debounce.js"; -import { formatAgentEnvelope } from "../../auto-reply/envelope.js"; +import { + formatAgentEnvelope, + formatInboundEnvelope, + resolveEnvelopeFormatOptions, +} from "../../auto-reply/envelope.js"; import { dispatchReplyFromConfig } from "../../auto-reply/reply/dispatch-from-config.js"; import { buildMentionRegexes, matchesMentionPatterns } from "../../auto-reply/reply/mentions.js"; import { dispatchReplyWithBufferedBlockDispatcher } from "../../auto-reply/reply/provider-dispatcher.js"; @@ -33,6 +37,7 @@ import { import { resolveStateDir } from "../../config/paths.js"; import { loadConfig, writeConfigFile } from "../../config/config.js"; import { + readSessionUpdatedAt, recordSessionMetaFromInbound, resolveStorePath, updateLastRoute, @@ -157,6 +162,8 @@ export function createPluginRuntime(): PluginRuntime { dispatchReplyFromConfig, finalizeInboundContext, formatAgentEnvelope, + formatInboundEnvelope, + resolveEnvelopeFormatOptions, }, routing: { resolveAgentRoute, @@ -172,6 +179,7 @@ export function createPluginRuntime(): PluginRuntime { }, session: { resolveStorePath, + readSessionUpdatedAt, recordSessionMetaFromInbound, updateLastRoute, }, diff --git a/src/plugins/runtime/types.ts b/src/plugins/runtime/types.ts index 3a7c211da..397a79f09 100644 --- a/src/plugins/runtime/types.ts +++ b/src/plugins/runtime/types.ts @@ -44,10 +44,14 @@ type DispatchReplyFromConfig = type FinalizeInboundContext = typeof import("../../auto-reply/reply/inbound-context.js").finalizeInboundContext; type FormatAgentEnvelope = typeof import("../../auto-reply/envelope.js").formatAgentEnvelope; +type FormatInboundEnvelope = typeof import("../../auto-reply/envelope.js").formatInboundEnvelope; +type ResolveEnvelopeFormatOptions = + typeof import("../../auto-reply/envelope.js").resolveEnvelopeFormatOptions; type ResolveStateDir = typeof import("../../config/paths.js").resolveStateDir; type RecordSessionMetaFromInbound = typeof import("../../config/sessions.js").recordSessionMetaFromInbound; type ResolveStorePath = typeof import("../../config/sessions.js").resolveStorePath; +type ReadSessionUpdatedAt = typeof import("../../config/sessions.js").readSessionUpdatedAt; type UpdateLastRoute = typeof import("../../config/sessions.js").updateLastRoute; type LoadConfig = typeof import("../../config/config.js").loadConfig; type WriteConfigFile = typeof import("../../config/config.js").writeConfigFile; @@ -169,6 +173,8 @@ export type PluginRuntime = { dispatchReplyFromConfig: DispatchReplyFromConfig; finalizeInboundContext: FinalizeInboundContext; formatAgentEnvelope: FormatAgentEnvelope; + formatInboundEnvelope: FormatInboundEnvelope; + resolveEnvelopeFormatOptions: ResolveEnvelopeFormatOptions; }; routing: { resolveAgentRoute: ResolveAgentRoute; @@ -184,6 +190,7 @@ export type PluginRuntime = { }; session: { resolveStorePath: ResolveStorePath; + readSessionUpdatedAt: ReadSessionUpdatedAt; recordSessionMetaFromInbound: RecordSessionMetaFromInbound; updateLastRoute: UpdateLastRoute; }; diff --git a/src/signal/monitor/event-handler.ts b/src/signal/monitor/event-handler.ts index bfbd9bd72..4bc29b7e8 100644 --- a/src/signal/monitor/event-handler.ts +++ b/src/signal/monitor/event-handler.ts @@ -8,7 +8,11 @@ import { type ResponsePrefixContext, } from "../../auto-reply/reply/response-prefix-template.js"; import { hasControlCommand } from "../../auto-reply/command-detection.js"; -import { formatInboundEnvelope, formatInboundFromLabel } from "../../auto-reply/envelope.js"; +import { + formatInboundEnvelope, + formatInboundFromLabel, + resolveEnvelopeFormatOptions, +} from "../../auto-reply/envelope.js"; import { createInboundDebouncer, resolveInboundDebounceMs, @@ -21,6 +25,7 @@ import { import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js"; import { createReplyDispatcher } from "../../auto-reply/reply/reply-dispatcher.js"; import { + readSessionUpdatedAt, recordSessionMetaFromInbound, resolveStorePath, updateLastRoute, @@ -77,6 +82,23 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { directLabel: entry.senderName, directId: entry.senderDisplay, }); + const route = resolveAgentRoute({ + cfg: deps.cfg, + channel: "signal", + accountId: deps.accountId, + peer: { + kind: entry.isGroup ? "group" : "dm", + id: entry.isGroup ? (entry.groupId ?? "unknown") : entry.senderPeerId, + }, + }); + const storePath = resolveStorePath(deps.cfg.session?.store, { + agentId: route.agentId, + }); + const envelopeOptions = resolveEnvelopeFormatOptions(deps.cfg); + const previousTimestamp = readSessionUpdatedAt({ + storePath, + sessionKey: route.sessionKey, + }); const body = formatInboundEnvelope({ channel: "Signal", from: fromLabel, @@ -84,6 +106,8 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { body: entry.bodyText, chatType: entry.isGroup ? "group" : "direct", sender: { name: entry.senderName, id: entry.senderDisplay }, + previousTimestamp, + envelope: envelopeOptions, }); let combinedBody = body; const historyKey = entry.isGroup ? String(entry.groupId ?? "unknown") : undefined; @@ -103,19 +127,10 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { }`, chatType: "group", senderLabel: historyEntry.sender, + envelope: envelopeOptions, }), }); } - - const route = resolveAgentRoute({ - cfg: deps.cfg, - channel: "signal", - accountId: deps.accountId, - peer: { - kind: entry.isGroup ? "group" : "dm", - id: entry.isGroup ? (entry.groupId ?? "unknown") : entry.senderPeerId, - }, - }); const signalTo = entry.isGroup ? `group:${entry.groupId}` : `signal:${entry.senderRecipient}`; const ctxPayload = finalizeInboundContext({ Body: combinedBody, @@ -144,9 +159,6 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { OriginatingTo: signalTo, }); - const storePath = resolveStorePath(deps.cfg.session?.store, { - agentId: route.agentId, - }); void recordSessionMetaFromInbound({ storePath, sessionKey: ctxPayload.SessionKey ?? route.sessionKey, diff --git a/src/slack/monitor/message-handler/prepare.ts b/src/slack/monitor/message-handler/prepare.ts index a1fe35675..27872151e 100644 --- a/src/slack/monitor/message-handler/prepare.ts +++ b/src/slack/monitor/message-handler/prepare.ts @@ -5,6 +5,7 @@ import type { FinalizedMsgContext } from "../../../auto-reply/templating.js"; import { formatInboundEnvelope, formatThreadStarterEnvelope, + resolveEnvelopeFormatOptions, } from "../../../auto-reply/envelope.js"; import { buildPendingHistoryContextFromMap, @@ -21,7 +22,11 @@ import { resolveThreadSessionKeys } from "../../../routing/session-key.js"; import { resolveMentionGatingWithBypass } from "../../../channels/mention-gating.js"; import { resolveConversationLabel } from "../../../channels/conversation-label.js"; import { resolveControlCommandGate } from "../../../channels/command-gating.js"; -import { recordSessionMetaFromInbound, resolveStorePath } from "../../../config/sessions.js"; +import { + readSessionUpdatedAt, + recordSessionMetaFromInbound, + resolveStorePath, +} from "../../../config/sessions.js"; import type { ResolvedSlackAccount } from "../../accounts.js"; import { reactSlackMessage } from "../../actions.js"; @@ -372,6 +377,14 @@ export async function prepareSlackMessage(params: { From: slackFrom, }) ?? (isDirectMessage ? senderName : roomLabel); const textWithId = `${rawBody}\n[slack message id: ${message.ts} channel: ${message.channel}]`; + const storePath = resolveStorePath(ctx.cfg.session?.store, { + agentId: route.agentId, + }); + const envelopeOptions = resolveEnvelopeFormatOptions(ctx.cfg); + const previousTimestamp = readSessionUpdatedAt({ + storePath, + sessionKey: route.sessionKey, + }); const body = formatInboundEnvelope({ channel: "Slack", from: envelopeFrom, @@ -379,6 +392,8 @@ export async function prepareSlackMessage(params: { body: textWithId, chatType: isDirectMessage ? "direct" : "channel", sender: { name: senderName, id: senderId }, + previousTimestamp, + envelope: envelopeOptions, }); let combinedBody = body; @@ -389,17 +404,18 @@ export async function prepareSlackMessage(params: { limit: ctx.historyLimit, currentMessage: combinedBody, formatEntry: (entry) => - formatInboundEnvelope({ - channel: "Slack", - from: roomLabel, - timestamp: entry.timestamp, - body: `${entry.body}${ - entry.messageId ? ` [id:${entry.messageId} channel:${message.channel}]` : "" - }`, - chatType: "channel", - senderLabel: entry.sender, - }), - }); + formatInboundEnvelope({ + channel: "Slack", + from: roomLabel, + timestamp: entry.timestamp, + body: `${entry.body}${ + entry.messageId ? ` [id:${entry.messageId} channel:${message.channel}]` : "" + }`, + chatType: "channel", + senderLabel: entry.sender, + envelope: envelopeOptions, + }), + }); } const slackTo = isDirectMessage ? `user:${message.user}` : `channel:${message.channel}`; @@ -433,6 +449,7 @@ export async function prepareSlackMessage(params: { author: starterName, timestamp: starter.ts ? Math.round(Number(starter.ts) * 1000) : undefined, body: starterWithId, + envelope: envelopeOptions, }); const snippet = starter.text.replace(/\s+/g, " ").slice(0, 80); threadLabel = `Slack thread ${roomLabel}${snippet ? `: ${snippet}` : ""}`; @@ -472,9 +489,6 @@ export async function prepareSlackMessage(params: { OriginatingTo: slackTo, }) satisfies FinalizedMsgContext; - const storePath = resolveStorePath(ctx.cfg.session?.store, { - agentId: route.agentId, - }); void recordSessionMetaFromInbound({ storePath, sessionKey: sessionKey, diff --git a/src/telegram/bot-message-context.ts b/src/telegram/bot-message-context.ts index deecb7385..8c01b0d5f 100644 --- a/src/telegram/bot-message-context.ts +++ b/src/telegram/bot-message-context.ts @@ -3,7 +3,7 @@ import type { Bot } from "grammy"; import { resolveAckReaction } from "../agents/identity.js"; import { hasControlCommand } from "../auto-reply/command-detection.js"; import { normalizeCommandBody } from "../auto-reply/commands-registry.js"; -import { formatInboundEnvelope } from "../auto-reply/envelope.js"; +import { formatInboundEnvelope, resolveEnvelopeFormatOptions } from "../auto-reply/envelope.js"; import { buildPendingHistoryContextFromMap, recordPendingHistoryEntry, @@ -13,6 +13,7 @@ import { finalizeInboundContext } from "../auto-reply/reply/inbound-context.js"; import { buildMentionRegexes, matchesMentionPatterns } from "../auto-reply/reply/mentions.js"; import { formatLocationText, toLocationContext } from "../channels/location.js"; import { + readSessionUpdatedAt, recordSessionMetaFromInbound, resolveStorePath, updateLastRoute, @@ -417,6 +418,14 @@ export const buildTelegramMessageContext = async ({ const conversationLabel = isGroup ? (groupLabel ?? `group:${chatId}`) : buildSenderLabel(msg, senderId || chatId); + const storePath = resolveStorePath(cfg.session?.store, { + agentId: route.agentId, + }); + const envelopeOptions = resolveEnvelopeFormatOptions(cfg); + const previousTimestamp = readSessionUpdatedAt({ + storePath, + sessionKey: route.sessionKey, + }); const body = formatInboundEnvelope({ channel: "Telegram", from: conversationLabel, @@ -428,6 +437,8 @@ export const buildTelegramMessageContext = async ({ username: senderUsername || undefined, id: senderId || undefined, }, + previousTimestamp, + envelope: envelopeOptions, }); let combinedBody = body; if (isGroup && historyKey && historyLimit > 0) { @@ -444,6 +455,7 @@ export const buildTelegramMessageContext = async ({ body: `${entry.body} [id:${entry.messageId ?? "unknown"} chat:${chatId}]`, chatType: "group", senderLabel: entry.sender, + envelope: envelopeOptions, }), }); } @@ -504,9 +516,6 @@ export const buildTelegramMessageContext = async ({ OriginatingTo: `telegram:${chatId}`, }); - const storePath = resolveStorePath(cfg.session?.store, { - agentId: route.agentId, - }); void recordSessionMetaFromInbound({ storePath, sessionKey: ctxPayload.SessionKey ?? route.sessionKey, diff --git a/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts b/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts index 83297495c..7134d7d3b 100644 --- a/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts +++ b/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts @@ -176,7 +176,7 @@ describe("createTelegramBot", () => { expect(payload.WasMentioned).toBe(true); expect(payload.SenderName).toBe("Ada"); expect(payload.SenderId).toBe("9"); - expect(payload.Body).toMatch(/^\[Telegram Test Group id:7 2025-01-09T00:00Z\]/); + expect(payload.Body).toMatch(/^\[Telegram Test Group id:7 (\+\d+[smhd] )?2025-01-09T00:00Z\]/); }); it("keeps group envelope headers stable (sender identity is separate)", async () => { onSpy.mockReset(); @@ -217,7 +217,7 @@ describe("createTelegramBot", () => { expect(payload.SenderName).toBe("Ada Lovelace"); expect(payload.SenderId).toBe("99"); expect(payload.SenderUsername).toBe("ada"); - expect(payload.Body).toMatch(/^\[Telegram Ops id:42 2025-01-09T00:00Z\]/); + expect(payload.Body).toMatch(/^\[Telegram Ops id:42 (\+\d+[smhd] )?2025-01-09T00:00Z\]/); }); it("reacts to mention-gated group messages when ackReaction is enabled", async () => { onSpy.mockReset(); diff --git a/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts b/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts index 3a143a4f1..fc34477c1 100644 --- a/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts +++ b/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts @@ -330,7 +330,7 @@ describe("createTelegramBot", () => { expect(replySpy).toHaveBeenCalledTimes(1); const payload = replySpy.mock.calls[0][0]; expect(payload.Body).toMatch( - /^\[Telegram Ada Lovelace \(@ada_bot\) id:1234 2025-01-09T00:00Z\]/, + /^\[Telegram Ada Lovelace \(@ada_bot\) id:1234 (\+\d+[smhd] )?2025-01-09T00:00Z\]/, ); expect(payload.Body).toContain("hello world"); } finally { diff --git a/src/telegram/bot.test.ts b/src/telegram/bot.test.ts index c0d81a8f8..d7b37daa3 100644 --- a/src/telegram/bot.test.ts +++ b/src/telegram/bot.test.ts @@ -452,7 +452,7 @@ describe("createTelegramBot", () => { expect(replySpy).toHaveBeenCalledTimes(1); const payload = replySpy.mock.calls[0][0]; expect(payload.Body).toMatch( - /^\[Telegram Ada Lovelace \(@ada_bot\) id:1234 2025-01-09T00:00Z\]/, + /^\[Telegram Ada Lovelace \(@ada_bot\) id:1234 (\+\d+[smhd] )?2025-01-09T00:00Z\]/, ); expect(payload.Body).toContain("hello world"); } finally { @@ -586,7 +586,7 @@ describe("createTelegramBot", () => { const payload = replySpy.mock.calls[0][0]; expectInboundContextContract(payload); expect(payload.WasMentioned).toBe(true); - expect(payload.Body).toMatch(/^\[Telegram Test Group id:7 2025-01-09T00:00Z\]/); + expect(payload.Body).toMatch(/^\[Telegram Test Group id:7 (\+\d+[smhd] )?2025-01-09T00:00Z\]/); expect(payload.SenderName).toBe("Ada"); expect(payload.SenderId).toBe("9"); }); @@ -628,7 +628,7 @@ describe("createTelegramBot", () => { expect(replySpy).toHaveBeenCalledTimes(1); const payload = replySpy.mock.calls[0][0]; expectInboundContextContract(payload); - expect(payload.Body).toMatch(/^\[Telegram Ops id:42 2025-01-09T00:00Z\]/); + expect(payload.Body).toMatch(/^\[Telegram Ops id:42 (\+\d+[smhd] )?2025-01-09T00:00Z\]/); expect(payload.SenderName).toBe("Ada Lovelace"); expect(payload.SenderId).toBe("99"); expect(payload.SenderUsername).toBe("ada"); diff --git a/src/web/auto-reply.web-auto-reply.reconnects-after-connection-close.test.ts b/src/web/auto-reply.web-auto-reply.reconnects-after-connection-close.test.ts index c12d5284d..411875e21 100644 --- a/src/web/auto-reply.web-auto-reply.reconnects-after-connection-close.test.ts +++ b/src/web/auto-reply.web-auto-reply.reconnects-after-connection-close.test.ts @@ -328,9 +328,13 @@ describe("web auto-reply", () => { expect(resolver).toHaveBeenCalledTimes(2); const firstArgs = resolver.mock.calls[0][0]; const secondArgs = resolver.mock.calls[1][0]; - expect(firstArgs.Body).toContain("[WhatsApp +1 2025-01-01T00:00Z] [clawdbot] first"); + expect(firstArgs.Body).toMatch( + /\[WhatsApp \+1 (\+\d+[smhd] )?2025-01-01T00:00Z\] \[clawdbot\] first/, + ); expect(firstArgs.Body).not.toContain("second"); - expect(secondArgs.Body).toContain("[WhatsApp +1 2025-01-01T01:00Z] [clawdbot] second"); + expect(secondArgs.Body).toMatch( + /\[WhatsApp \+1 (\+\d+[smhd] )?2025-01-01T01:00Z\] \[clawdbot\] second/, + ); expect(secondArgs.Body).not.toContain("first"); // Max listeners bumped to avoid warnings in multi-instance test runs diff --git a/src/web/auto-reply/monitor/message-line.ts b/src/web/auto-reply/monitor/message-line.ts index 336dd2abc..ad51574f4 100644 --- a/src/web/auto-reply/monitor/message-line.ts +++ b/src/web/auto-reply/monitor/message-line.ts @@ -1,5 +1,8 @@ import { resolveMessagePrefix } from "../../../agents/identity.js"; -import { formatInboundEnvelope } from "../../../auto-reply/envelope.js"; +import { + formatInboundEnvelope, + type EnvelopeFormatOptions, +} from "../../../auto-reply/envelope.js"; import type { loadConfig } from "../../../config/config.js"; import type { WebInboundMsg } from "../types.js"; @@ -14,8 +17,10 @@ export function buildInboundLine(params: { cfg: ReturnType; msg: WebInboundMsg; agentId: string; + previousTimestamp?: number; + envelope?: EnvelopeFormatOptions; }) { - const { cfg, msg, agentId } = params; + const { cfg, msg, agentId, previousTimestamp, envelope } = params; // WhatsApp inbound prefix: channels.whatsapp.messagePrefix > legacy messages.messagePrefix > identity/defaults const messagePrefix = resolveMessagePrefix(cfg, agentId, { configured: cfg.channels?.whatsapp?.messagePrefix, @@ -37,5 +42,7 @@ export function buildInboundLine(params: { e164: msg.senderE164, id: msg.senderJid, }, + previousTimestamp, + envelope, }); } diff --git a/src/web/auto-reply/monitor/process-message.ts b/src/web/auto-reply/monitor/process-message.ts index 9f90e7591..ea9895853 100644 --- a/src/web/auto-reply/monitor/process-message.ts +++ b/src/web/auto-reply/monitor/process-message.ts @@ -8,7 +8,10 @@ import { type ResponsePrefixContext, } from "../../../auto-reply/reply/response-prefix-template.js"; import { resolveTextChunkLimit } from "../../../auto-reply/chunk.js"; -import { formatInboundEnvelope } from "../../../auto-reply/envelope.js"; +import { + formatInboundEnvelope, + resolveEnvelopeFormatOptions, +} from "../../../auto-reply/envelope.js"; import { buildHistoryContextFromEntries, type HistoryEntry, @@ -20,7 +23,11 @@ import { shouldComputeCommandAuthorized } from "../../../auto-reply/command-dete import { finalizeInboundContext } from "../../../auto-reply/reply/inbound-context.js"; import { toLocationContext } from "../../../channels/location.js"; import type { loadConfig } from "../../../config/config.js"; -import { recordSessionMetaFromInbound, resolveStorePath } from "../../../config/sessions.js"; +import { + readSessionUpdatedAt, + recordSessionMetaFromInbound, + resolveStorePath, +} from "../../../config/sessions.js"; import { logVerbose, shouldLogVerbose } from "../../../globals.js"; import type { getChildLogger } from "../../../logging.js"; import { readChannelAllowFromStore } from "../../../pairing/pairing-store.js"; @@ -121,10 +128,20 @@ export async function processMessage(params: { suppressGroupHistoryClear?: boolean; }) { const conversationId = params.msg.conversationId ?? params.msg.from; + const storePath = resolveStorePath(params.cfg.session?.store, { + agentId: params.route.agentId, + }); + const envelopeOptions = resolveEnvelopeFormatOptions(params.cfg); + const previousTimestamp = readSessionUpdatedAt({ + storePath, + sessionKey: params.route.sessionKey, + }); let combinedBody = buildInboundLine({ cfg: params.cfg, msg: params.msg, agentId: params.route.agentId, + previousTimestamp, + envelope: envelopeOptions, }); let shouldClearGroupHistory = false; @@ -152,6 +169,7 @@ export async function processMessage(params: { body: bodyWithId, chatType: "group", senderLabel: entry.sender, + envelope: envelopeOptions, }); }, }); @@ -288,9 +306,6 @@ export async function processMessage(params: { }); } - const storePath = resolveStorePath(params.cfg.session?.store, { - agentId: params.route.agentId, - }); const metaTask = recordSessionMetaFromInbound({ storePath, sessionKey: params.route.sessionKey, diff --git a/ui/src/ui/chat/message-extract.test.ts b/ui/src/ui/chat/message-extract.test.ts new file mode 100644 index 000000000..2147d915e --- /dev/null +++ b/ui/src/ui/chat/message-extract.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { stripEnvelope } from "./message-extract"; + +describe("stripEnvelope", () => { + it("strips UTC envelope", () => { + const text = "[WebChat agent:main:main 2026-01-18T05:19Z] hello world"; + expect(stripEnvelope(text)).toBe("hello world"); + }); + + it("strips local-time envelope", () => { + const text = "[Telegram Ada Lovelace (@ada) id:1234 2026-01-18 19:29 GMT+1] test"; + expect(stripEnvelope(text)).toBe("test"); + }); + + it("strips envelopes without timestamps for known channels", () => { + const text = "[WhatsApp +1234567890] hi there"; + expect(stripEnvelope(text)).toBe("hi there"); + }); + + it("handles multi-line messages", () => { + const text = "[Slack #general 2026-01-18T05:19Z] first line\nsecond line"; + expect(stripEnvelope(text)).toBe("first line\nsecond line"); + }); + + it("returns text as-is when no envelope present", () => { + const text = "just a regular message"; + expect(stripEnvelope(text)).toBe("just a regular message"); + }); + + it("does not strip non-envelope brackets", () => { + expect(stripEnvelope("[OK] hello")).toBe("[OK] hello"); + expect(stripEnvelope("[1/2] step one")).toBe("[1/2] step one"); + }); +}); diff --git a/ui/src/ui/chat/message-extract.ts b/ui/src/ui/chat/message-extract.ts index d08c3258b..0a9874856 100644 --- a/ui/src/ui/chat/message-extract.ts +++ b/ui/src/ui/chat/message-extract.ts @@ -1,11 +1,42 @@ import { stripThinkingTags } from "../format"; +const ENVELOPE_PREFIX = /^\[([^\]]+)\]\s*/; +const ENVELOPE_CHANNELS = [ + "WebChat", + "WhatsApp", + "Telegram", + "Signal", + "Slack", + "Discord", + "iMessage", + "Teams", + "Matrix", + "Zalo", + "Zalo Personal", + "BlueBubbles", +]; + +function looksLikeEnvelopeHeader(header: string): boolean { + if (/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}Z\b/.test(header)) return true; + if (/\d{4}-\d{2}-\d{2} \d{2}:\d{2}\b/.test(header)) return true; + return ENVELOPE_CHANNELS.some((label) => header.startsWith(`${label} `)); +} + +export function stripEnvelope(text: string): string { + const match = text.match(ENVELOPE_PREFIX); + if (!match) return text; + const header = match[1] ?? ""; + if (!looksLikeEnvelopeHeader(header)) return text; + return text.slice(match[0].length); +} + export function extractText(message: unknown): string | null { const m = message as Record; const role = typeof m.role === "string" ? m.role : ""; const content = m.content; if (typeof content === "string") { - return role === "assistant" ? stripThinkingTags(content) : content; + const processed = role === "assistant" ? stripThinkingTags(content) : stripEnvelope(content); + return processed; } if (Array.isArray(content)) { const parts = content @@ -17,11 +48,13 @@ export function extractText(message: unknown): string | null { .filter((v): v is string => typeof v === "string"); if (parts.length > 0) { const joined = parts.join("\n"); - return role === "assistant" ? stripThinkingTags(joined) : joined; + const processed = role === "assistant" ? stripThinkingTags(joined) : stripEnvelope(joined); + return processed; } } if (typeof m.text === "string") { - return role === "assistant" ? stripThinkingTags(m.text) : m.text; + const processed = role === "assistant" ? stripThinkingTags(m.text) : stripEnvelope(m.text); + return processed; } return null; } @@ -83,4 +116,3 @@ export function formatReasoningMarkdown(text: string): string { .map((line) => `_${line}_`); return lines.length ? ["_Reasoning:_", ...lines].join("\n") : ""; } - diff --git a/ui/src/ui/controllers/chat.ts b/ui/src/ui/controllers/chat.ts index 8ea5ad84d..53027c6ea 100644 --- a/ui/src/ui/controllers/chat.ts +++ b/ui/src/ui/controllers/chat.ts @@ -1,5 +1,5 @@ import type { GatewayBrowserClient } from "../gateway"; -import { stripThinkingTags } from "../format"; +import { extractText } from "../chat/message-extract"; import { generateUUID } from "../uuid"; export type ChatState = { @@ -142,29 +142,3 @@ export function handleChatEvent( } return payload.state; } - -function extractText(message: unknown): string | null { - const m = message as Record; - const role = typeof m.role === "string" ? m.role : ""; - const content = m.content; - if (typeof content === "string") { - return role === "assistant" ? stripThinkingTags(content) : content; - } - if (Array.isArray(content)) { - const parts = content - .map((p) => { - const item = p as Record; - if (item.type === "text" && typeof item.text === "string") return item.text; - return null; - }) - .filter((v): v is string => typeof v === "string"); - if (parts.length > 0) { - const joined = parts.join("\n"); - return role === "assistant" ? stripThinkingTags(joined) : joined; - } - } - if (typeof m.text === "string") { - return role === "assistant" ? stripThinkingTags(m.text) : m.text; - } - return null; -}