diff --git a/CHANGELOG.md b/CHANGELOG.md index cad0cf0fe..93ea9893f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Docs: https://docs.clawd.bot ### Changes - Dependencies: update core + plugin deps (grammy, vitest, openai, Microsoft agents hosting, etc.). +- 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/README.md b/README.md index 6c28bc24f..446c366ef 100644 --- a/README.md +++ b/README.md @@ -474,25 +474,25 @@ Core contributors: Thanks to all clawtributors:
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
`,
);
}
- const channel = parseChannel(channelRaw);
+ const channel = parseChannel(channelRaw, channels);
const approved = await approveChannelPairingCode({
channel,
code: String(resolvedCode),
diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts
index f9443d4e6..1b0683a3f 100644
--- a/src/cli/plugins-cli.ts
+++ b/src/cli/plugins-cli.ts
@@ -13,6 +13,7 @@ import {
resolvePluginInstallDir,
} from "../plugins/install.js";
import { recordPluginInstall } from "../plugins/installs.js";
+import { applyExclusiveSlotSelection } from "../plugins/slots.js";
import type { PluginRecord } from "../plugins/registry.js";
import { buildPluginStatusReport } from "../plugins/status.js";
import { defaultRuntime } from "../runtime.js";
@@ -79,6 +80,31 @@ async function readInstalledPackageVersion(dir: string): Promise entry.id === pluginId);
+ if (!plugin) {
+ return { config, warnings: [] };
+ }
+ const result = applyExclusiveSlotSelection({
+ config,
+ selectedId: plugin.id,
+ selectedKind: plugin.kind,
+ registry: report,
+ });
+ return { config: result.config, warnings: result.warnings };
+}
+
+function logSlotWarnings(warnings: string[]) {
+ if (warnings.length === 0) return;
+ for (const warning of warnings) {
+ defaultRuntime.log(chalk.yellow(warning));
+ }
+}
+
export function registerPluginsCli(program: Command) {
const plugins = program
.command("plugins")
@@ -197,7 +223,7 @@ export function registerPluginsCli(program: Command) {
.argument("", "Plugin id")
.action(async (id: string) => {
const cfg = loadConfig();
- const next = {
+ let next: ClawdbotConfig = {
...cfg,
plugins: {
...cfg.plugins,
@@ -210,7 +236,10 @@ export function registerPluginsCli(program: Command) {
},
},
};
+ const slotResult = applySlotSelectionForPlugin(next, id);
+ next = slotResult.config;
await writeConfigFile(next);
+ logSlotWarnings(slotResult.warnings);
defaultRuntime.log(`Enabled plugin "${id}". Restart the gateway to apply.`);
});
@@ -280,7 +309,10 @@ export function registerPluginsCli(program: Command) {
installPath: resolved,
version: probe.version,
});
+ const slotResult = applySlotSelectionForPlugin(next, probe.pluginId);
+ next = slotResult.config;
await writeConfigFile(next);
+ logSlotWarnings(slotResult.warnings);
defaultRuntime.log(`Linked plugin path: ${resolved}`);
defaultRuntime.log(`Restart the gateway to load plugins.`);
return;
@@ -319,7 +351,10 @@ export function registerPluginsCli(program: Command) {
installPath: result.targetDir,
version: result.version,
});
+ const slotResult = applySlotSelectionForPlugin(next, result.pluginId);
+ next = slotResult.config;
await writeConfigFile(next);
+ logSlotWarnings(slotResult.warnings);
defaultRuntime.log(`Installed plugin: ${result.pluginId}`);
defaultRuntime.log(`Restart the gateway to load plugins.`);
return;
@@ -379,7 +414,10 @@ export function registerPluginsCli(program: Command) {
installPath: result.targetDir,
version: result.version,
});
+ const slotResult = applySlotSelectionForPlugin(next, result.pluginId);
+ next = slotResult.config;
await writeConfigFile(next);
+ logSlotWarnings(slotResult.warnings);
defaultRuntime.log(`Installed plugin: ${result.pluginId}`);
defaultRuntime.log(`Restart the gateway to load plugins.`);
});
diff --git a/src/commands/auth-choice.apply.plugin-provider.ts b/src/commands/auth-choice.apply.plugin-provider.ts
index fd280c894..3e7686665 100644
--- a/src/commands/auth-choice.apply.plugin-provider.ts
+++ b/src/commands/auth-choice.apply.plugin-provider.ts
@@ -176,10 +176,7 @@ export async function applyAuthChoicePluginProvider(
if (result.defaultModel) {
if (params.setDefaultModel) {
nextConfig = applyDefaultModel(nextConfig, result.defaultModel);
- await params.prompter.note(
- `Default model set to ${result.defaultModel}`,
- "Model configured",
- );
+ await params.prompter.note(`Default model set to ${result.defaultModel}`, "Model configured");
} else if (params.agentId) {
agentModelOverride = result.defaultModel;
await params.prompter.note(
diff --git a/src/commands/channels.adds-non-default-telegram-account.test.ts b/src/commands/channels.adds-non-default-telegram-account.test.ts
index 7a8c2ed29..d03be6a51 100644
--- a/src/commands/channels.adds-non-default-telegram-account.test.ts
+++ b/src/commands/channels.adds-non-default-telegram-account.test.ts
@@ -417,6 +417,22 @@ describe("channels command", () => {
expect(lines.join("\n")).toMatch(/Telegram Bot API privacy mode/i);
});
+ it("includes Telegram bot username from probe data", () => {
+ const lines = formatGatewayChannelsStatusLines({
+ channelAccounts: {
+ telegram: [
+ {
+ accountId: "default",
+ enabled: true,
+ configured: true,
+ probe: { ok: true, bot: { username: "clawdbot_bot" } },
+ },
+ ],
+ },
+ });
+ expect(lines.join("\n")).toMatch(/bot:@clawdbot_bot/);
+ });
+
it("surfaces Telegram group membership audit issues in channels status output", () => {
const lines = formatGatewayChannelsStatusLines({
channelAccounts: {
diff --git a/src/commands/channels/status.ts b/src/commands/channels/status.ts
index dad94853d..521bf4c59 100644
--- a/src/commands/channels/status.ts
+++ b/src/commands/channels/status.ts
@@ -51,6 +51,18 @@ export function formatGatewayChannelsStatusLines(payload: Record 0) {
bits.push(`mode:${account.mode}`);
}
+ const botUsername = (() => {
+ const bot = account.bot as { username?: string | null } | undefined;
+ const probeBot = (account.probe as { bot?: { username?: string | null } } | undefined)?.bot;
+ const raw = bot?.username ?? probeBot?.username ?? "";
+ if (typeof raw !== "string") return "";
+ const trimmed = raw.trim();
+ if (!trimmed) return "";
+ return trimmed.startsWith("@") ? trimmed : `@${trimmed}`;
+ })();
+ if (botUsername) {
+ bits.push(`bot:${botUsername}`);
+ }
if (typeof account.dmPolicy === "string" && account.dmPolicy.length > 0) {
bits.push(`dm:${account.dmPolicy}`);
}
diff --git a/src/commands/doctor-gateway-daemon-flow.ts b/src/commands/doctor-gateway-daemon-flow.ts
index 87553ca27..57196c0c6 100644
--- a/src/commands/doctor-gateway-daemon-flow.ts
+++ b/src/commands/doctor-gateway-daemon-flow.ts
@@ -1,6 +1,9 @@
import type { ClawdbotConfig } from "../config/config.js";
import { resolveGatewayPort } from "../config/config.js";
-import { resolveGatewayLaunchAgentLabel, resolveNodeLaunchAgentLabel } from "../daemon/constants.js";
+import {
+ resolveGatewayLaunchAgentLabel,
+ resolveNodeLaunchAgentLabel,
+} from "../daemon/constants.js";
import { readLastGatewayErrorLine } from "../daemon/diagnostics.js";
import {
isLaunchAgentListed,
@@ -44,10 +47,7 @@ async function maybeRepairLaunchAgentBootstrap(params: {
const plistExists = await launchAgentPlistExists(params.env);
if (!plistExists) return false;
- note(
- "LaunchAgent is listed but not loaded in launchd.",
- `${params.title} LaunchAgent`,
- );
+ note("LaunchAgent is listed but not loaded in launchd.", `${params.title} LaunchAgent`);
const shouldFix = await params.prompter.confirmSkipInNonInteractive({
message: `Repair ${params.title} LaunchAgent bootstrap now?`,
diff --git a/src/commands/onboard-non-interactive.remote.test.ts b/src/commands/onboard-non-interactive.remote.test.ts
index fd2d003e9..bd4d24e57 100644
--- a/src/commands/onboard-non-interactive.remote.test.ts
+++ b/src/commands/onboard-non-interactive.remote.test.ts
@@ -3,7 +3,7 @@ import { createServer } from "node:net";
import os from "node:os";
import path from "node:path";
-import { describe, expect, it, vi } from "vitest";
+import { describe, expect, it } from "vitest";
async function getFreePort(): Promise {
return await new Promise((resolve, reject) => {
@@ -50,7 +50,6 @@ describe("onboard (non-interactive): remote gateway config", () => {
process.env.HOME = tempHome;
delete process.env.CLAWDBOT_STATE_DIR;
delete process.env.CLAWDBOT_CONFIG_PATH;
- vi.resetModules();
const port = await getFreePort();
const token = "tok_remote_123";
@@ -85,8 +84,8 @@ describe("onboard (non-interactive): remote gateway config", () => {
runtime,
);
- const { CONFIG_PATH_CLAWDBOT } = await import("../config/config.js");
- const cfg = JSON.parse(await fs.readFile(CONFIG_PATH_CLAWDBOT, "utf8")) as {
+ const { resolveConfigPath } = await import("../config/config.js");
+ const cfg = JSON.parse(await fs.readFile(resolveConfigPath(), "utf8")) as {
gateway?: { mode?: string; remote?: { url?: string; token?: string } };
};
diff --git a/src/config/plugin-auto-enable.test.ts b/src/config/plugin-auto-enable.test.ts
index 4b13f8bad..62ca47f47 100644
--- a/src/config/plugin-auto-enable.test.ts
+++ b/src/config/plugin-auto-enable.test.ts
@@ -8,6 +8,7 @@ describe("applyPluginAutoEnable", () => {
channels: { slack: { botToken: "x" } },
plugins: { allow: ["telegram"] },
},
+ env: {},
});
expect(result.config.plugins?.entries?.slack?.enabled).toBe(true);
@@ -21,6 +22,7 @@ describe("applyPluginAutoEnable", () => {
channels: { slack: { botToken: "x" } },
plugins: { entries: { slack: { enabled: false } } },
},
+ env: {},
});
expect(result.config.plugins?.entries?.slack?.enabled).toBe(false);
@@ -39,6 +41,7 @@ describe("applyPluginAutoEnable", () => {
},
},
},
+ env: {},
});
expect(result.config.plugins?.entries?.["google-antigravity-auth"]?.enabled).toBe(true);
@@ -50,6 +53,7 @@ describe("applyPluginAutoEnable", () => {
channels: { slack: { botToken: "x" } },
plugins: { enabled: false },
},
+ env: {},
});
expect(result.config.plugins?.entries?.slack?.enabled).toBeUndefined();
diff --git a/src/config/plugin-auto-enable.ts b/src/config/plugin-auto-enable.ts
index eadebf0fd..41c4eec12 100644
--- a/src/config/plugin-auto-enable.ts
+++ b/src/config/plugin-auto-enable.ts
@@ -45,10 +45,7 @@ function recordHasKeys(value: unknown): boolean {
return isRecord(value) && Object.keys(value).length > 0;
}
-function accountsHaveKeys(
- value: unknown,
- keys: string[],
-): boolean {
+function accountsHaveKeys(value: unknown, keys: string[]): boolean {
if (!isRecord(value)) return false;
for (const account of Object.values(value)) {
if (!isRecord(account)) continue;
@@ -59,7 +56,10 @@ function accountsHaveKeys(
return false;
}
-function resolveChannelConfig(cfg: ClawdbotConfig, channelId: string): Record | null {
+function resolveChannelConfig(
+ cfg: ClawdbotConfig,
+ channelId: string,
+): Record | null {
const channels = cfg.channels as Record | undefined;
const entry = channels?.[channelId];
return isRecord(entry) ? entry : null;
@@ -234,7 +234,10 @@ function isProviderConfigured(cfg: ClawdbotConfig, providerId: string): boolean
return false;
}
-function resolveConfiguredPlugins(cfg: ClawdbotConfig, env: NodeJS.ProcessEnv): PluginEnableChange[] {
+function resolveConfiguredPlugins(
+ cfg: ClawdbotConfig,
+ env: NodeJS.ProcessEnv,
+): PluginEnableChange[] {
const changes: PluginEnableChange[] = [];
for (const channelId of CHANNEL_PLUGIN_IDS) {
if (isChannelConfigured(cfg, channelId, env)) {
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/version.ts b/src/config/version.ts
index c17a2bfa7..828e9ab9b 100644
--- a/src/config/version.ts
+++ b/src/config/version.ts
@@ -20,7 +20,10 @@ export function parseClawdbotVersion(raw: string | null | undefined): ClawdbotVe
};
}
-export function compareClawdbotVersions(a: string | null | undefined, b: string | null | undefined): number | null {
+export function compareClawdbotVersions(
+ a: string | null | undefined,
+ b: string | null | undefined,
+): number | null {
const parsedA = parseClawdbotVersion(a);
const parsedB = parseClawdbotVersion(b);
if (!parsedA || !parsedB) return null;
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/daemon/launchd.test.ts b/src/daemon/launchd.test.ts
index c02717435..72c4041f5 100644
--- a/src/daemon/launchd.test.ts
+++ b/src/daemon/launchd.test.ts
@@ -40,7 +40,7 @@ async function withLaunchctlStub(
' fs.appendFileSync(logPath, JSON.stringify(args) + "\\n", "utf8");',
"}",
'if (args[0] === "list") {',
- " const output = process.env.CLAWDBOT_TEST_LAUNCHCTL_LIST_OUTPUT || \"\";",
+ ' const output = process.env.CLAWDBOT_TEST_LAUNCHCTL_LIST_OUTPUT || "";',
" process.stdout.write(output);",
"}",
"process.exit(0);",
@@ -107,13 +107,10 @@ describe("launchd runtime parsing", () => {
describe("launchctl list detection", () => {
it("detects the resolved label in launchctl list", async () => {
- await withLaunchctlStub(
- { listOutput: "123 0 com.clawdbot.gateway\n" },
- async ({ env }) => {
- const listed = await isLaunchAgentListed({ env });
- expect(listed).toBe(true);
- },
- );
+ await withLaunchctlStub({ listOutput: "123 0 com.clawdbot.gateway\n" }, async ({ env }) => {
+ const listed = await isLaunchAgentListed({ env });
+ expect(listed).toBe(true);
+ });
});
it("returns false when the label is missing", async () => {
diff --git a/src/daemon/launchd.ts b/src/daemon/launchd.ts
index 28895b6b0..529cfdc1a 100644
--- a/src/daemon/launchd.ts
+++ b/src/daemon/launchd.ts
@@ -176,9 +176,7 @@ export async function isLaunchAgentListed(args: {
const label = resolveLaunchAgentLabel({ env: args.env });
const res = await execLaunchctl(["list"]);
if (res.code !== 0) return false;
- return res.stdout
- .split(/\r?\n/)
- .some((line) => line.trim().split(/\s+/).at(-1) === label);
+ return res.stdout.split(/\r?\n/).some((line) => line.trim().split(/\s+/).at(-1) === label);
}
export async function launchAgentPlistExists(
diff --git a/src/discord/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.test.ts b/src/discord/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.test.ts
index 0d495abac..0910b37f5 100644
--- a/src/discord/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.test.ts
+++ b/src/discord/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.test.ts
@@ -3,6 +3,8 @@ import { ChannelType, MessageType } from "@buape/carbon";
import { Routes } from "discord-api-types/v10";
import { beforeEach, describe, expect, it, vi } from "vitest";
+import { __resetDiscordChannelInfoCacheForTest } from "./monitor/message-utils.js";
+
const sendMock = vi.fn();
const reactMock = vi.fn();
const updateLastRouteMock = vi.fn();
@@ -42,7 +44,7 @@ beforeEach(() => {
});
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
upsertPairingRequestMock.mockReset().mockResolvedValue({ code: "PAIRCODE", created: true });
- vi.resetModules();
+ __resetDiscordChannelInfoCacheForTest();
});
describe("discord tool result dispatch", () => {
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/message-utils.ts b/src/discord/monitor/message-utils.ts
index ff5c96ea4..a681afa16 100644
--- a/src/discord/monitor/message-utils.ts
+++ b/src/discord/monitor/message-utils.ts
@@ -44,6 +44,10 @@ const DISCORD_CHANNEL_INFO_CACHE = new Map<
{ value: DiscordChannelInfo | null; expiresAt: number }
>();
+export function __resetDiscordChannelInfoCacheForTest() {
+ DISCORD_CHANNEL_INFO_CACHE.clear();
+}
+
export async function resolveDiscordChannelInfo(
client: Client,
channelId: string,
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/boot.test.ts b/src/gateway/boot.test.ts
index b4a9073a0..1b9484850 100644
--- a/src/gateway/boot.test.ts
+++ b/src/gateway/boot.test.ts
@@ -27,9 +27,10 @@ describe("runBootOnce", () => {
it("skips when BOOT.md is missing", async () => {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-boot-"));
- await expect(
- runBootOnce({ cfg: {}, deps: makeDeps(), workspaceDir }),
- ).resolves.toEqual({ status: "skipped", reason: "missing" });
+ await expect(runBootOnce({ cfg: {}, deps: makeDeps(), workspaceDir })).resolves.toEqual({
+ status: "skipped",
+ reason: "missing",
+ });
expect(agentCommand).not.toHaveBeenCalled();
await fs.rm(workspaceDir, { recursive: true, force: true });
});
@@ -37,9 +38,10 @@ describe("runBootOnce", () => {
it("skips when BOOT.md is empty", async () => {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-boot-"));
await fs.writeFile(path.join(workspaceDir, "BOOT.md"), " \n", "utf-8");
- await expect(
- runBootOnce({ cfg: {}, deps: makeDeps(), workspaceDir }),
- ).resolves.toEqual({ status: "skipped", reason: "empty" });
+ await expect(runBootOnce({ cfg: {}, deps: makeDeps(), workspaceDir })).resolves.toEqual({
+ status: "skipped",
+ reason: "empty",
+ });
expect(agentCommand).not.toHaveBeenCalled();
await fs.rm(workspaceDir, { recursive: true, force: true });
});
@@ -50,9 +52,9 @@ describe("runBootOnce", () => {
await fs.writeFile(path.join(workspaceDir, "BOOT.md"), content, "utf-8");
agentCommand.mockResolvedValue(undefined);
- await expect(
- runBootOnce({ cfg: {}, deps: makeDeps(), workspaceDir }),
- ).resolves.toEqual({ status: "ran" });
+ await expect(runBootOnce({ cfg: {}, deps: makeDeps(), workspaceDir })).resolves.toEqual({
+ status: "ran",
+ });
expect(agentCommand).toHaveBeenCalledTimes(1);
const call = agentCommand.mock.calls[0]?.[0];
diff --git a/src/gateway/gateway.wizard.e2e.test.ts b/src/gateway/gateway.wizard.e2e.test.ts
index 7fcb7e757..385ffcc57 100644
--- a/src/gateway/gateway.wizard.e2e.test.ts
+++ b/src/gateway/gateway.wizard.e2e.test.ts
@@ -3,7 +3,7 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
-import { describe, expect, it, vi } from "vitest";
+import { describe, expect, it } from "vitest";
import { WebSocket } from "ws";
import { rawDataToString } from "../infra/ws.js";
@@ -141,7 +141,6 @@ describe("gateway wizard (e2e)", () => {
process.env.HOME = tempHome;
delete process.env.CLAWDBOT_STATE_DIR;
delete process.env.CLAWDBOT_CONFIG_PATH;
- vi.resetModules();
const wizardToken = `wiz-${randomUUID()}`;
const port = await getFreeGatewayPort();
@@ -187,8 +186,8 @@ describe("gateway wizard (e2e)", () => {
expect(didSendToken).toBe(true);
expect(next.status).toBe("done");
- const { CONFIG_PATH_CLAWDBOT } = await import("../config/config.js");
- const parsed = JSON.parse(await fs.readFile(CONFIG_PATH_CLAWDBOT, "utf8"));
+ const { resolveConfigPath } = await import("../config/config.js");
+ const parsed = JSON.parse(await fs.readFile(resolveConfigPath(), "utf8"));
const token = (parsed as Record)?.gateway as
| Record
| undefined;
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/gateway/server.agent.gateway-server-agent-a.test.ts b/src/gateway/server.agent.gateway-server-agent-a.test.ts
index 71fe1ee18..cea69eade 100644
--- a/src/gateway/server.agent.gateway-server-agent-a.test.ts
+++ b/src/gateway/server.agent.gateway-server-agent-a.test.ts
@@ -2,6 +2,8 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, test, vi } from "vitest";
+import type { ChannelPlugin } from "../channels/plugins/types.js";
+import type { PluginRegistry } from "../plugins/registry.js";
import {
agentCommand,
connectOk,
@@ -14,6 +16,33 @@ import {
installGatewayTestHooks();
+const registryState = vi.hoisted(() => ({
+ registry: {
+ plugins: [],
+ tools: [],
+ channels: [],
+ providers: [],
+ gatewayHandlers: {},
+ httpHandlers: [],
+ cliRegistrars: [],
+ services: [],
+ diagnostics: [],
+ } as PluginRegistry,
+}));
+
+vi.mock("./server-plugins.js", async () => {
+ const { setActivePluginRegistry } = await import("../plugins/runtime.js");
+ return {
+ loadGatewayPlugins: (params: { baseMethods: string[] }) => {
+ setActivePluginRegistry(registryState.registry);
+ return {
+ pluginRegistry: registryState.registry,
+ gatewayMethods: params.baseMethods ?? [],
+ };
+ },
+ };
+});
+
const BASE_IMAGE_PNG =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+X3mIAAAAASUVORK5CYII=";
@@ -22,8 +51,96 @@ function expectChannels(call: Record, channel: string) {
expect(call.messageChannel).toBe(channel);
}
+const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry => ({
+ plugins: [],
+ tools: [],
+ channels,
+ providers: [],
+ gatewayHandlers: {},
+ httpHandlers: [],
+ cliRegistrars: [],
+ services: [],
+ diagnostics: [],
+});
+
+const createStubChannelPlugin = (params: {
+ id: ChannelPlugin["id"];
+ label: string;
+ resolveAllowFrom?: (cfg: Record) => string[];
+}): ChannelPlugin => ({
+ id: params.id,
+ meta: {
+ id: params.id,
+ label: params.label,
+ selectionLabel: params.label,
+ docsPath: `/channels/${params.id}`,
+ blurb: "test stub.",
+ },
+ capabilities: { chatTypes: ["direct"] },
+ config: {
+ listAccountIds: () => ["default"],
+ resolveAccount: () => ({}),
+ resolveAllowFrom: params.resolveAllowFrom
+ ? ({ cfg }) => params.resolveAllowFrom?.(cfg as Record) ?? []
+ : undefined,
+ },
+ outbound: {
+ deliveryMode: "direct",
+ resolveTarget: ({ to, allowFrom }) => {
+ const trimmed = to?.trim() ?? "";
+ if (trimmed) return { ok: true, to: trimmed };
+ const first = allowFrom?.[0];
+ if (first) return { ok: true, to: String(first) };
+ return {
+ ok: false,
+ error: new Error(`missing target for ${params.id}`),
+ };
+ },
+ sendText: async () => ({ channel: params.id, messageId: "msg-test" }),
+ sendMedia: async () => ({ channel: params.id, messageId: "msg-test" }),
+ },
+});
+
+const defaultRegistry = createRegistry([
+ {
+ pluginId: "whatsapp",
+ source: "test",
+ plugin: createStubChannelPlugin({
+ id: "whatsapp",
+ label: "WhatsApp",
+ resolveAllowFrom: (cfg) => {
+ const channels = cfg.channels as Record | undefined;
+ const entry = channels?.whatsapp as Record | undefined;
+ const allow = entry?.allowFrom;
+ return Array.isArray(allow) ? allow.map((value) => String(value)) : [];
+ },
+ }),
+ },
+ {
+ pluginId: "telegram",
+ source: "test",
+ plugin: createStubChannelPlugin({ id: "telegram", label: "Telegram" }),
+ },
+ {
+ pluginId: "discord",
+ source: "test",
+ plugin: createStubChannelPlugin({ id: "discord", label: "Discord" }),
+ },
+ {
+ pluginId: "slack",
+ source: "test",
+ plugin: createStubChannelPlugin({ id: "slack", label: "Slack" }),
+ },
+ {
+ pluginId: "signal",
+ source: "test",
+ plugin: createStubChannelPlugin({ id: "signal", label: "Signal" }),
+ },
+]);
+
describe("gateway server agent", () => {
test("agent marks implicit delivery when lastTo is stale", async () => {
+ registryState.registry = defaultRegistry;
testState.allowFrom = ["+436769770569"];
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
@@ -63,6 +180,7 @@ describe("gateway server agent", () => {
});
test("agent forwards sessionKey to agentCommand", async () => {
+ registryState.registry = defaultRegistry;
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
@@ -97,6 +215,7 @@ describe("gateway server agent", () => {
});
test("agent forwards accountId to agentCommand", async () => {
+ registryState.registry = defaultRegistry;
testState.allowFrom = ["+1555"];
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
@@ -136,6 +255,7 @@ describe("gateway server agent", () => {
});
test("agent avoids lastAccountId when explicit to is provided", async () => {
+ registryState.registry = defaultRegistry;
testState.allowFrom = ["+1555"];
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
@@ -175,6 +295,7 @@ describe("gateway server agent", () => {
});
test("agent keeps explicit accountId when explicit to is provided", async () => {
+ registryState.registry = defaultRegistry;
testState.allowFrom = ["+1555"];
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
@@ -215,6 +336,7 @@ describe("gateway server agent", () => {
});
test("agent falls back to lastAccountId for implicit delivery", async () => {
+ registryState.registry = defaultRegistry;
testState.allowFrom = ["+1555"];
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
@@ -253,6 +375,7 @@ describe("gateway server agent", () => {
});
test("agent forwards image attachments as images[]", async () => {
+ registryState.registry = defaultRegistry;
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
@@ -299,6 +422,7 @@ describe("gateway server agent", () => {
});
test("agent falls back to whatsapp when delivery requested and no last channel exists", async () => {
+ registryState.registry = defaultRegistry;
testState.allowFrom = ["+1555"];
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
@@ -335,6 +459,7 @@ describe("gateway server agent", () => {
});
test("agent routes main last-channel whatsapp", async () => {
+ registryState.registry = defaultRegistry;
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
@@ -374,6 +499,7 @@ describe("gateway server agent", () => {
});
test("agent routes main last-channel telegram", async () => {
+ registryState.registry = defaultRegistry;
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
@@ -412,6 +538,7 @@ describe("gateway server agent", () => {
});
test("agent routes main last-channel discord", async () => {
+ registryState.registry = defaultRegistry;
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
@@ -450,6 +577,7 @@ describe("gateway server agent", () => {
});
test("agent routes main last-channel slack", async () => {
+ registryState.registry = defaultRegistry;
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
@@ -488,6 +616,7 @@ describe("gateway server agent", () => {
});
test("agent routes main last-channel signal", async () => {
+ registryState.registry = defaultRegistry;
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
diff --git a/src/gateway/server.channels.test.ts b/src/gateway/server.channels.test.ts
index ee49ea9b1..19410d8e3 100644
--- a/src/gateway/server.channels.test.ts
+++ b/src/gateway/server.channels.test.ts
@@ -1,4 +1,6 @@
import { afterEach, describe, expect, test, vi } from "vitest";
+import type { ChannelPlugin } from "../channels/plugins/types.js";
+import type { PluginRegistry } from "../plugins/registry.js";
import {
connectOk,
installGatewayTestHooks,
@@ -10,6 +12,125 @@ const loadConfigHelpers = async () => await import("../config/config.js");
installGatewayTestHooks();
+const registryState = vi.hoisted(() => ({
+ registry: {
+ plugins: [],
+ tools: [],
+ channels: [],
+ providers: [],
+ gatewayHandlers: {},
+ httpHandlers: [],
+ cliRegistrars: [],
+ services: [],
+ diagnostics: [],
+ } as PluginRegistry,
+}));
+
+vi.mock("./server-plugins.js", async () => {
+ const { setActivePluginRegistry } = await import("../plugins/runtime.js");
+ return {
+ loadGatewayPlugins: (params: { baseMethods: string[] }) => {
+ setActivePluginRegistry(registryState.registry);
+ return {
+ pluginRegistry: registryState.registry,
+ gatewayMethods: params.baseMethods ?? [],
+ };
+ },
+ };
+});
+
+const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry => ({
+ plugins: [],
+ tools: [],
+ channels,
+ providers: [],
+ gatewayHandlers: {},
+ httpHandlers: [],
+ cliRegistrars: [],
+ services: [],
+ diagnostics: [],
+});
+
+const createStubChannelPlugin = (params: {
+ id: ChannelPlugin["id"];
+ label: string;
+ summary?: Record;
+ logoutCleared?: boolean;
+}): ChannelPlugin => ({
+ id: params.id,
+ meta: {
+ id: params.id,
+ label: params.label,
+ selectionLabel: params.label,
+ docsPath: `/channels/${params.id}`,
+ blurb: "test stub.",
+ },
+ capabilities: { chatTypes: ["direct"] },
+ config: {
+ listAccountIds: () => ["default"],
+ resolveAccount: () => ({}),
+ isConfigured: async () => false,
+ },
+ status: {
+ buildChannelSummary: async () => ({
+ configured: false,
+ ...params.summary,
+ }),
+ },
+ gateway: {
+ logoutAccount: async () => ({
+ cleared: params.logoutCleared ?? false,
+ envToken: false,
+ }),
+ },
+});
+
+const telegramPlugin: ChannelPlugin = {
+ ...createStubChannelPlugin({
+ id: "telegram",
+ label: "Telegram",
+ summary: { tokenSource: "none", lastProbeAt: null },
+ logoutCleared: true,
+ }),
+ gateway: {
+ logoutAccount: async ({ cfg }) => {
+ const { writeConfigFile } = await import("../config/config.js");
+ const nextTelegram = cfg.channels?.telegram ? { ...cfg.channels.telegram } : {};
+ delete nextTelegram.botToken;
+ await writeConfigFile({
+ ...cfg,
+ channels: {
+ ...cfg.channels,
+ telegram: nextTelegram,
+ },
+ });
+ return { cleared: true, envToken: false, loggedOut: true };
+ },
+ },
+};
+
+const defaultRegistry = createRegistry([
+ {
+ pluginId: "whatsapp",
+ source: "test",
+ plugin: createStubChannelPlugin({ id: "whatsapp", label: "WhatsApp" }),
+ },
+ {
+ pluginId: "telegram",
+ source: "test",
+ plugin: telegramPlugin,
+ },
+ {
+ pluginId: "signal",
+ source: "test",
+ plugin: createStubChannelPlugin({
+ id: "signal",
+ label: "Signal",
+ summary: { lastProbeAt: null },
+ }),
+ },
+]);
+
const servers: Array>> = [];
afterEach(async () => {
@@ -28,6 +149,7 @@ afterEach(async () => {
describe("gateway server channels", () => {
test("channels.status returns snapshot without probe", async () => {
vi.stubEnv("TELEGRAM_BOT_TOKEN", undefined);
+ registryState.registry = defaultRegistry;
const result = await startServerWithClient();
servers.push(result);
const { ws } = result;
@@ -59,6 +181,7 @@ describe("gateway server channels", () => {
});
test("channels.logout reports no session when missing", async () => {
+ registryState.registry = defaultRegistry;
const result = await startServerWithClient();
servers.push(result);
const { ws } = result;
@@ -74,6 +197,7 @@ describe("gateway server channels", () => {
test("channels.logout clears telegram bot token from config", async () => {
vi.stubEnv("TELEGRAM_BOT_TOKEN", undefined);
+ registryState.registry = defaultRegistry;
const { readConfigFileSnapshot, writeConfigFile } = await loadConfigHelpers();
await writeConfigFile({
channels: {
diff --git a/src/gateway/server.misc.test.ts b/src/gateway/server.misc.test.ts
index ede279d9f..0e705f8b5 100644
--- a/src/gateway/server.misc.test.ts
+++ b/src/gateway/server.misc.test.ts
@@ -1,7 +1,12 @@
import { createServer } from "node:net";
import { describe, expect, test } from "vitest";
import { resolveCanvasHostUrl } from "../infra/canvas-host-url.js";
+import { getChannelPlugin } from "../channels/plugins/index.js";
import { GatewayLockError } from "../infra/gateway-lock.js";
+import type { ChannelOutboundAdapter } from "../channels/plugins/types.js";
+import type { PluginRegistry } from "../plugins/registry.js";
+import { getActivePluginRegistry, setActivePluginRegistry } from "../plugins/runtime.js";
+import { createOutboundTestPlugin } from "../test-utils/channel-plugins.js";
import {
connectOk,
getFreePort,
@@ -16,6 +21,49 @@ import {
installGatewayTestHooks();
+const whatsappOutbound: ChannelOutboundAdapter = {
+ deliveryMode: "direct",
+ sendText: async ({ deps, to, text }) => {
+ if (!deps?.sendWhatsApp) {
+ throw new Error("Missing sendWhatsApp dep");
+ }
+ return { channel: "whatsapp", ...(await deps.sendWhatsApp(to, text, {})) };
+ },
+ sendMedia: async ({ deps, to, text, mediaUrl }) => {
+ if (!deps?.sendWhatsApp) {
+ throw new Error("Missing sendWhatsApp dep");
+ }
+ return { channel: "whatsapp", ...(await deps.sendWhatsApp(to, text, { mediaUrl })) };
+ },
+};
+
+const whatsappPlugin = createOutboundTestPlugin({
+ id: "whatsapp",
+ outbound: whatsappOutbound,
+ label: "WhatsApp",
+});
+
+const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry => ({
+ plugins: [],
+ tools: [],
+ channels,
+ providers: [],
+ gatewayHandlers: {},
+ httpHandlers: [],
+ cliRegistrars: [],
+ services: [],
+ diagnostics: [],
+});
+
+const whatsappRegistry = createRegistry([
+ {
+ pluginId: "whatsapp",
+ source: "test",
+ plugin: whatsappPlugin,
+ },
+]);
+const emptyRegistry = createRegistry([]);
+
describe("gateway server misc", () => {
test("hello-ok advertises the gateway port for canvas host", async () => {
const prevToken = process.env.CLAWDBOT_GATEWAY_TOKEN;
@@ -47,31 +95,38 @@ describe("gateway server misc", () => {
});
test("send dedupes by idempotencyKey", { timeout: 60_000 }, async () => {
- const { server, ws } = await startServerWithClient();
- await connectOk(ws);
+ const prevRegistry = getActivePluginRegistry() ?? emptyRegistry;
+ try {
+ const { server, ws } = await startServerWithClient();
+ await connectOk(ws);
+ setActivePluginRegistry(whatsappRegistry);
+ expect(getChannelPlugin("whatsapp")).toBeDefined();
- const idem = "same-key";
- const res1P = onceMessage(ws, (o) => o.type === "res" && o.id === "a1");
- const res2P = onceMessage(ws, (o) => o.type === "res" && o.id === "a2");
- const sendReq = (id: string) =>
- ws.send(
- JSON.stringify({
- type: "req",
- id,
- method: "send",
- params: { to: "+15550000000", message: "hi", idempotencyKey: idem },
- }),
- );
- sendReq("a1");
- sendReq("a2");
+ const idem = "same-key";
+ const res1P = onceMessage(ws, (o) => o.type === "res" && o.id === "a1");
+ const res2P = onceMessage(ws, (o) => o.type === "res" && o.id === "a2");
+ const sendReq = (id: string) =>
+ ws.send(
+ JSON.stringify({
+ type: "req",
+ id,
+ method: "send",
+ params: { to: "+15550000000", message: "hi", idempotencyKey: idem },
+ }),
+ );
+ sendReq("a1");
+ sendReq("a2");
- const res1 = await res1P;
- const res2 = await res2P;
- expect(res1.ok).toBe(true);
- expect(res2.ok).toBe(true);
- expect(res1.payload).toEqual(res2.payload);
- ws.close();
- await server.close();
+ const res1 = await res1P;
+ const res2 = await res2P;
+ expect(res1.ok).toBe(true);
+ expect(res2.ok).toBe(true);
+ expect(res1.payload).toEqual(res2.payload);
+ ws.close();
+ await server.close();
+ } finally {
+ setActivePluginRegistry(prevRegistry);
+ }
});
test("refuses to start when port already bound", async () => {
diff --git a/src/gateway/test-helpers.mocks.ts b/src/gateway/test-helpers.mocks.ts
index 051b98305..36c25897b 100644
--- a/src/gateway/test-helpers.mocks.ts
+++ b/src/gateway/test-helpers.mocks.ts
@@ -3,6 +3,7 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { vi } from "vitest";
+import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
export type BridgeClientInfo = {
nodeId: string;
@@ -91,6 +92,7 @@ export const testState = {
agentConfig: undefined as Record | undefined,
agentsConfig: undefined as Record | undefined,
bindingsConfig: undefined as Array> | undefined,
+ channelsConfig: undefined as Record | undefined,
sessionStorePath: undefined as string | undefined,
sessionConfig: undefined as Record | undefined,
allowFrom: undefined as string[] | undefined,
@@ -259,49 +261,63 @@ vi.mock("../config/config.js", async () => {
config: testState.migrationConfig ?? (raw as Record),
changes: testState.migrationChanges,
}),
- loadConfig: () => ({
- agents: (() => {
- const defaults = {
- model: "anthropic/claude-opus-4-5",
- workspace: path.join(os.tmpdir(), "clawd-gateway-test"),
- ...testState.agentConfig,
- };
- if (testState.agentsConfig) {
- return { ...testState.agentsConfig, defaults };
- }
- return { defaults };
- })(),
- bindings: testState.bindingsConfig,
- channels: {
- whatsapp: {
- allowFrom: testState.allowFrom,
+ loadConfig: () => {
+ const base = {
+ agents: (() => {
+ const defaults = {
+ model: "anthropic/claude-opus-4-5",
+ workspace: path.join(os.tmpdir(), "clawd-gateway-test"),
+ ...testState.agentConfig,
+ };
+ if (testState.agentsConfig) {
+ return { ...testState.agentsConfig, defaults };
+ }
+ return { defaults };
+ })(),
+ bindings: testState.bindingsConfig,
+ channels: (() => {
+ const baseChannels =
+ testState.channelsConfig && typeof testState.channelsConfig === "object"
+ ? { ...testState.channelsConfig }
+ : {};
+ const existing = baseChannels.whatsapp;
+ const mergedWhatsApp: Record =
+ existing && typeof existing === "object" && !Array.isArray(existing)
+ ? { ...existing }
+ : {};
+ if (testState.allowFrom !== undefined) {
+ mergedWhatsApp.allowFrom = testState.allowFrom;
+ }
+ baseChannels.whatsapp = mergedWhatsApp;
+ return baseChannels;
+ })(),
+ session: {
+ mainKey: "main",
+ store: testState.sessionStorePath,
+ ...testState.sessionConfig,
},
- },
- session: {
- mainKey: "main",
- store: testState.sessionStorePath,
- ...testState.sessionConfig,
- },
- gateway: (() => {
- const gateway: Record = {};
- if (testState.gatewayBind) gateway.bind = testState.gatewayBind;
- if (testState.gatewayAuth) gateway.auth = testState.gatewayAuth;
- return Object.keys(gateway).length > 0 ? gateway : undefined;
- })(),
- canvasHost: (() => {
- const canvasHost: Record = {};
- if (typeof testState.canvasHostPort === "number")
- canvasHost.port = testState.canvasHostPort;
- return Object.keys(canvasHost).length > 0 ? canvasHost : undefined;
- })(),
- hooks: testState.hooksConfig,
- cron: (() => {
- const cron: Record = {};
- if (typeof testState.cronEnabled === "boolean") cron.enabled = testState.cronEnabled;
- if (typeof testState.cronStorePath === "string") cron.store = testState.cronStorePath;
- return Object.keys(cron).length > 0 ? cron : undefined;
- })(),
- }),
+ gateway: (() => {
+ const gateway: Record = {};
+ if (testState.gatewayBind) gateway.bind = testState.gatewayBind;
+ if (testState.gatewayAuth) gateway.auth = testState.gatewayAuth;
+ return Object.keys(gateway).length > 0 ? gateway : undefined;
+ })(),
+ canvasHost: (() => {
+ const canvasHost: Record = {};
+ if (typeof testState.canvasHostPort === "number")
+ canvasHost.port = testState.canvasHostPort;
+ return Object.keys(canvasHost).length > 0 ? canvasHost : undefined;
+ })(),
+ hooks: testState.hooksConfig,
+ cron: (() => {
+ const cron: Record = {};
+ if (typeof testState.cronEnabled === "boolean") cron.enabled = testState.cronEnabled;
+ if (typeof testState.cronStorePath === "string") cron.store = testState.cronStorePath;
+ return Object.keys(cron).length > 0 ? cron : undefined;
+ })(),
+ } as ReturnType;
+ return applyPluginAutoEnable({ config: base }).config;
+ },
parseConfigJson5: (raw: string) => {
try {
return { ok: true, parsed: JSON.parse(raw) as unknown };
diff --git a/src/gateway/test-helpers.server.ts b/src/gateway/test-helpers.server.ts
index 526eed1e1..0a88a25e6 100644
--- a/src/gateway/test-helpers.server.ts
+++ b/src/gateway/test-helpers.server.ts
@@ -99,6 +99,7 @@ export function installGatewayTestHooks() {
testState.agentConfig = undefined;
testState.agentsConfig = undefined;
testState.bindingsConfig = undefined;
+ testState.channelsConfig = undefined;
testState.allowFrom = undefined;
testIsNixMode.value = false;
cronIsolatedRun.mockClear();
diff --git a/src/hooks/install.test.ts b/src/hooks/install.test.ts
index c1912eac3..76d0f88df 100644
--- a/src/hooks/install.test.ts
+++ b/src/hooks/install.test.ts
@@ -4,7 +4,7 @@ import os from "node:os";
import path from "node:path";
import JSZip from "jszip";
import * as tar from "tar";
-import { afterEach, describe, expect, it, vi } from "vitest";
+import { afterEach, describe, expect, it } from "vitest";
const tempDirs: string[] = [];
@@ -15,22 +15,6 @@ function makeTempDir() {
return dir;
}
-async function withStateDir(stateDir: string, fn: () => Promise) {
- const prev = process.env.CLAWDBOT_STATE_DIR;
- process.env.CLAWDBOT_STATE_DIR = stateDir;
- vi.resetModules();
- try {
- return await fn();
- } finally {
- if (prev === undefined) {
- delete process.env.CLAWDBOT_STATE_DIR;
- } else {
- process.env.CLAWDBOT_STATE_DIR = prev;
- }
- vi.resetModules();
- }
-}
-
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
try {
@@ -72,10 +56,9 @@ describe("installHooksFromArchive", () => {
const buffer = await zip.generateAsync({ type: "nodebuffer" });
fs.writeFileSync(archivePath, buffer);
- const result = await withStateDir(stateDir, async () => {
- const { installHooksFromArchive } = await import("./install.js");
- return await installHooksFromArchive({ archivePath });
- });
+ const hooksDir = path.join(stateDir, "hooks");
+ const { installHooksFromArchive } = await import("./install.js");
+ const result = await installHooksFromArchive({ archivePath, hooksDir });
expect(result.ok).toBe(true);
if (!result.ok) return;
@@ -121,10 +104,9 @@ describe("installHooksFromArchive", () => {
);
await tar.c({ cwd: workDir, file: archivePath }, ["package"]);
- const result = await withStateDir(stateDir, async () => {
- const { installHooksFromArchive } = await import("./install.js");
- return await installHooksFromArchive({ archivePath });
- });
+ const hooksDir = path.join(stateDir, "hooks");
+ const { installHooksFromArchive } = await import("./install.js");
+ const result = await installHooksFromArchive({ archivePath, hooksDir });
expect(result.ok).toBe(true);
if (!result.ok) return;
@@ -155,10 +137,9 @@ describe("installHooksFromPath", () => {
);
fs.writeFileSync(path.join(hookDir, "handler.ts"), "export default async () => {};\n");
- const result = await withStateDir(stateDir, async () => {
- const { installHooksFromPath } = await import("./install.js");
- return await installHooksFromPath({ path: hookDir });
- });
+ const hooksDir = path.join(stateDir, "hooks");
+ const { installHooksFromPath } = await import("./install.js");
+ const result = await installHooksFromPath({ path: hookDir, hooksDir });
expect(result.ok).toBe(true);
if (!result.ok) return;
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/infra/bonjour.test.ts b/src/infra/bonjour.test.ts
index 0993218c7..f5786fcd6 100644
--- a/src/infra/bonjour.test.ts
+++ b/src/infra/bonjour.test.ts
@@ -1,6 +1,8 @@
import os from "node:os";
-import { afterEach, describe, expect, it, vi } from "vitest";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import * as logging from "../logging.js";
const createService = vi.fn();
const shutdown = vi.fn();
@@ -23,14 +25,6 @@ vi.mock("../logger.js", () => {
};
});
-vi.mock("../logging.js", async () => {
- const actual = await vi.importActual("../logging.js");
- return {
- ...actual,
- getLogger: () => ({ info: (...args: unknown[]) => getLoggerInfo(...args) }),
- };
-});
-
vi.mock("@homebridge/ciao", () => {
return {
Protocol: { TCP: "tcp" },
@@ -60,6 +54,12 @@ describe("gateway bonjour advertiser", () => {
const prevEnv = { ...process.env };
+ beforeEach(() => {
+ vi.spyOn(logging, "getLogger").mockReturnValue({
+ info: (...args: unknown[]) => getLoggerInfo(...args),
+ });
+ });
+
afterEach(() => {
for (const key of Object.keys(process.env)) {
if (!(key in prevEnv)) delete process.env[key];
diff --git a/src/infra/exec-approvals.test.ts b/src/infra/exec-approvals.test.ts
index aa58e8321..c3321aebf 100644
--- a/src/infra/exec-approvals.test.ts
+++ b/src/infra/exec-approvals.test.ts
@@ -90,7 +90,7 @@ describe("exec approvals command resolution", () => {
const script = path.join(cwd, "bin", "tool");
fs.mkdirSync(path.dirname(script), { recursive: true });
fs.writeFileSync(script, "");
- const res = resolveCommandResolution("\"./bin/tool\" --version", cwd, undefined);
+ const res = resolveCommandResolution('"./bin/tool" --version', cwd, undefined);
expect(res?.resolvedPath).toBe(script);
});
});
diff --git a/src/infra/exec-host.ts b/src/infra/exec-host.ts
index 9a748bf0b..73176788b 100644
--- a/src/infra/exec-host.ts
+++ b/src/infra/exec-host.ts
@@ -86,7 +86,12 @@ export async function requestExecHostViaSocket(params: {
idx = buffer.indexOf("\n");
if (!line) continue;
try {
- const msg = JSON.parse(line) as { type?: string; ok?: boolean; payload?: unknown; error?: unknown };
+ const msg = JSON.parse(line) as {
+ type?: string;
+ ok?: boolean;
+ payload?: unknown;
+ error?: unknown;
+ };
if (msg?.type === "exec-res") {
clearTimeout(timer);
if (msg.ok === true && msg.payload) {
diff --git a/src/logging.ts b/src/logging.ts
index 9f831bf6c..7fe87c1d2 100644
--- a/src/logging.ts
+++ b/src/logging.ts
@@ -1,4 +1,31 @@
-export * from "./logging/console.js";
+export {
+ enableConsoleCapture,
+ getConsoleSettings,
+ getResolvedConsoleSettings,
+ routeLogsToStderr,
+ setConsoleSubsystemFilter,
+ setConsoleTimestampPrefix,
+ shouldLogSubsystemToConsole,
+} from "./logging/console.js";
+export type { ConsoleLoggerSettings, ConsoleStyle } from "./logging/console.js";
export type { LogLevel } from "./logging/levels.js";
-export * from "./logging/logger.js";
-export * from "./logging/subsystem.js";
+export { ALLOWED_LOG_LEVELS, levelToMinLevel, normalizeLogLevel } from "./logging/levels.js";
+export {
+ DEFAULT_LOG_DIR,
+ DEFAULT_LOG_FILE,
+ getChildLogger,
+ getLogger,
+ getResolvedLoggerSettings,
+ isFileLogLevelEnabled,
+ resetLogger,
+ setLoggerOverride,
+ toPinoLikeLogger,
+} from "./logging/logger.js";
+export type { LoggerResolvedSettings, LoggerSettings, PinoLikeLogger } from "./logging/logger.js";
+export {
+ createSubsystemLogger,
+ createSubsystemRuntime,
+ runtimeForLogger,
+ stripRedundantSubsystemPrefixForConsole,
+} from "./logging/subsystem.js";
+export type { SubsystemLogger } from "./logging/subsystem.js";
diff --git a/src/media/store.test.ts b/src/media/store.test.ts
index 327e243a3..ecf633a39 100644
--- a/src/media/store.test.ts
+++ b/src/media/store.test.ts
@@ -1,21 +1,60 @@
import fs from "node:fs/promises";
+import os from "node:os";
import path from "node:path";
import JSZip from "jszip";
import sharp from "sharp";
-import { describe, expect, it, vi } from "vitest";
+import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { isPathWithinBase } from "../../test/helpers/paths.js";
-import { withTempHome } from "../../test/helpers/temp-home.js";
describe("media store", () => {
+ let store: typeof import("./store.js");
+ let home = "";
+ const envSnapshot: Record = {};
+
+ const snapshotEnv = () => {
+ for (const key of ["HOME", "USERPROFILE", "HOMEDRIVE", "HOMEPATH", "CLAWDBOT_STATE_DIR"]) {
+ envSnapshot[key] = process.env[key];
+ }
+ };
+
+ const restoreEnv = () => {
+ for (const [key, value] of Object.entries(envSnapshot)) {
+ if (value === undefined) delete process.env[key];
+ else process.env[key] = value;
+ }
+ };
+
+ beforeAll(async () => {
+ snapshotEnv();
+ home = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-test-home-"));
+ process.env.HOME = home;
+ process.env.USERPROFILE = home;
+ process.env.CLAWDBOT_STATE_DIR = path.join(home, ".clawdbot");
+ if (process.platform === "win32") {
+ const match = home.match(/^([A-Za-z]:)(.*)$/);
+ if (match) {
+ process.env.HOMEDRIVE = match[1];
+ process.env.HOMEPATH = match[2] || "\\";
+ }
+ }
+ await fs.mkdir(path.join(home, ".clawdbot"), { recursive: true });
+ store = await import("./store.js");
+ });
+
+ afterAll(async () => {
+ restoreEnv();
+ try {
+ await fs.rm(home, { recursive: true, force: true });
+ } catch {
+ // ignore cleanup failures in tests
+ }
+ });
+
async function withTempStore(
fn: (store: typeof import("./store.js"), home: string) => Promise,
): Promise {
- return await withTempHome(async (home) => {
- vi.resetModules();
- const store = await import("./store.js");
- return await fn(store, home);
- });
+ return await fn(store, home);
}
it("creates and returns media directory", async () => {
diff --git a/src/media/store.ts b/src/media/store.ts
index eb80bbb54..d1781966e 100644
--- a/src/media/store.ts
+++ b/src/media/store.ts
@@ -4,29 +4,30 @@ import fs from "node:fs/promises";
import { request } from "node:https";
import path from "node:path";
import { pipeline } from "node:stream/promises";
-import { CONFIG_DIR } from "../utils.js";
+import { resolveConfigDir } from "../utils.js";
import { detectMime, extensionForMime } from "./mime.js";
-const MEDIA_DIR = path.join(CONFIG_DIR, "media");
+const resolveMediaDir = () => path.join(resolveConfigDir(), "media");
const MAX_BYTES = 5 * 1024 * 1024; // 5MB default
const DEFAULT_TTL_MS = 2 * 60 * 1000; // 2 minutes
export function getMediaDir() {
- return MEDIA_DIR;
+ return resolveMediaDir();
}
export async function ensureMediaDir() {
- await fs.mkdir(MEDIA_DIR, { recursive: true });
- return MEDIA_DIR;
+ const mediaDir = resolveMediaDir();
+ await fs.mkdir(mediaDir, { recursive: true });
+ return mediaDir;
}
export async function cleanOldMedia(ttlMs = DEFAULT_TTL_MS) {
- await ensureMediaDir();
- const entries = await fs.readdir(MEDIA_DIR).catch(() => []);
+ const mediaDir = await ensureMediaDir();
+ const entries = await fs.readdir(mediaDir).catch(() => []);
const now = Date.now();
await Promise.all(
entries.map(async (file) => {
- const full = path.join(MEDIA_DIR, file);
+ const full = path.join(mediaDir, file);
const stat = await fs.stat(full).catch(() => null);
if (!stat) return;
if (now - stat.mtimeMs > ttlMs) {
@@ -110,7 +111,8 @@ export async function saveMediaSource(
headers?: Record,
subdir = "",
): Promise {
- const dir = subdir ? path.join(MEDIA_DIR, subdir) : MEDIA_DIR;
+ const baseDir = resolveMediaDir();
+ const dir = subdir ? path.join(baseDir, subdir) : baseDir;
await fs.mkdir(dir, { recursive: true });
await cleanOldMedia();
const baseId = crypto.randomUUID();
@@ -154,7 +156,7 @@ export async function saveMediaBuffer(
if (buffer.byteLength > maxBytes) {
throw new Error(`Media exceeds ${(maxBytes / (1024 * 1024)).toFixed(0)}MB limit`);
}
- const dir = path.join(MEDIA_DIR, subdir);
+ const dir = path.join(resolveMediaDir(), subdir);
await fs.mkdir(dir, { recursive: true });
const baseId = crypto.randomUUID();
const headerExt = extensionForMime(contentType?.split(";")[0]?.trim() ?? undefined);
diff --git a/src/memory/batch-gemini.ts b/src/memory/batch-gemini.ts
index 6a4ddb12e..44de03ca9 100644
--- a/src/memory/batch-gemini.ts
+++ b/src/memory/batch-gemini.ts
@@ -183,7 +183,9 @@ async function fetchGeminiBatchStatus(params: {
batchName: string;
}): Promise {
const baseUrl = getGeminiBaseUrl(params.gemini);
- const name = params.batchName.startsWith("batches/") ? params.batchName : `batches/${params.batchName}`;
+ const name = params.batchName.startsWith("batches/")
+ ? params.batchName
+ : `batches/${params.batchName}`;
const statusUrl = `${baseUrl}/${name}`;
debugLog("memory embeddings: gemini batch status", { statusUrl });
const res = await fetch(statusUrl, {
@@ -328,7 +330,11 @@ export async function runGeminiEmbeddingBatches(params: {
requests: group.length,
});
- if (!params.wait && batchInfo.state && !["SUCCEEDED", "COMPLETED", "DONE"].includes(batchInfo.state)) {
+ if (
+ !params.wait &&
+ batchInfo.state &&
+ !["SUCCEEDED", "COMPLETED", "DONE"].includes(batchInfo.state)
+ ) {
throw new Error(
`gemini batch ${batchName} submitted; enable remote.batch.wait to await completion`,
);
@@ -376,8 +382,7 @@ export async function runGeminiEmbeddingBatches(params: {
errors.push(`${customId}: ${line.response.error.message}`);
continue;
}
- const embedding =
- line.embedding?.values ?? line.response?.embedding?.values ?? [];
+ const embedding = line.embedding?.values ?? line.response?.embedding?.values ?? [];
if (embedding.length === 0) {
errors.push(`${customId}: empty embedding`);
continue;
diff --git a/src/memory/embeddings.ts b/src/memory/embeddings.ts
index 8e13fb316..2d4aba27e 100644
--- a/src/memory/embeddings.ts
+++ b/src/memory/embeddings.ts
@@ -3,14 +3,8 @@ import fsSync from "node:fs";
import type { Llama, LlamaEmbeddingContext, LlamaModel } from "node-llama-cpp";
import type { ClawdbotConfig } from "../config/config.js";
import { resolveUserPath } from "../utils.js";
-import {
- createGeminiEmbeddingProvider,
- type GeminiEmbeddingClient,
-} from "./embeddings-gemini.js";
-import {
- createOpenAiEmbeddingProvider,
- type OpenAiEmbeddingClient,
-} from "./embeddings-openai.js";
+import { createGeminiEmbeddingProvider, type GeminiEmbeddingClient } from "./embeddings-gemini.js";
+import { createOpenAiEmbeddingProvider, type OpenAiEmbeddingClient } from "./embeddings-openai.js";
import { importNodeLlamaCpp } from "./node-llama.js";
export type { GeminiEmbeddingClient } from "./embeddings-gemini.js";
@@ -68,7 +62,6 @@ function isMissingApiKeyError(err: unknown): boolean {
return message.includes("No API key found for provider");
}
-
async function createLocalEmbeddingProvider(
options: EmbeddingProviderOptions,
): Promise {
@@ -188,9 +181,7 @@ export async function createEmbeddingProvider(
fallbackReason: reason,
};
} catch (fallbackErr) {
- throw new Error(
- `${reason}\n\nFallback to ${fallback} failed: ${formatError(fallbackErr)}`,
- );
+ throw new Error(`${reason}\n\nFallback to ${fallback} failed: ${formatError(fallbackErr)}`);
}
}
throw new Error(reason);
diff --git a/src/memory/manager.ts b/src/memory/manager.ts
index eef106b00..56eab4375 100644
--- a/src/memory/manager.ts
+++ b/src/memory/manager.ts
@@ -697,9 +697,7 @@ export class MemoryIndexManager {
private async removeIndexFiles(basePath: string): Promise {
const suffixes = ["", "-wal", "-shm"];
- await Promise.all(
- suffixes.map((suffix) => fs.rm(`${basePath}${suffix}`, { force: true })),
- );
+ await Promise.all(suffixes.map((suffix) => fs.rm(`${basePath}${suffix}`, { force: true })));
}
private ensureSchema() {
@@ -1064,8 +1062,8 @@ export class MemoryIndexManager {
const batch = this.settings.remote?.batch;
const enabled = Boolean(
batch?.enabled &&
- ((this.openAi && this.provider.id === "openai") ||
- (this.gemini && this.provider.id === "gemini")),
+ ((this.openAi && this.provider.id === "openai") ||
+ (this.gemini && this.provider.id === "gemini")),
);
return {
enabled,
diff --git a/src/memory/manager.vector-dedupe.test.ts b/src/memory/manager.vector-dedupe.test.ts
index 936bb8410..a63cdd48e 100644
--- a/src/memory/manager.vector-dedupe.test.ts
+++ b/src/memory/manager.vector-dedupe.test.ts
@@ -63,7 +63,11 @@ describe("memory vector dedupe", () => {
if (!result.manager) throw new Error("manager missing");
manager = result.manager;
- const db = (manager as unknown as { db: { exec: (sql: string) => void; prepare: (sql: string) => unknown } }).db;
+ const db = (
+ manager as unknown as {
+ db: { exec: (sql: string) => void; prepare: (sql: string) => unknown };
+ }
+ ).db;
db.exec("CREATE TABLE IF NOT EXISTS chunks_vec (id TEXT PRIMARY KEY, embedding BLOB)");
const sqlSeen: string[] = [];
@@ -75,16 +79,20 @@ describe("memory vector dedupe", () => {
return originalPrepare(sql);
};
- (manager as unknown as { ensureVectorReady: (dims?: number) => Promise }).ensureVectorReady =
- async () => true;
+ (
+ manager as unknown as { ensureVectorReady: (dims?: number) => Promise }
+ ).ensureVectorReady = async () => true;
const entry = await buildFileEntry(path.join(workspaceDir, "MEMORY.md"), workspaceDir);
- await (manager as unknown as { indexFile: (entry: unknown, options: { source: "memory" }) => Promise }).indexFile(
- entry,
- { source: "memory" },
- );
+ await (
+ manager as unknown as {
+ indexFile: (entry: unknown, options: { source: "memory" }) => Promise;
+ }
+ ).indexFile(entry, { source: "memory" });
- const deleteIndex = sqlSeen.findIndex((sql) => sql.includes("DELETE FROM chunks_vec WHERE id = ?"));
+ const deleteIndex = sqlSeen.findIndex((sql) =>
+ sql.includes("DELETE FROM chunks_vec WHERE id = ?"),
+ );
const insertIndex = sqlSeen.findIndex((sql) => sql.includes("INSERT INTO chunks_vec"));
expect(deleteIndex).toBeGreaterThan(-1);
expect(insertIndex).toBeGreaterThan(-1);
diff --git a/src/node-host/runner.ts b/src/node-host/runner.ts
index 6bf8454ab..1b69015a3 100644
--- a/src/node-host/runner.ts
+++ b/src/node-host/runner.ts
@@ -576,7 +576,8 @@ async function handleInvoke(
const skillAllow =
autoAllowSkills && resolution?.executableName ? bins.has(resolution.executableName) : false;
- const useMacAppExec = process.platform === "darwin" && (execHostEnforced || !execHostFallbackAllowed);
+ const useMacAppExec =
+ process.platform === "darwin" && (execHostEnforced || !execHostFallbackAllowed);
if (useMacAppExec) {
const execRequest: ExecHostRequest = {
command: argv,
diff --git a/src/plugins/install.test.ts b/src/plugins/install.test.ts
index 8862329da..6e2863a9b 100644
--- a/src/plugins/install.test.ts
+++ b/src/plugins/install.test.ts
@@ -4,7 +4,7 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import JSZip from "jszip";
-import { afterEach, describe, expect, it, vi } from "vitest";
+import { afterEach, describe, expect, it } from "vitest";
const tempDirs: string[] = [];
@@ -77,22 +77,6 @@ function packToArchive({
return dest;
}
-async function withStateDir(stateDir: string, fn: () => Promise) {
- const prev = process.env.CLAWDBOT_STATE_DIR;
- process.env.CLAWDBOT_STATE_DIR = stateDir;
- vi.resetModules();
- try {
- return await fn();
- } finally {
- if (prev === undefined) {
- delete process.env.CLAWDBOT_STATE_DIR;
- } else {
- process.env.CLAWDBOT_STATE_DIR = prev;
- }
- vi.resetModules();
- }
-}
-
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
try {
@@ -126,10 +110,9 @@ describe("installPluginFromArchive", () => {
outName: "plugin.tgz",
});
- const result = await withStateDir(stateDir, async () => {
- const { installPluginFromArchive } = await import("./install.js");
- return await installPluginFromArchive({ archivePath });
- });
+ const extensionsDir = path.join(stateDir, "extensions");
+ const { installPluginFromArchive } = await import("./install.js");
+ const result = await installPluginFromArchive({ archivePath, extensionsDir });
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.pluginId).toBe("voice-call");
@@ -160,12 +143,10 @@ describe("installPluginFromArchive", () => {
outName: "plugin.tgz",
});
- const { first, second } = await withStateDir(stateDir, async () => {
- const { installPluginFromArchive } = await import("./install.js");
- const first = await installPluginFromArchive({ archivePath });
- const second = await installPluginFromArchive({ archivePath });
- return { first, second };
- });
+ const extensionsDir = path.join(stateDir, "extensions");
+ const { installPluginFromArchive } = await import("./install.js");
+ const first = await installPluginFromArchive({ archivePath, extensionsDir });
+ const second = await installPluginFromArchive({ archivePath, extensionsDir });
expect(first.ok).toBe(true);
expect(second.ok).toBe(false);
@@ -191,10 +172,9 @@ describe("installPluginFromArchive", () => {
const buffer = await zip.generateAsync({ type: "nodebuffer" });
fs.writeFileSync(archivePath, buffer);
- const result = await withStateDir(stateDir, async () => {
- const { installPluginFromArchive } = await import("./install.js");
- return await installPluginFromArchive({ archivePath });
- });
+ const extensionsDir = path.join(stateDir, "extensions");
+ const { installPluginFromArchive } = await import("./install.js");
+ const result = await installPluginFromArchive({ archivePath, extensionsDir });
expect(result.ok).toBe(true);
if (!result.ok) return;
@@ -243,18 +223,23 @@ describe("installPluginFromArchive", () => {
});
})();
- const result = await withStateDir(stateDir, async () => {
- const { installPluginFromArchive } = await import("./install.js");
- const first = await installPluginFromArchive({ archivePath: archiveV1 });
- const second = await installPluginFromArchive({ archivePath: archiveV2, mode: "update" });
- return { first, second };
+ const extensionsDir = path.join(stateDir, "extensions");
+ const { installPluginFromArchive } = await import("./install.js");
+ const first = await installPluginFromArchive({
+ archivePath: archiveV1,
+ extensionsDir,
+ });
+ const second = await installPluginFromArchive({
+ archivePath: archiveV2,
+ extensionsDir,
+ mode: "update",
});
- expect(result.first.ok).toBe(true);
- expect(result.second.ok).toBe(true);
- if (!result.second.ok) return;
+ expect(first.ok).toBe(true);
+ expect(second.ok).toBe(true);
+ if (!second.ok) return;
const manifest = JSON.parse(
- fs.readFileSync(path.join(result.second.targetDir, "package.json"), "utf-8"),
+ fs.readFileSync(path.join(second.targetDir, "package.json"), "utf-8"),
) as { version?: string };
expect(manifest.version).toBe("0.0.2");
});
@@ -276,10 +261,9 @@ describe("installPluginFromArchive", () => {
outName: "bad.tgz",
});
- const result = await withStateDir(stateDir, async () => {
- const { installPluginFromArchive } = await import("./install.js");
- return await installPluginFromArchive({ archivePath });
- });
+ const extensionsDir = path.join(stateDir, "extensions");
+ const { installPluginFromArchive } = await import("./install.js");
+ const result = await installPluginFromArchive({ archivePath, extensionsDir });
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error).toContain("clawdbot.extensions");
diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts
index 3e6b0f3b6..4ea8c1816 100644
--- a/src/plugins/loader.ts
+++ b/src/plugins/loader.ts
@@ -12,6 +12,7 @@ import { initializeGlobalHookRunner } from "./hook-runner-global.js";
import { createPluginRegistry, type PluginRecord, type PluginRegistry } from "./registry.js";
import { createPluginRuntime } from "./runtime/index.js";
import { setActivePluginRegistry } from "./runtime.js";
+import { defaultSlotIdForKey } from "./slots.js";
import type {
ClawdbotPluginConfigSchema,
ClawdbotPluginDefinition,
@@ -92,7 +93,7 @@ const normalizePluginsConfig = (config?: ClawdbotConfig["plugins"]): NormalizedP
deny: normalizeList(config?.deny),
loadPaths: normalizeList(config?.load?.paths),
slots: {
- memory: memorySlot ?? "memory-core",
+ memory: memorySlot ?? defaultSlotIdForKey("memory"),
},
entries: normalizePluginEntries(config?.entries),
};
diff --git a/src/plugins/runtime/index.ts b/src/plugins/runtime/index.ts
index 3da8c2647..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,12 +37,16 @@ import {
import { resolveStateDir } from "../../config/paths.js";
import { loadConfig, writeConfigFile } from "../../config/config.js";
import {
+ readSessionUpdatedAt,
recordSessionMetaFromInbound,
resolveStorePath,
updateLastRoute,
} from "../../config/sessions.js";
import { auditDiscordChannelPermissions } from "../../discord/audit.js";
-import { listDiscordDirectoryGroupsLive, listDiscordDirectoryPeersLive } from "../../discord/directory-live.js";
+import {
+ listDiscordDirectoryGroupsLive,
+ listDiscordDirectoryPeersLive,
+} from "../../discord/directory-live.js";
import { monitorDiscordProvider } from "../../discord/monitor.js";
import { probeDiscord } from "../../discord/probe.js";
import { resolveDiscordChannelAllowlist } from "../../discord/resolve-channels.js";
@@ -68,7 +76,10 @@ import { monitorSignalProvider } from "../../signal/index.js";
import { probeSignal } from "../../signal/probe.js";
import { sendMessageSignal } from "../../signal/send.js";
import { monitorSlackProvider } from "../../slack/index.js";
-import { listSlackDirectoryGroupsLive, listSlackDirectoryPeersLive } from "../../slack/directory-live.js";
+import {
+ listSlackDirectoryGroupsLive,
+ listSlackDirectoryPeersLive,
+} from "../../slack/directory-live.js";
import { probeSlack } from "../../slack/probe.js";
import { resolveSlackChannelAllowlist } from "../../slack/resolve-channels.js";
import { resolveSlackUserAllowlist } from "../../slack/resolve-users.js";
@@ -137,12 +148,12 @@ export function createPluginRuntime(): PluginRuntime {
registerMemoryCli,
},
channel: {
- text: {
- chunkMarkdownText,
- chunkText,
- resolveTextChunkLimit,
- hasControlCommand,
- },
+ text: {
+ chunkMarkdownText,
+ chunkText,
+ resolveTextChunkLimit,
+ hasControlCommand,
+ },
reply: {
dispatchReplyWithBufferedBlockDispatcher,
createReplyDispatcherWithTyping,
@@ -151,6 +162,8 @@ export function createPluginRuntime(): PluginRuntime {
dispatchReplyFromConfig,
finalizeInboundContext,
formatAgentEnvelope,
+ formatInboundEnvelope,
+ resolveEnvelopeFormatOptions,
},
routing: {
resolveAgentRoute,
@@ -166,6 +179,7 @@ export function createPluginRuntime(): PluginRuntime {
},
session: {
resolveStorePath,
+ readSessionUpdatedAt,
recordSessionMetaFromInbound,
updateLastRoute,
},
@@ -181,12 +195,12 @@ export function createPluginRuntime(): PluginRuntime {
createInboundDebouncer,
resolveInboundDebounceMs,
},
- commands: {
- resolveCommandAuthorizedFromAuthorizers,
- isControlCommandMessage,
- shouldComputeCommandAuthorized,
- shouldHandleTextCommands,
- },
+ commands: {
+ resolveCommandAuthorizedFromAuthorizers,
+ isControlCommandMessage,
+ shouldComputeCommandAuthorized,
+ shouldHandleTextCommands,
+ },
discord: {
messageActions: discordMessageActions,
auditChannelPermissions: auditDiscordChannelPermissions,
diff --git a/src/plugins/runtime/types.ts b/src/plugins/runtime/types.ts
index 0533931b7..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;
@@ -59,8 +63,7 @@ type MediaKindFromMime = typeof import("../../media/constants.js").mediaKindFrom
type IsVoiceCompatibleAudio = typeof import("../../media/audio.js").isVoiceCompatibleAudio;
type GetImageMetadata = typeof import("../../media/image-ops.js").getImageMetadata;
type ResizeToJpeg = typeof import("../../media/image-ops.js").resizeToJpeg;
-type CreateMemoryGetTool =
- typeof import("../../agents/tools/memory-tool.js").createMemoryGetTool;
+type CreateMemoryGetTool = typeof import("../../agents/tools/memory-tool.js").createMemoryGetTool;
type CreateMemorySearchTool =
typeof import("../../agents/tools/memory-tool.js").createMemorySearchTool;
type RegisterMemoryCli = typeof import("../../cli/memory-cli.js").registerMemoryCli;
@@ -170,6 +173,8 @@ export type PluginRuntime = {
dispatchReplyFromConfig: DispatchReplyFromConfig;
finalizeInboundContext: FinalizeInboundContext;
formatAgentEnvelope: FormatAgentEnvelope;
+ formatInboundEnvelope: FormatInboundEnvelope;
+ resolveEnvelopeFormatOptions: ResolveEnvelopeFormatOptions;
};
routing: {
resolveAgentRoute: ResolveAgentRoute;
@@ -185,6 +190,7 @@ export type PluginRuntime = {
};
session: {
resolveStorePath: ResolveStorePath;
+ readSessionUpdatedAt: ReadSessionUpdatedAt;
recordSessionMetaFromInbound: RecordSessionMetaFromInbound;
updateLastRoute: UpdateLastRoute;
};
diff --git a/src/plugins/slots.test.ts b/src/plugins/slots.test.ts
new file mode 100644
index 000000000..c2a165dea
--- /dev/null
+++ b/src/plugins/slots.test.ts
@@ -0,0 +1,96 @@
+import { describe, expect, it } from "vitest";
+
+import type { ClawdbotConfig } from "../config/config.js";
+import { applyExclusiveSlotSelection } from "./slots.js";
+
+describe("applyExclusiveSlotSelection", () => {
+ it("selects the slot and disables other entries for the same kind", () => {
+ const config: ClawdbotConfig = {
+ plugins: {
+ slots: { memory: "memory-core" },
+ entries: {
+ "memory-core": { enabled: true },
+ memory: { enabled: true },
+ },
+ },
+ };
+
+ const result = applyExclusiveSlotSelection({
+ config,
+ selectedId: "memory",
+ selectedKind: "memory",
+ registry: {
+ plugins: [
+ { id: "memory-core", kind: "memory" },
+ { id: "memory", kind: "memory" },
+ ],
+ },
+ });
+
+ expect(result.changed).toBe(true);
+ expect(result.config.plugins?.slots?.memory).toBe("memory");
+ expect(result.config.plugins?.entries?.["memory-core"]?.enabled).toBe(false);
+ expect(result.warnings).toContain(
+ 'Exclusive slot "memory" switched from "memory-core" to "memory".',
+ );
+ expect(result.warnings).toContain(
+ 'Disabled other "memory" slot plugins: memory-core.',
+ );
+ });
+
+ it("does nothing when the slot already matches", () => {
+ const config: ClawdbotConfig = {
+ plugins: {
+ slots: { memory: "memory" },
+ entries: {
+ memory: { enabled: true },
+ },
+ },
+ };
+
+ const result = applyExclusiveSlotSelection({
+ config,
+ selectedId: "memory",
+ selectedKind: "memory",
+ registry: { plugins: [{ id: "memory", kind: "memory" }] },
+ });
+
+ expect(result.changed).toBe(false);
+ expect(result.warnings).toHaveLength(0);
+ expect(result.config).toBe(config);
+ });
+
+ it("warns when the slot falls back to a default", () => {
+ const config: ClawdbotConfig = {
+ plugins: {
+ entries: {
+ memory: { enabled: true },
+ },
+ },
+ };
+
+ const result = applyExclusiveSlotSelection({
+ config,
+ selectedId: "memory",
+ selectedKind: "memory",
+ registry: { plugins: [{ id: "memory", kind: "memory" }] },
+ });
+
+ expect(result.changed).toBe(true);
+ expect(result.warnings).toContain(
+ 'Exclusive slot "memory" switched from "memory-core" to "memory".',
+ );
+ });
+
+ it("skips changes when no exclusive slot applies", () => {
+ const config: ClawdbotConfig = {};
+ const result = applyExclusiveSlotSelection({
+ config,
+ selectedId: "custom",
+ });
+
+ expect(result.changed).toBe(false);
+ expect(result.warnings).toHaveLength(0);
+ expect(result.config).toBe(config);
+ });
+});
diff --git a/src/plugins/slots.ts b/src/plugins/slots.ts
new file mode 100644
index 000000000..05e06dc5c
--- /dev/null
+++ b/src/plugins/slots.ts
@@ -0,0 +1,102 @@
+import type { ClawdbotConfig } from "../config/config.js";
+import type { PluginSlotsConfig } from "../config/types.plugins.js";
+import type { PluginKind } from "./types.js";
+
+export type PluginSlotKey = keyof PluginSlotsConfig;
+
+type SlotPluginRecord = {
+ id: string;
+ kind?: PluginKind;
+};
+
+const SLOT_BY_KIND: Record = {
+ memory: "memory",
+};
+
+const DEFAULT_SLOT_BY_KEY: Record = {
+ memory: "memory-core",
+};
+
+export function slotKeyForPluginKind(kind?: PluginKind): PluginSlotKey | null {
+ if (!kind) return null;
+ return SLOT_BY_KIND[kind] ?? null;
+}
+
+export function defaultSlotIdForKey(slotKey: PluginSlotKey): string {
+ return DEFAULT_SLOT_BY_KEY[slotKey];
+}
+
+export type SlotSelectionResult = {
+ config: ClawdbotConfig;
+ warnings: string[];
+ changed: boolean;
+};
+
+export function applyExclusiveSlotSelection(params: {
+ config: ClawdbotConfig;
+ selectedId: string;
+ selectedKind?: PluginKind;
+ registry?: { plugins: SlotPluginRecord[] };
+}): SlotSelectionResult {
+ const slotKey = slotKeyForPluginKind(params.selectedKind);
+ if (!slotKey) {
+ return { config: params.config, warnings: [], changed: false };
+ }
+
+ const warnings: string[] = [];
+ const pluginsConfig = params.config.plugins ?? {};
+ const prevSlot = pluginsConfig.slots?.[slotKey];
+ const slots = {
+ ...pluginsConfig.slots,
+ [slotKey]: params.selectedId,
+ };
+
+ const inferredPrevSlot = prevSlot ?? defaultSlotIdForKey(slotKey);
+ if (inferredPrevSlot && inferredPrevSlot !== params.selectedId) {
+ warnings.push(
+ `Exclusive slot "${slotKey}" switched from "${inferredPrevSlot}" to "${params.selectedId}".`,
+ );
+ }
+
+ const entries = { ...pluginsConfig.entries };
+ const disabledIds: string[] = [];
+ if (params.registry) {
+ for (const plugin of params.registry.plugins) {
+ if (plugin.id === params.selectedId) continue;
+ if (plugin.kind !== params.selectedKind) continue;
+ const entry = entries[plugin.id];
+ if (!entry || entry.enabled !== false) {
+ entries[plugin.id] = {
+ ...entry,
+ enabled: false,
+ };
+ disabledIds.push(plugin.id);
+ }
+ }
+ }
+
+ if (disabledIds.length > 0) {
+ warnings.push(
+ `Disabled other "${slotKey}" slot plugins: ${disabledIds.sort().join(", ")}.`,
+ );
+ }
+
+ const changed = prevSlot !== params.selectedId || disabledIds.length > 0;
+
+ if (!changed) {
+ return { config: params.config, warnings: [], changed: false };
+ }
+
+ return {
+ config: {
+ ...params.config,
+ plugins: {
+ ...pluginsConfig,
+ slots,
+ entries,
+ },
+ },
+ warnings,
+ changed: true,
+ };
+}
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/src/web/logout.test.ts b/src/web/logout.test.ts
index 93b7a2a29..54991d6b9 100644
--- a/src/web/logout.test.ts
+++ b/src/web/logout.test.ts
@@ -23,23 +23,23 @@ describe("web logout", () => {
it("deletes cached credentials when present", { timeout: 60_000 }, async () => {
await withTempHome(async (home) => {
- vi.resetModules();
- const { logoutWeb, WA_WEB_AUTH_DIR } = await import("./session.js");
+ const { logoutWeb } = await import("./session.js");
+ const { resolveDefaultWebAuthDir } = await import("./auth-store.js");
+ const authDir = resolveDefaultWebAuthDir();
- expect(isPathWithinBase(home, WA_WEB_AUTH_DIR)).toBe(true);
+ expect(isPathWithinBase(home, authDir)).toBe(true);
- fs.mkdirSync(WA_WEB_AUTH_DIR, { recursive: true });
- fs.writeFileSync(path.join(WA_WEB_AUTH_DIR, "creds.json"), "{}");
+ fs.mkdirSync(authDir, { recursive: true });
+ fs.writeFileSync(path.join(authDir, "creds.json"), "{}");
const result = await logoutWeb({ runtime: runtime as never });
expect(result).toBe(true);
- expect(fs.existsSync(WA_WEB_AUTH_DIR)).toBe(false);
+ expect(fs.existsSync(authDir)).toBe(false);
});
});
it("no-ops when nothing to delete", { timeout: 60_000 }, async () => {
await withTempHome(async () => {
- vi.resetModules();
const { logoutWeb } = await import("./session.js");
const result = await logoutWeb({ runtime: runtime as never });
expect(result).toBe(false);
@@ -49,7 +49,6 @@ describe("web logout", () => {
it("keeps shared oauth.json when using legacy auth dir", async () => {
await withTempHome(async () => {
- vi.resetModules();
const { logoutWeb } = await import("./session.js");
const { resolveOAuthDir } = await import("../config/paths.js");
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;
-}