feat: make inbound envelopes configurable
Co-authored-by: Shiva Prasad <shiv19@users.noreply.github.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<EnvelopeFormatOptions> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -172,6 +172,9 @@ const FIELD_LABELS: Record<string, string> = {
|
||||
"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<string, string> = {
|
||||
"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).",
|
||||
|
||||
@@ -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<string, SessionEntry>,
|
||||
|
||||
@@ -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.). */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<typeof loadConfig>;
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
34
ui/src/ui/chat/message-extract.test.ts
Normal file
34
ui/src/ui/chat/message-extract.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>;
|
||||
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") : "";
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user