From cef6b16d14ce6d0c717adb27dd90664ddd5cdfe2 Mon Sep 17 00:00:00 2001 From: Sebastian Slight Date: Sun, 18 Jan 2026 11:26:50 -0500 Subject: [PATCH 01/11] Plugins: auto-select exclusive slots --- src/cli/plugins-cli.ts | 40 ++++++++++++++- src/plugins/loader.ts | 3 +- src/plugins/slots.test.ts | 96 +++++++++++++++++++++++++++++++++++ src/plugins/slots.ts | 102 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 239 insertions(+), 2 deletions(-) create mode 100644 src/plugins/slots.test.ts create mode 100644 src/plugins/slots.ts 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/plugins/loader.ts b/src/plugins/loader.ts index 5d03bd50a..31ee538eb 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/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..7762b5ccd --- /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, + }; +} From ab340c82fbb51e8f8c81f6270b3e7b47725be457 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 18 Jan 2026 18:43:31 +0000 Subject: [PATCH 02/11] fix: stabilize tests and logging --- ...dded-helpers.image-dimension-error.test.ts | 2 +- ...lpers.iscloudcodeassistformaterror.test.ts | 2 +- ...ssistant-after-existing-transcript.test.ts | 1 - ...models-json-into-provided-agentdir.test.ts | 1 - .../pi-embedded-runner/run/images.test.ts | 2 +- src/agents/system-prompt.ts | 6 +- src/agents/tool-images.ts | 12 +- src/agents/tools/sessions-send-helpers.ts | 9 +- src/cli/exec-approvals-cli.test.ts | 42 +++--- src/cli/exec-approvals-cli.ts | 8 +- src/cli/gateway.sigterm.test.ts | 18 +-- src/cli/memory-cli.ts | 8 +- src/cli/nodes-cli/register.status.ts | 14 +- .../auth-choice.apply.plugin-provider.ts | 5 +- src/commands/doctor-gateway-daemon-flow.ts | 10 +- .../onboard-non-interactive.remote.test.ts | 5 +- src/config/plugin-auto-enable.test.ts | 4 + src/config/plugin-auto-enable.ts | 15 +- src/config/version.ts | 5 +- src/daemon/launchd.test.ts | 13 +- src/daemon/launchd.ts | 4 +- ...ild-messages-mentionpatterns-match.test.ts | 4 +- src/discord/monitor/message-utils.ts | 4 + src/gateway/boot.test.ts | 20 +-- src/gateway/gateway.wizard.e2e.test.ts | 5 +- ...erver.agent.gateway-server-agent-a.test.ts | 129 ++++++++++++++++++ src/gateway/server.channels.test.ts | 124 +++++++++++++++++ src/gateway/server.misc.test.ts | 101 ++++++++++---- src/gateway/test-helpers.mocks.ts | 100 ++++++++------ src/gateway/test-helpers.server.ts | 1 + src/hooks/install.test.ts | 39 ++---- src/infra/bonjour.test.ts | 18 +-- src/infra/exec-approvals.test.ts | 2 +- src/infra/exec-host.ts | 7 +- src/logging.ts | 33 ++++- src/media/store.test.ts | 53 ++++++- src/media/store.ts | 22 +-- src/memory/batch-gemini.ts | 13 +- src/memory/embeddings.ts | 15 +- src/memory/manager.ts | 8 +- src/memory/manager.vector-dedupe.test.ts | 24 ++-- src/node-host/runner.ts | 3 +- src/plugins/install.test.ts | 72 ++++------ src/plugins/runtime/index.ts | 34 +++-- src/plugins/runtime/types.ts | 3 +- src/web/logout.test.ts | 15 +- 46 files changed, 700 insertions(+), 335 deletions(-) diff --git a/src/agents/pi-embedded-helpers.image-dimension-error.test.ts b/src/agents/pi-embedded-helpers.image-dimension-error.test.ts index b3417b9b1..d56f662a2 100644 --- a/src/agents/pi-embedded-helpers.image-dimension-error.test.ts +++ b/src/agents/pi-embedded-helpers.image-dimension-error.test.ts @@ -5,7 +5,7 @@ import { isImageDimensionErrorMessage, parseImageDimensionError } from "./pi-emb describe("image dimension errors", () => { it("parses anthropic image dimension errors", () => { const raw = - "400 {\"type\":\"error\",\"error\":{\"type\":\"invalid_request_error\",\"message\":\"messages.84.content.1.image.source.base64.data: At least one of the image dimensions exceed max allowed size for many-image requests: 2000 pixels\"}}"; + '400 {"type":"error","error":{"type":"invalid_request_error","message":"messages.84.content.1.image.source.base64.data: At least one of the image dimensions exceed max allowed size for many-image requests: 2000 pixels"}}'; const parsed = parseImageDimensionError(raw); expect(parsed).not.toBeNull(); expect(parsed?.maxDimensionPx).toBe(2000); diff --git a/src/agents/pi-embedded-helpers.iscloudcodeassistformaterror.test.ts b/src/agents/pi-embedded-helpers.iscloudcodeassistformaterror.test.ts index ca0c7861f..2433642e4 100644 --- a/src/agents/pi-embedded-helpers.iscloudcodeassistformaterror.test.ts +++ b/src/agents/pi-embedded-helpers.iscloudcodeassistformaterror.test.ts @@ -25,7 +25,7 @@ describe("isCloudCodeAssistFormatError", () => { expect(isCloudCodeAssistFormatError("rate limit exceeded")).toBe(false); expect( isCloudCodeAssistFormatError( - "400 {\"type\":\"error\",\"error\":{\"type\":\"invalid_request_error\",\"message\":\"messages.84.content.1.image.source.base64.data: At least one of the image dimensions exceed max allowed size for many-image requests: 2000 pixels\"}}", + '400 {"type":"error","error":{"type":"invalid_request_error","message":"messages.84.content.1.image.source.base64.data: At least one of the image dimensions exceed max allowed size for many-image requests: 2000 pixels"}}', ), ).toBe(false); }); diff --git a/src/agents/pi-embedded-runner.run-embedded-pi-agent.appends-new-user-assistant-after-existing-transcript.test.ts b/src/agents/pi-embedded-runner.run-embedded-pi-agent.appends-new-user-assistant-after-existing-transcript.test.ts index 536ba5ccb..c2067b99b 100644 --- a/src/agents/pi-embedded-runner.run-embedded-pi-agent.appends-new-user-assistant-after-existing-transcript.test.ts +++ b/src/agents/pi-embedded-runner.run-embedded-pi-agent.appends-new-user-assistant-after-existing-transcript.test.ts @@ -89,7 +89,6 @@ let runEmbeddedPiAgent: typeof import("./pi-embedded-runner.js").runEmbeddedPiAg beforeEach(async () => { vi.useRealTimers(); - vi.resetModules(); ({ runEmbeddedPiAgent } = await import("./pi-embedded-runner.js")); }); diff --git a/src/agents/pi-embedded-runner.run-embedded-pi-agent.writes-models-json-into-provided-agentdir.test.ts b/src/agents/pi-embedded-runner.run-embedded-pi-agent.writes-models-json-into-provided-agentdir.test.ts index 8cfabe90c..d49e38567 100644 --- a/src/agents/pi-embedded-runner.run-embedded-pi-agent.writes-models-json-into-provided-agentdir.test.ts +++ b/src/agents/pi-embedded-runner.run-embedded-pi-agent.writes-models-json-into-provided-agentdir.test.ts @@ -91,7 +91,6 @@ let runEmbeddedPiAgent: typeof import("./pi-embedded-runner.js").runEmbeddedPiAg beforeAll(async () => { vi.useRealTimers(); - vi.resetModules(); mockPiAi(); ({ runEmbeddedPiAgent } = await import("./pi-embedded-runner.js")); }, 20_000); diff --git a/src/agents/pi-embedded-runner/run/images.test.ts b/src/agents/pi-embedded-runner/run/images.test.ts index 3c25a8e6d..d516dfb04 100644 --- a/src/agents/pi-embedded-runner/run/images.test.ts +++ b/src/agents/pi-embedded-runner/run/images.test.ts @@ -41,7 +41,7 @@ describe("detectImageReferences", () => { expect(refs[0]?.raw).toBe("~/Pictures/vacation.png"); expect(refs[0]?.type).toBe("path"); // Resolved path should expand ~ - expect(refs[0]?.resolved).not.toContain("~"); + expect(refs[0]?.resolved?.startsWith("~")).toBe(false); }); it("detects multiple image references in a prompt", () => { diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index e29cb483e..fb354b441 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -109,11 +109,7 @@ function buildMessagingSection(params: { ]; } -function buildDocsSection(params: { - docsPath?: string; - isMinimal: boolean; - readToolName: string; -}) { +function buildDocsSection(params: { docsPath?: string; isMinimal: boolean; readToolName: string }) { const docsPath = params.docsPath?.trim(); if (!docsPath || params.isMinimal) return []; return [ diff --git a/src/agents/tool-images.ts b/src/agents/tool-images.ts index 3ae8b124d..814f9903c 100644 --- a/src/agents/tool-images.ts +++ b/src/agents/tool-images.ts @@ -58,7 +58,12 @@ async function resizeImageBase64IfNeeded(params: { const height = meta?.height; const overBytes = buf.byteLength > params.maxBytes; const hasDimensions = typeof width === "number" && typeof height === "number"; - if (hasDimensions && !overBytes && width <= params.maxDimensionPx && height <= params.maxDimensionPx) { + if ( + hasDimensions && + !overBytes && + width <= params.maxDimensionPx && + height <= params.maxDimensionPx + ) { return { base64: params.base64, mimeType: params.mimeType, @@ -67,7 +72,10 @@ async function resizeImageBase64IfNeeded(params: { height, }; } - if (hasDimensions && (width > params.maxDimensionPx || height > params.maxDimensionPx || overBytes)) { + if ( + hasDimensions && + (width > params.maxDimensionPx || height > params.maxDimensionPx || overBytes) + ) { log.warn("Image exceeds limits; resizing", { label: params.label, width, diff --git a/src/agents/tools/sessions-send-helpers.ts b/src/agents/tools/sessions-send-helpers.ts index 2ef2416bf..c8613b179 100644 --- a/src/agents/tools/sessions-send-helpers.ts +++ b/src/agents/tools/sessions-send-helpers.ts @@ -1,4 +1,8 @@ -import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js"; +import { + getChannelPlugin, + normalizeChannelId as normalizeAnyChannelId, +} from "../../channels/plugins/index.js"; +import { normalizeChannelId as normalizeChatChannelId } from "../../channels/registry.js"; import type { ClawdbotConfig } from "../../config/config.js"; const ANNOUNCE_SKIP_TOKEN = "ANNOUNCE_SKIP"; @@ -21,7 +25,8 @@ export function resolveAnnounceTargetFromKey(sessionKey: string): AnnounceTarget const id = rest.join(":").trim(); if (!id) return null; if (!channelRaw) return null; - const normalizedChannel = normalizeChannelId(channelRaw); + const normalizedChannel = + normalizeAnyChannelId(channelRaw) ?? normalizeChatChannelId(channelRaw); const channel = normalizedChannel ?? channelRaw.toLowerCase(); const kindTarget = (() => { if (!normalizedChannel) return id; diff --git a/src/cli/exec-approvals-cli.test.ts b/src/cli/exec-approvals-cli.test.ts index 8f4261e7d..2525ee10a 100644 --- a/src/cli/exec-approvals-cli.test.ts +++ b/src/cli/exec-approvals-cli.test.ts @@ -1,19 +1,17 @@ import { Command } from "commander"; import { describe, expect, it, vi } from "vitest"; -const callGatewayFromCli = vi.fn( - async (method: string, _opts: unknown, params?: unknown) => { - if (method.endsWith(".get")) { - return { - path: "/tmp/exec-approvals.json", - exists: true, - hash: "hash-1", - file: { version: 1, agents: {} }, - }; - } - return { method, params }; - }, -); +const callGatewayFromCli = vi.fn(async (method: string, _opts: unknown, params?: unknown) => { + if (method.endsWith(".get")) { + return { + path: "/tmp/exec-approvals.json", + exists: true, + hash: "hash-1", + file: { version: 1, agents: {} }, + }; + } + return { method, params }; +}); const runtimeLogs: string[] = []; const runtimeErrors: string[] = []; @@ -31,9 +29,7 @@ vi.mock("./gateway-rpc.js", () => ({ })); vi.mock("./nodes-cli/rpc.js", async () => { - const actual = await vi.importActual( - "./nodes-cli/rpc.js", - ); + const actual = await vi.importActual("./nodes-cli/rpc.js"); return { ...actual, resolveNodeId: vi.fn(async () => "node-1"), @@ -57,11 +53,7 @@ describe("exec approvals CLI", () => { await program.parseAsync(["approvals", "get"], { from: "user" }); - expect(callGatewayFromCli).toHaveBeenCalledWith( - "exec.approvals.get", - expect.anything(), - {}, - ); + expect(callGatewayFromCli).toHaveBeenCalledWith("exec.approvals.get", expect.anything(), {}); expect(runtimeErrors).toHaveLength(0); }); @@ -77,11 +69,9 @@ describe("exec approvals CLI", () => { await program.parseAsync(["approvals", "get", "--node", "macbook"], { from: "user" }); - expect(callGatewayFromCli).toHaveBeenCalledWith( - "exec.approvals.node.get", - expect.anything(), - { nodeId: "node-1" }, - ); + expect(callGatewayFromCli).toHaveBeenCalledWith("exec.approvals.node.get", expect.anything(), { + nodeId: "node-1", + }); expect(runtimeErrors).toHaveLength(0); }); }); diff --git a/src/cli/exec-approvals-cli.ts b/src/cli/exec-approvals-cli.ts index b25e80d6a..f8e6c269b 100644 --- a/src/cli/exec-approvals-cli.ts +++ b/src/cli/exec-approvals-cli.ts @@ -151,15 +151,13 @@ export function registerExecApprovalsCli(program: Command) { }); nodesCallOpts(setCmd); - const allowlist = approvals - .command("allowlist") - .description("Edit the per-agent allowlist"); + const allowlist = approvals.command("allowlist").description("Edit the per-agent allowlist"); const allowlistAdd = allowlist .command("add ") .description("Add a glob pattern to an allowlist") .option("--node ", "Target node id/name/IP (defaults to gateway)") - .option("--agent ", "Agent id (defaults to \"default\")") + .option("--agent ", 'Agent id (defaults to "default")') .action(async (pattern: string, opts: ExecApprovalsCliOpts) => { const trimmed = pattern.trim(); if (!trimmed) { @@ -196,7 +194,7 @@ export function registerExecApprovalsCli(program: Command) { .command("remove ") .description("Remove a glob pattern from an allowlist") .option("--node ", "Target node id/name/IP (defaults to gateway)") - .option("--agent ", "Agent id (defaults to \"default\")") + .option("--agent ", 'Agent id (defaults to "default")') .action(async (pattern: string, opts: ExecApprovalsCliOpts) => { const trimmed = pattern.trim(); if (!trimmed) { diff --git a/src/cli/gateway.sigterm.test.ts b/src/cli/gateway.sigterm.test.ts index 48415410c..24fc2723e 100644 --- a/src/cli/gateway.sigterm.test.ts +++ b/src/cli/gateway.sigterm.test.ts @@ -87,19 +87,13 @@ describe("gateway SIGTERM", () => { const out: string[] = []; const err: string[] = []; + const bunBin = process.env.BUN_INSTALL + ? path.join(process.env.BUN_INSTALL, "bin", "bun") + : "bun"; + child = spawn( - process.execPath, - [ - "--import", - "tsx", - "src/index.ts", - "gateway", - "--port", - String(port), - "--bind", - "loopback", - "--allow-unconfigured", - ], + bunBin, + ["src/entry.ts", "gateway", "--port", String(port), "--bind", "loopback", "--allow-unconfigured"], { cwd: process.cwd(), env: { diff --git a/src/cli/memory-cli.ts b/src/cli/memory-cli.ts index 0265ab315..51e4d338c 100644 --- a/src/cli/memory-cli.ts +++ b/src/cli/memory-cli.ts @@ -156,9 +156,7 @@ export function registerMemoryCli(program: Command) { for (const result of allResults) { const { agentId, status, embeddingProbe, indexError } = result; if (opts.index) { - const line = indexError - ? `Memory index failed: ${indexError}` - : "Memory index complete."; + const line = indexError ? `Memory index failed: ${indexError}` : "Memory index complete."; defaultRuntime.log(line); } const lines = [ @@ -167,9 +165,7 @@ export function registerMemoryCli(program: Command) { `(requested: ${status.requestedProvider})`, )}`, `${label("Model")} ${info(status.model)}`, - status.sources?.length - ? `${label("Sources")} ${info(status.sources.join(", "))}` - : null, + status.sources?.length ? `${label("Sources")} ${info(status.sources.join(", "))}` : null, `${label("Indexed")} ${success(`${status.files} files ยท ${status.chunks} chunks`)}`, `${label("Dirty")} ${status.dirty ? warn("yes") : muted("no")}`, `${label("Store")} ${info(status.dbPath)}`, diff --git a/src/cli/nodes-cli/register.status.ts b/src/cli/nodes-cli/register.status.ts index 25dd543d5..c7940f0cb 100644 --- a/src/cli/nodes-cli/register.status.ts +++ b/src/cli/nodes-cli/register.status.ts @@ -116,12 +116,14 @@ export function registerNodesStatusCommands(nodes: Command) { const family = typeof obj.deviceFamily === "string" ? obj.deviceFamily : null; const model = typeof obj.modelIdentifier === "string" ? obj.modelIdentifier : null; const ip = typeof obj.remoteIp === "string" ? obj.remoteIp : null; - const versions = formatNodeVersions(obj as { - platform?: string; - version?: string; - coreVersion?: string; - uiVersion?: string; - }); + const versions = formatNodeVersions( + obj as { + platform?: string; + version?: string; + coreVersion?: string; + uiVersion?: string; + }, + ); const parts: string[] = ["Node:", displayName, nodeId]; if (ip) parts.push(ip); 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/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..bd8d6cc49 100644 --- a/src/commands/onboard-non-interactive.remote.test.ts +++ b/src/commands/onboard-non-interactive.remote.test.ts @@ -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/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/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-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/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..b35151ff0 100644 --- a/src/gateway/gateway.wizard.e2e.test.ts +++ b/src/gateway/gateway.wizard.e2e.test.ts @@ -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.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..f3a5f6d71 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..b98aad469 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 = + 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/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/runtime/index.ts b/src/plugins/runtime/index.ts index 3da8c2647..7b38fd967 100644 --- a/src/plugins/runtime/index.ts +++ b/src/plugins/runtime/index.ts @@ -38,7 +38,10 @@ import { 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 +71,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 +143,12 @@ export function createPluginRuntime(): PluginRuntime { registerMemoryCli, }, channel: { - text: { - chunkMarkdownText, - chunkText, - resolveTextChunkLimit, - hasControlCommand, - }, + text: { + chunkMarkdownText, + chunkText, + resolveTextChunkLimit, + hasControlCommand, + }, reply: { dispatchReplyWithBufferedBlockDispatcher, createReplyDispatcherWithTyping, @@ -181,12 +187,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..3a7c211da 100644 --- a/src/plugins/runtime/types.ts +++ b/src/plugins/runtime/types.ts @@ -59,8 +59,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; 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"); From ee380e9ab90affeeeeddf2c2ecc43b3d2d0d7542 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 18 Jan 2026 18:21:13 +0000 Subject: [PATCH 03/11] fix: run cli scripts via node build runner --- docs/debug/node-issue.md | 65 +++++++++++++++++++++++++++++++++ package.json | 18 ++++----- scripts/repro/tsx-name-repro.ts | 3 ++ scripts/run-node.mjs | 38 +++++++++++++++++++ scripts/watch-node.mjs | 52 ++++++++++++++++++++++++++ 5 files changed, 167 insertions(+), 9 deletions(-) create mode 100644 docs/debug/node-issue.md create mode 100644 scripts/repro/tsx-name-repro.ts create mode 100644 scripts/run-node.mjs create mode 100644 scripts/watch-node.mjs diff --git a/docs/debug/node-issue.md b/docs/debug/node-issue.md new file mode 100644 index 000000000..d061170d1 --- /dev/null +++ b/docs/debug/node-issue.md @@ -0,0 +1,65 @@ +# Node + tsx "__name is not a function" crash + +## Summary +Running Clawdbot via Node with `tsx` fails at startup with: + +``` +[clawdbot] Failed to start CLI: TypeError: __name is not a function + at createSubsystemLogger (.../src/logging/subsystem.ts:203:25) + at .../src/agents/auth-profiles/constants.ts:25:20 +``` + +This began after switching dev scripts from Bun to `tsx` (commit `2871657e`, 2026-01-06). The same runtime path worked with Bun. + +## Environment +- Node: v25.x (observed on v25.3.0) +- tsx: 4.21.0 +- OS: macOS (repro also likely on other platforms that run Node 25) + +## Repro (Node-only) +```bash +# in repo root +node --version +pnpm install +node --import tsx src/entry.ts status +``` + +## Minimal repro in repo +```bash +node --import tsx scripts/repro/tsx-name-repro.ts +``` + +## Node version check +- Node 25.3.0: fails +- Node 22.22.0 (Homebrew `node@22`): fails +- Node 24: not installed here yet; needs verification + +## Notes / hypothesis +- `tsx` uses esbuild to transform TS/ESM. esbuildโ€™s `keepNames` emits a `__name` helper and wraps function definitions with `__name(...)`. +- The crash indicates `__name` exists but is not a function at runtime, which implies the helper is missing or overwritten for this module in the Node 25 loader path. +- Similar `__name` helper issues have been reported in other esbuild consumers when the helper is missing or rewritten. + +## Regression history +- `2871657e` (2026-01-06): scripts changed from Bun to tsx to make Bun optional. +- Before that (Bun path), `pnpm clawdbot status` and `gateway:watch` worked. + +## Workarounds +- Use Bun for dev scripts (current temporary revert). +- Use Node + tsc watch, then run compiled output: + ```bash + pnpm exec tsc --watch --preserveWatchOutput + node --watch dist/entry.js status + ``` +- Confirmed locally: `pnpm exec tsc -p tsconfig.json` + `node dist/entry.js status` works on Node 25. +- Disable esbuild keepNames in the TS loader if possible (prevents `__name` helper insertion); tsx does not currently expose this. +- Test Node LTS (22/24) with `tsx` to see if the issue is Node 25โ€“specific. + +## References +- https://opennext.js.org/cloudflare/howtos/keep_names +- https://esbuild.github.io/api/#keep-names +- https://github.com/evanw/esbuild/issues/1031 + +## Next steps +- Repro on Node 22/24 to confirm Node 25 regression. +- Test `tsx` nightly or pin to earlier version if a known regression exists. +- If reproduces on Node LTS, file a minimal repro upstream with the `__name` stack trace. diff --git a/package.json b/package.json index 5d6da168c..2352c6d75 100644 --- a/package.json +++ b/package.json @@ -65,7 +65,7 @@ "dist/whatsapp/**" ], "scripts": { - "dev": "bun src/entry.ts", + "dev": "node scripts/run-node.mjs", "postinstall": "node scripts/postinstall.js", "prepack": "pnpm build", "docs:list": "bun scripts/docs-list.ts", @@ -78,14 +78,14 @@ "ui:install": "node scripts/ui.js install", "ui:dev": "node scripts/ui.js dev", "ui:build": "node scripts/ui.js build", - "start": "bun src/entry.ts", - "clawdbot": "bun src/entry.ts", - "gateway:watch": "bun --watch src/entry.ts gateway --force", - "gateway:dev": "CLAWDBOT_SKIP_CHANNELS=1 bun src/entry.ts --dev gateway", - "gateway:dev:reset": "CLAWDBOT_SKIP_CHANNELS=1 bun src/entry.ts --dev gateway --reset", - "tui": "bun src/entry.ts tui", - "tui:dev": "CLAWDBOT_PROFILE=dev bun src/entry.ts tui", - "clawdbot:rpc": "bun src/entry.ts agent --mode rpc --json", + "start": "node scripts/run-node.mjs", + "clawdbot": "node scripts/run-node.mjs", + "gateway:watch": "node scripts/watch-node.mjs gateway --force", + "gateway:dev": "CLAWDBOT_SKIP_CHANNELS=1 node scripts/run-node.mjs --dev gateway", + "gateway:dev:reset": "CLAWDBOT_SKIP_CHANNELS=1 node scripts/run-node.mjs --dev gateway --reset", + "tui": "node scripts/run-node.mjs tui", + "tui:dev": "CLAWDBOT_PROFILE=dev node scripts/run-node.mjs tui", + "clawdbot:rpc": "node scripts/run-node.mjs agent --mode rpc --json", "ios:gen": "cd apps/ios && xcodegen generate", "ios:open": "cd apps/ios && xcodegen generate && open Clawdbot.xcodeproj", "ios:build": "bash -lc 'cd apps/ios && xcodegen generate && xcodebuild -project Clawdbot.xcodeproj -scheme Clawdbot -destination \"${IOS_DEST:-platform=iOS Simulator,name=iPhone 17}\" -configuration Debug build'", diff --git a/scripts/repro/tsx-name-repro.ts b/scripts/repro/tsx-name-repro.ts new file mode 100644 index 000000000..7f5161d40 --- /dev/null +++ b/scripts/repro/tsx-name-repro.ts @@ -0,0 +1,3 @@ +import "../../src/logging/subsystem.js"; + +console.log("tsx-name-repro: loaded logging/subsystem"); diff --git a/scripts/run-node.mjs b/scripts/run-node.mjs new file mode 100644 index 000000000..d833a8ac0 --- /dev/null +++ b/scripts/run-node.mjs @@ -0,0 +1,38 @@ +#!/usr/bin/env node +import { spawn } from "node:child_process"; +import process from "node:process"; + +const args = process.argv.slice(2); +const env = { ...process.env }; +const cwd = process.cwd(); + +const build = spawn("pnpm", ["exec", "tsc", "-p", "tsconfig.json"], { + cwd, + env, + stdio: "inherit", +}); + +build.on("exit", (code, signal) => { + if (signal) { + process.exit(1); + return; + } + if (code !== 0 && code !== null) { + process.exit(code); + return; + } + + const nodeProcess = spawn(process.execPath, ["dist/entry.js", ...args], { + cwd, + env, + stdio: "inherit", + }); + + nodeProcess.on("exit", (exitCode, exitSignal) => { + if (exitSignal) { + process.exit(1); + return; + } + process.exit(exitCode ?? 1); + }); +}); diff --git a/scripts/watch-node.mjs b/scripts/watch-node.mjs new file mode 100644 index 000000000..c91a389af --- /dev/null +++ b/scripts/watch-node.mjs @@ -0,0 +1,52 @@ +#!/usr/bin/env node +import { spawn, spawnSync } from "node:child_process"; +import process from "node:process"; + +const args = process.argv.slice(2); +const env = { ...process.env }; +const cwd = process.cwd(); + +const initialBuild = spawnSync("pnpm", ["exec", "tsc", "-p", "tsconfig.json"], { + cwd, + env, + stdio: "inherit", +}); + +if (initialBuild.status !== 0) { + process.exit(initialBuild.status ?? 1); +} + +const tsc = spawn("pnpm", ["exec", "tsc", "--watch", "--preserveWatchOutput"], { + cwd, + env, + stdio: "inherit", +}); + +const nodeProcess = spawn(process.execPath, ["--watch", "dist/entry.js", ...args], { + cwd, + env, + stdio: "inherit", +}); + +let exiting = false; + +function cleanup(code = 0) { + if (exiting) return; + exiting = true; + nodeProcess.kill("SIGTERM"); + tsc.kill("SIGTERM"); + process.exit(code); +} + +process.on("SIGINT", () => cleanup(130)); +process.on("SIGTERM", () => cleanup(143)); + +tsc.on("exit", (code) => { + if (exiting) return; + cleanup(code ?? 1); +}); + +nodeProcess.on("exit", (code, signal) => { + if (signal || exiting) return; + cleanup(code ?? 1); +}); From 5f21bf735ab32c9cf5e08370a26881e560bc8949 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 18 Jan 2026 18:46:18 +0000 Subject: [PATCH 04/11] chore: switch repo scripts to node --- package.json | 18 ++++----- scripts/build-docs-list.mjs | 15 ++++++++ scripts/{docs-list.ts => docs-list.js} | 4 +- scripts/release-check.ts | 2 +- scripts/test-force.ts | 2 +- scripts/ui.js | 53 ++++++-------------------- 6 files changed, 40 insertions(+), 54 deletions(-) create mode 100644 scripts/build-docs-list.mjs rename scripts/{docs-list.ts => docs-list.js} (98%) diff --git a/package.json b/package.json index 2352c6d75..a08ec1c48 100644 --- a/package.json +++ b/package.json @@ -68,13 +68,13 @@ "dev": "node scripts/run-node.mjs", "postinstall": "node scripts/postinstall.js", "prepack": "pnpm build", - "docs:list": "bun scripts/docs-list.ts", - "docs:bin": "bun build scripts/docs-list.ts --compile --outfile bin/docs-list", + "docs:list": "node scripts/docs-list.js", + "docs:bin": "node scripts/build-docs-list.mjs", "docs:dev": "cd docs && mint dev", "docs:build": "cd docs && pnpm dlx --reporter append-only mint broken-links", - "build": "tsc -p tsconfig.json && bun scripts/canvas-a2ui-copy.ts && bun scripts/copy-hook-metadata.ts && bun scripts/write-build-info.ts", - "plugins:sync": "bun scripts/sync-plugin-versions.ts", - "release:check": "bun scripts/release-check.ts", + "build": "tsc -p tsconfig.json && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/write-build-info.ts", + "plugins:sync": "node --import tsx scripts/sync-plugin-versions.ts", + "release:check": "node --import tsx scripts/release-check.ts", "ui:install": "node scripts/ui.js install", "ui:dev": "node scripts/ui.js dev", "ui:build": "node scripts/ui.js build", @@ -108,7 +108,7 @@ "test": "vitest run", "test:watch": "vitest", "test:ui": "pnpm --dir ui test", - "test:force": "bun scripts/test-force.ts", + "test:force": "node --import tsx scripts/test-force.ts", "test:coverage": "vitest run --coverage", "test:e2e": "vitest run --config vitest.e2e.config.ts", "test:live": "CLAWDBOT_LIVE_TEST=1 vitest run --config vitest.live.config.ts", @@ -126,11 +126,11 @@ "test:install:smoke": "bash scripts/test-install-sh-docker.sh", "test:install:e2e:openai": "CLAWDBOT_E2E_MODELS=openai bash scripts/test-install-sh-e2e-docker.sh", "test:install:e2e:anthropic": "CLAWDBOT_E2E_MODELS=anthropic bash scripts/test-install-sh-e2e-docker.sh", - "protocol:gen": "bun scripts/protocol-gen.ts", - "protocol:gen:swift": "bun scripts/protocol-gen-swift.ts", + "protocol:gen": "node --import tsx scripts/protocol-gen.ts", + "protocol:gen:swift": "node --import tsx scripts/protocol-gen-swift.ts", "protocol:check": "pnpm protocol:gen && pnpm protocol:gen:swift && git diff --exit-code -- dist/protocol.schema.json apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift", "canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh", - "check:loc": "tsx scripts/check-ts-max-loc.ts --max 500" + "check:loc": "node --import tsx scripts/check-ts-max-loc.ts --max 500" }, "keywords": [], "author": "", diff --git a/scripts/build-docs-list.mjs b/scripts/build-docs-list.mjs new file mode 100644 index 000000000..6d5dbf458 --- /dev/null +++ b/scripts/build-docs-list.mjs @@ -0,0 +1,15 @@ +#!/usr/bin/env node +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const binDir = path.join(root, "bin"); +const scriptPath = path.join(root, "scripts", "docs-list.js"); +const binPath = path.join(binDir, "docs-list"); + +fs.mkdirSync(binDir, { recursive: true }); + +const wrapper = `#!/usr/bin/env node\nimport { spawnSync } from "node:child_process";\nimport path from "node:path";\nimport { fileURLToPath } from "node:url";\n\nconst here = path.dirname(fileURLToPath(import.meta.url));\nconst script = path.join(here, "..", "scripts", "docs-list.js");\n\nconst result = spawnSync(process.execPath, [script], { stdio: "inherit" });\nprocess.exit(result.status ?? 1);\n`; + +fs.writeFileSync(binPath, wrapper, { mode: 0o755 }); diff --git a/scripts/docs-list.ts b/scripts/docs-list.js similarity index 98% rename from scripts/docs-list.ts rename to scripts/docs-list.js index 7fad2594a..194ea43ec 100755 --- a/scripts/docs-list.ts +++ b/scripts/docs-list.js @@ -1,10 +1,10 @@ -#!/usr/bin/env bun +#!/usr/bin/env node import { readdirSync, readFileSync } from 'node:fs'; import { join, relative } from 'node:path'; process.stdout.on('error', (error) => { - if ((error as NodeJS.ErrnoException).code === 'EPIPE') { + if (error?.code === 'EPIPE') { process.exit(0); } throw error; diff --git a/scripts/release-check.ts b/scripts/release-check.ts index d3377ce8d..a971e82cb 100755 --- a/scripts/release-check.ts +++ b/scripts/release-check.ts @@ -1,4 +1,4 @@ -#!/usr/bin/env bun +#!/usr/bin/env -S node --import tsx import { execSync } from "node:child_process"; import { readdirSync, readFileSync } from "node:fs"; diff --git a/scripts/test-force.ts b/scripts/test-force.ts index 4e6e3bf9e..055ecd9bc 100755 --- a/scripts/test-force.ts +++ b/scripts/test-force.ts @@ -1,4 +1,4 @@ -#!/usr/bin/env bun +#!/usr/bin/env -S node --import tsx import os from "node:os"; import path from "node:path"; import { spawnSync } from "node:child_process"; diff --git a/scripts/ui.js b/scripts/ui.js index fc7bc2ce8..32bbb2009 100644 --- a/scripts/ui.js +++ b/scripts/ui.js @@ -45,19 +45,8 @@ function which(cmd) { } function resolveRunner() { - // CLAWDBOT_PREFER_PNPM=1 forces pnpm (useful in Docker on architectures where Bun fails) - const preferPnpm = process.env.CLAWDBOT_PREFER_PNPM === "1"; - if (!preferPnpm) { - const bun = which("bun"); - if (bun) return { cmd: bun, kind: "bun" }; - } const pnpm = which("pnpm"); if (pnpm) return { cmd: pnpm, kind: "pnpm" }; - if (preferPnpm) { - // Fallback to bun if pnpm not found even when preferring pnpm - const bun = which("bun"); - if (bun) return { cmd: bun, kind: "bun" }; - } return null; } @@ -107,9 +96,7 @@ if (!action) { const runner = resolveRunner(); if (!runner) { - process.stderr.write( - "Missing UI runner: install bun or pnpm, then retry.\n", - ); + process.stderr.write("Missing UI runner: install pnpm, then retry.\n"); process.exit(1); } @@ -129,32 +116,16 @@ if (action !== "install" && !script) { process.exit(2); } -if (runner.kind === "bun") { - if (action === "install") run(runner.cmd, ["install", ...rest]); - else { - if (!depsInstalled(action === "test" ? "test" : "build")) { - const installEnv = - action === "build" - ? { ...process.env, NODE_ENV: "production" } - : process.env; - const installArgs = - action === "build" ? ["install", "--production"] : ["install"]; - runSync(runner.cmd, installArgs, installEnv); - } - run(runner.cmd, ["run", script, ...rest]); - } -} else { - if (action === "install") run(runner.cmd, ["install", ...rest]); - else { - if (!depsInstalled(action === "test" ? "test" : "build")) { - const installEnv = - action === "build" - ? { ...process.env, NODE_ENV: "production" } - : process.env; - const installArgs = - action === "build" ? ["install", "--prod"] : ["install"]; - runSync(runner.cmd, installArgs, installEnv); - } - run(runner.cmd, ["run", script, ...rest]); +if (action === "install") run(runner.cmd, ["install", ...rest]); +else { + if (!depsInstalled(action === "test" ? "test" : "build")) { + const installEnv = + action === "build" + ? { ...process.env, NODE_ENV: "production" } + : process.env; + const installArgs = + action === "build" ? ["install", "--prod"] : ["install"]; + runSync(runner.cmd, installArgs, installEnv); } + run(runner.cmd, ["run", script, ...rest]); } From 42e6ff46114c8eb49cbd1a3e25d0d9501b12486d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 18 Jan 2026 18:36:08 +0000 Subject: [PATCH 05/11] feat(cli): show Telegram bot username in status --- CHANGELOG.md | 1 + ...els.adds-non-default-telegram-account.test.ts | 16 ++++++++++++++++ src/commands/channels/status.ts | 12 ++++++++++++ 3 files changed, 29 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index cad0cf0fe..0c0a8ca14 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.). +- CLI: show Telegram bot username in channel status (probe/runtime). ### Fixes - Configure: hide OpenRouter auto routing model from the model picker. (#1182) โ€” thanks @zerone0x. 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}`); } From 744d1329cb492d0cfb254ad34b5ee2453f1d3687 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 18 Jan 2026 18:42:34 +0000 Subject: [PATCH 06/11] feat: make inbound envelopes configurable Co-authored-by: Shiva Prasad --- CHANGELOG.md | 2 +- docs/concepts/timezone.md | 43 ++++- docs/date-time.md | 48 +++++- extensions/bluebubbles/src/monitor.ts | 10 ++ extensions/matrix/src/matrix/monitor/index.ts | 40 +++-- .../src/monitor-handler/message-handler.ts | 36 ++-- extensions/zalo/src/monitor.ts | 13 +- extensions/zalouser/src/monitor.ts | 13 +- src/auto-reply/envelope.test.ts | 95 ++++++++++- src/auto-reply/envelope.ts | 157 +++++++++++++++++- src/config/schema.ts | 9 + src/config/sessions/store.ts | 12 ++ src/config/types.agent-defaults.ts | 12 ++ src/config/zod-schema.agent-defaults.ts | 3 + .../monitor/message-handler.process.ts | 26 ++- src/discord/monitor/reply-context.ts | 4 +- src/gateway/server-methods/chat.ts | 13 +- src/imessage/monitor/monitor-provider.ts | 21 ++- src/plugins/runtime/index.ts | 10 +- src/plugins/runtime/types.ts | 7 + src/signal/monitor/event-handler.ts | 40 +++-- src/slack/monitor/message-handler/prepare.ts | 44 +++-- src/telegram/bot-message-context.ts | 17 +- ...patterns-match-without-botusername.test.ts | 4 +- ...gram-bot.installs-grammy-throttler.test.ts | 2 +- src/telegram/bot.test.ts | 6 +- ....reconnects-after-connection-close.test.ts | 8 +- src/web/auto-reply/monitor/message-line.ts | 11 +- src/web/auto-reply/monitor/process-message.ts | 25 ++- ui/src/ui/chat/message-extract.test.ts | 34 ++++ ui/src/ui/chat/message-extract.ts | 40 ++++- ui/src/ui/controllers/chat.ts | 28 +--- 32 files changed, 688 insertions(+), 145 deletions(-) create mode 100644 ui/src/ui/chat/message-extract.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c0a8ca14..93ea9893f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ Docs: https://docs.clawd.bot ### Changes - Dependencies: update core + plugin deps (grammy, vitest, openai, Microsoft agents hosting, etc.). -- CLI: show Telegram bot username in channel status (probe/runtime). +- Agents: make inbound message envelopes configurable (timezone/timestamp/elapsed) and surface elapsed gaps. (#1150) โ€” thanks @shiv19. ### Fixes - Configure: hide OpenRouter auto routing model from the model picker. (#1182) โ€” thanks @zerone0x. diff --git a/docs/concepts/timezone.md b/docs/concepts/timezone.md index 9d88f1c43..3a6d3a4dd 100644 --- a/docs/concepts/timezone.md +++ b/docs/concepts/timezone.md @@ -9,7 +9,7 @@ read_when: Clawdbot standardizes timestamps so the model sees a **single reference time**. -## Message envelopes (UTC) +## Message envelopes (UTC by default) Inbound messages are wrapped in an envelope like: @@ -17,7 +17,46 @@ Inbound messages are wrapped in an envelope like: [Provider ... 2026-01-05T21:26Z] message text ``` -The timestamp in the envelope is **always UTC**, with minutes precision. +The timestamp in the envelope is **UTC by default**, with minutes precision. + +You can override this with: + +```json5 +{ + agents: { + defaults: { + envelopeTimezone: "user", // "utc" | "local" | "user" | IANA timezone + envelopeTimestamp: "on", // "on" | "off" + envelopeElapsed: "on" // "on" | "off" + } + } +} +``` + +- `envelopeTimezone: "user"` uses `agents.defaults.userTimezone` (falls back to host timezone). +- Use an explicit IANA timezone (e.g., `"Europe/Vienna"`) for a fixed offset. +- `envelopeTimestamp: "off"` removes absolute timestamps from envelope headers. +- `envelopeElapsed: "off"` removes elapsed time suffixes (the `+2m` style). + +### Examples + +**UTC (default):** + +``` +[Signal Alice +1555 2026-01-18T05:19Z] hello +``` + +**Fixed timezone:** + +``` +[Signal Alice +1555 2026-01-18 06:19 GMT+1] hello +``` + +**Elapsed time:** + +``` +[Signal Alice +1555 +2m 2026-01-18T05:19Z] follow-up +``` ## Tool payloads (raw provider data + normalized fields) diff --git a/docs/date-time.md b/docs/date-time.md index eef73e557..99da67630 100644 --- a/docs/date-time.md +++ b/docs/date-time.md @@ -7,10 +7,10 @@ read_when: # Date & Time -Clawdbot uses **UTC for transport timestamps** and **user-local time only in the system prompt**. -We avoid rewriting provider timestamps so tools keep their native semantics. +Clawdbot defaults to **UTC for transport timestamps** and **user-local time only in the system prompt**. +Provider timestamps are preserved so tools keep their native semantics. -## Message envelopes (UTC) +## Message envelopes (UTC by default) Inbound messages are wrapped with a UTC timestamp (minute precision): @@ -18,7 +18,47 @@ Inbound messages are wrapped with a UTC timestamp (minute precision): [Provider ... 2026-01-05T21:26Z] message text ``` -This envelope timestamp is **always UTC**, regardless of the host timezone. +This envelope timestamp is **UTC by default**, regardless of the host timezone. + +You can override this behavior: + +```json5 +{ + agents: { + defaults: { + envelopeTimezone: "utc", // "utc" | "local" | "user" | IANA timezone + envelopeTimestamp: "on", // "on" | "off" + envelopeElapsed: "on" // "on" | "off" + } + } +} +``` + +- `envelopeTimezone: "local"` uses the host timezone. +- `envelopeTimezone: "user"` uses `agents.defaults.userTimezone` (falls back to host timezone). +- Use an explicit IANA timezone (e.g., `"America/Chicago"`) for a fixed zone. +- `envelopeTimestamp: "off"` removes absolute timestamps from envelope headers. +- `envelopeElapsed: "off"` removes elapsed time suffixes (the `+2m` style). + +### Examples + +**UTC (default):** + +``` +[WhatsApp +1555 2026-01-18T05:19Z] hello +``` + +**User timezone:** + +``` +[WhatsApp +1555 2026-01-18 00:19 CST] hello +``` + +**Elapsed time enabled:** + +``` +[WhatsApp +1555 +30s 2026-01-18T05:19Z] follow-up +``` ## System prompt: Current Date & Time diff --git a/extensions/bluebubbles/src/monitor.ts b/extensions/bluebubbles/src/monitor.ts index ccd2cf7a8..9625d91a8 100644 --- a/extensions/bluebubbles/src/monitor.ts +++ b/extensions/bluebubbles/src/monitor.ts @@ -836,10 +836,20 @@ async function processMessage( const fromLabel = message.isGroup ? `group:${peerId}` : message.senderName || `user:${message.senderId}`; + const storePath = core.channel.session.resolveStorePath(config.session?.store, { + agentId: route.agentId, + }); + const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config); + const previousTimestamp = core.channel.session.readSessionUpdatedAt({ + storePath, + sessionKey: route.sessionKey, + }); const body = core.channel.reply.formatAgentEnvelope({ channel: "BlueBubbles", from: fromLabel, timestamp: message.timestamp, + previousTimestamp, + envelope: envelopeOptions, body: rawBody, }); let chatGuidForActions = chatGuid; diff --git a/extensions/matrix/src/matrix/monitor/index.ts b/extensions/matrix/src/matrix/monitor/index.ts index db6db5508..4b8ee51fd 100644 --- a/extensions/matrix/src/matrix/monitor/index.ts +++ b/extensions/matrix/src/matrix/monitor/index.ts @@ -466,25 +466,34 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi isThreadRoot: event.isThreadRoot, }); + const route = core.channel.routing.resolveAgentRoute({ + cfg, + channel: "matrix", + peer: { + kind: isDirectMessage ? "dm" : "channel", + id: isDirectMessage ? senderId : roomId, + }, + }); const envelopeFrom = isDirectMessage ? senderName : (roomName ?? roomId); const textWithId = `${bodyText}\n[matrix event id: ${messageId} room: ${roomId}]`; + const storePath = core.channel.session.resolveStorePath(cfg.session?.store, { + agentId: route.agentId, + }); + const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg); + const previousTimestamp = core.channel.session.readSessionUpdatedAt({ + storePath, + sessionKey: route.sessionKey, + }); const body = core.channel.reply.formatAgentEnvelope({ channel: "Matrix", from: envelopeFrom, timestamp: event.getTs() ?? undefined, + previousTimestamp, + envelope: envelopeOptions, body: textWithId, }); - const route = core.channel.routing.resolveAgentRoute({ - cfg, - channel: "matrix", - peer: { - kind: isDirectMessage ? "dm" : "channel", - id: isDirectMessage ? senderId : roomId, - }, - }); - - const groupSystemPrompt = roomConfigInfo.config?.systemPrompt?.trim() || undefined; + const groupSystemPrompt = roomConfigInfo.config?.systemPrompt?.trim() || undefined; const ctxPayload = core.channel.reply.finalizeInboundContext({ Body: body, RawBody: bodyText, @@ -517,13 +526,10 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi OriginatingTo: `room:${roomId}`, }); - const storePath = core.channel.session.resolveStorePath(cfg.session?.store, { - agentId: route.agentId, - }); - void core.channel.session.recordSessionMetaFromInbound({ - storePath, - sessionKey: ctxPayload.SessionKey ?? route.sessionKey, - ctx: ctxPayload, + void core.channel.session.recordSessionMetaFromInbound({ + storePath, + sessionKey: ctxPayload.SessionKey ?? route.sessionKey, + ctx: ctxPayload, }).catch((err) => { logger.warn( { error: String(err), storePath, sessionKey: ctxPayload.SessionKey ?? route.sessionKey }, diff --git a/extensions/msteams/src/monitor-handler/message-handler.ts b/extensions/msteams/src/monitor-handler/message-handler.ts index 6addc74fe..ee48b82ca 100644 --- a/extensions/msteams/src/monitor-handler/message-handler.ts +++ b/extensions/msteams/src/monitor-handler/message-handler.ts @@ -406,10 +406,20 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { const mediaPayload = buildMSTeamsMediaPayload(mediaList); const envelopeFrom = isDirectMessage ? senderName : conversationType; + const storePath = core.channel.session.resolveStorePath(cfg.session?.store, { + agentId: route.agentId, + }); + const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg); + const previousTimestamp = core.channel.session.readSessionUpdatedAt({ + storePath, + sessionKey: route.sessionKey, + }); const body = core.channel.reply.formatAgentEnvelope({ channel: "Teams", from: envelopeFrom, timestamp, + previousTimestamp, + envelope: envelopeOptions, body: rawBody, }); let combinedBody = body; @@ -421,15 +431,16 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { historyKey, limit: historyLimit, currentMessage: combinedBody, - formatEntry: (entry) => - core.channel.reply.formatAgentEnvelope({ - channel: "Teams", - from: conversationType, - timestamp: entry.timestamp, - body: `${entry.sender}: ${entry.body}${entry.messageId ? ` [id:${entry.messageId}]` : ""}`, - }), - }); - } + formatEntry: (entry) => + core.channel.reply.formatAgentEnvelope({ + channel: "Teams", + from: conversationType, + timestamp: entry.timestamp, + body: `${entry.sender}: ${entry.body}${entry.messageId ? ` [id:${entry.messageId}]` : ""}`, + envelope: envelopeOptions, + }), + }); + } const ctxPayload = core.channel.reply.finalizeInboundContext({ Body: combinedBody, @@ -455,11 +466,8 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { ...mediaPayload, }); - const storePath = core.channel.session.resolveStorePath(cfg.session?.store, { - agentId: route.agentId, - }); - void core.channel.session.recordSessionMetaFromInbound({ - storePath, + void core.channel.session.recordSessionMetaFromInbound({ + storePath, sessionKey: ctxPayload.SessionKey ?? route.sessionKey, ctx: ctxPayload, }).catch((err) => { diff --git a/extensions/zalo/src/monitor.ts b/extensions/zalo/src/monitor.ts index f460d23a0..cb68388cf 100644 --- a/extensions/zalo/src/monitor.ts +++ b/extensions/zalo/src/monitor.ts @@ -530,10 +530,20 @@ async function processMessageWithPipeline(params: { } const fromLabel = isGroup ? `group:${chatId}` : senderName || `user:${senderId}`; + const storePath = core.channel.session.resolveStorePath(config.session?.store, { + agentId: route.agentId, + }); + const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config); + const previousTimestamp = core.channel.session.readSessionUpdatedAt({ + storePath, + sessionKey: route.sessionKey, + }); const body = core.channel.reply.formatAgentEnvelope({ channel: "Zalo", from: fromLabel, timestamp: date ? date * 1000 : undefined, + previousTimestamp, + envelope: envelopeOptions, body: rawBody, }); @@ -560,9 +570,6 @@ async function processMessageWithPipeline(params: { OriginatingTo: `zalo:${chatId}`, }); - const storePath = core.channel.session.resolveStorePath(config.session?.store, { - agentId: route.agentId, - }); void core.channel.session.recordSessionMetaFromInbound({ storePath, sessionKey: ctxPayload.SessionKey ?? route.sessionKey, diff --git a/extensions/zalouser/src/monitor.ts b/extensions/zalouser/src/monitor.ts index ec1cb5a60..b3ab31dd3 100644 --- a/extensions/zalouser/src/monitor.ts +++ b/extensions/zalouser/src/monitor.ts @@ -274,10 +274,20 @@ async function processMessage( }); const fromLabel = isGroup ? `group:${chatId}` : senderName || `user:${senderId}`; + const storePath = core.channel.session.resolveStorePath(config.session?.store, { + agentId: route.agentId, + }); + const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config); + const previousTimestamp = core.channel.session.readSessionUpdatedAt({ + storePath, + sessionKey: route.sessionKey, + }); const body = core.channel.reply.formatAgentEnvelope({ channel: "Zalo Personal", from: fromLabel, timestamp: timestamp ? timestamp * 1000 : undefined, + previousTimestamp, + envelope: envelopeOptions, body: rawBody, }); @@ -301,9 +311,6 @@ async function processMessage( OriginatingTo: `zalouser:${chatId}`, }); - const storePath = core.channel.session.resolveStorePath(config.session?.store, { - agentId: route.agentId, - }); void core.channel.session.recordSessionMetaFromInbound({ storePath, sessionKey: ctxPayload.SessionKey ?? route.sessionKey, diff --git a/src/auto-reply/envelope.test.ts b/src/auto-reply/envelope.test.ts index 7e8f39150..d811fbd2f 100644 --- a/src/auto-reply/envelope.test.ts +++ b/src/auto-reply/envelope.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; -import { formatAgentEnvelope, formatInboundEnvelope } from "./envelope.js"; +import { + formatAgentEnvelope, + formatInboundEnvelope, + resolveEnvelopeFormatOptions, +} from "./envelope.js"; describe("formatAgentEnvelope", () => { it("includes channel, from, ip, host, and timestamp", () => { @@ -38,6 +42,46 @@ describe("formatAgentEnvelope", () => { expect(body).toBe("[WebChat 2025-01-02T03:04Z] hello"); }); + it("formats timestamps in local timezone when configured", () => { + const originalTz = process.env.TZ; + process.env.TZ = "America/Los_Angeles"; + + const ts = Date.UTC(2025, 0, 2, 3, 4); // 2025-01-02T03:04:00Z (19:04 PST) + const body = formatAgentEnvelope({ + channel: "WebChat", + timestamp: ts, + envelope: { timezone: "local" }, + body: "hello", + }); + + process.env.TZ = originalTz; + + expect(body).toMatch(/\[WebChat 2025-01-01 19:04 [^\]]+\] hello/); + }); + + it("formats timestamps in user timezone when configured", () => { + const ts = Date.UTC(2025, 0, 2, 3, 4); // 2025-01-02T03:04:00Z (04:04 CET) + const body = formatAgentEnvelope({ + channel: "WebChat", + timestamp: ts, + envelope: { timezone: "user", userTimezone: "Europe/Vienna" }, + body: "hello", + }); + + expect(body).toMatch(/\[WebChat 2025-01-02 04:04 [^\]]+\] hello/); + }); + + it("omits timestamps when configured", () => { + const ts = Date.UTC(2025, 0, 2, 3, 4); + const body = formatAgentEnvelope({ + channel: "WebChat", + timestamp: ts, + envelope: { includeTimestamp: false }, + body: "hello", + }); + expect(body).toBe("[WebChat] hello"); + }); + it("handles missing optional fields", () => { const body = formatAgentEnvelope({ channel: "Telegram", body: "hi" }); expect(body).toBe("[Telegram] hi"); @@ -77,4 +121,53 @@ describe("formatInboundEnvelope", () => { }); expect(body).toBe("[iMessage +1555] hello"); }); + + it("includes elapsed time when previousTimestamp is provided", () => { + const now = Date.now(); + const twoMinutesAgo = now - 2 * 60 * 1000; + const body = formatInboundEnvelope({ + channel: "Telegram", + from: "Alice", + body: "follow-up message", + timestamp: now, + previousTimestamp: twoMinutesAgo, + chatType: "direct", + envelope: { includeTimestamp: false }, + }); + expect(body).toContain("Alice +2m"); + expect(body).toContain("follow-up message"); + }); + + it("omits elapsed time when disabled", () => { + const now = Date.now(); + const body = formatInboundEnvelope({ + channel: "Telegram", + from: "Alice", + body: "follow-up message", + timestamp: now, + previousTimestamp: now - 2 * 60 * 1000, + chatType: "direct", + envelope: { includeElapsed: false, includeTimestamp: false }, + }); + expect(body).toBe("[Telegram Alice] follow-up message"); + }); + + it("resolves envelope options from config", () => { + const options = resolveEnvelopeFormatOptions({ + agents: { + defaults: { + envelopeTimezone: "user", + envelopeTimestamp: "off", + envelopeElapsed: "off", + userTimezone: "Europe/Vienna", + }, + }, + }); + expect(options).toEqual({ + timezone: "user", + includeTimestamp: false, + includeElapsed: false, + userTimezone: "Europe/Vienna", + }); + }); }); diff --git a/src/auto-reply/envelope.ts b/src/auto-reply/envelope.ts index a3e0ba1fe..064533eca 100644 --- a/src/auto-reply/envelope.ts +++ b/src/auto-reply/envelope.ts @@ -1,5 +1,7 @@ +import { resolveUserTimezone } from "../agents/date-time.js"; import { normalizeChatType } from "../channels/chat-type.js"; import { resolveSenderLabel, type SenderLabelParams } from "../channels/sender-label.js"; +import type { ClawdbotConfig } from "../config/config.js"; export type AgentEnvelopeParams = { channel: string; @@ -8,31 +10,162 @@ export type AgentEnvelopeParams = { host?: string; ip?: string; body: string; + previousTimestamp?: number | Date; + envelope?: EnvelopeFormatOptions; }; -function formatTimestamp(ts?: number | Date): string | undefined { - if (!ts) return undefined; - const date = ts instanceof Date ? ts : new Date(ts); - if (Number.isNaN(date.getTime())) return undefined; +export type EnvelopeFormatOptions = { + /** + * "utc" (default), "local", "user", or an explicit IANA timezone string. + */ + timezone?: string; + /** + * Include absolute timestamps in the envelope (default: true). + */ + includeTimestamp?: boolean; + /** + * Include elapsed time suffix when previousTimestamp is provided (default: true). + */ + includeElapsed?: boolean; + /** + * Optional user timezone used when timezone="user". + */ + userTimezone?: string; +}; +type ResolvedEnvelopeTimezone = + | { mode: "utc" } + | { mode: "local" } + | { mode: "iana"; timeZone: string }; + +export function resolveEnvelopeFormatOptions(cfg?: ClawdbotConfig): EnvelopeFormatOptions { + const defaults = cfg?.agents?.defaults; + return { + timezone: defaults?.envelopeTimezone, + includeTimestamp: defaults?.envelopeTimestamp !== "off", + includeElapsed: defaults?.envelopeElapsed !== "off", + userTimezone: defaults?.userTimezone, + }; +} + +function normalizeEnvelopeOptions(options?: EnvelopeFormatOptions): Required { + const includeTimestamp = options?.includeTimestamp !== false; + const includeElapsed = options?.includeElapsed !== false; + return { + timezone: options?.timezone?.trim() || "utc", + includeTimestamp, + includeElapsed, + userTimezone: options?.userTimezone, + }; +} + +function resolveExplicitTimezone(value: string): string | undefined { + try { + new Intl.DateTimeFormat("en-US", { timeZone: value }).format(new Date()); + return value; + } catch { + return undefined; + } +} + +function resolveEnvelopeTimezone(options: EnvelopeFormatOptions): ResolvedEnvelopeTimezone { + const trimmed = options.timezone?.trim(); + if (!trimmed) return { mode: "utc" }; + const lowered = trimmed.toLowerCase(); + if (lowered === "utc" || lowered === "gmt") return { mode: "utc" }; + if (lowered === "local" || lowered === "host") return { mode: "local" }; + if (lowered === "user") { + return { mode: "iana", timeZone: resolveUserTimezone(options.userTimezone) }; + } + const explicit = resolveExplicitTimezone(trimmed); + return explicit ? { mode: "iana", timeZone: explicit } : { mode: "utc" }; +} + +function formatUtcTimestamp(date: Date): string { const yyyy = String(date.getUTCFullYear()).padStart(4, "0"); const mm = String(date.getUTCMonth() + 1).padStart(2, "0"); const dd = String(date.getUTCDate()).padStart(2, "0"); const hh = String(date.getUTCHours()).padStart(2, "0"); const min = String(date.getUTCMinutes()).padStart(2, "0"); - - // Compact ISO-like UTC timestamp with minutes precision. - // Example: 2025-01-02T03:04Z return `${yyyy}-${mm}-${dd}T${hh}:${min}Z`; } +function formatZonedTimestamp(date: Date, timeZone?: string): string | undefined { + const parts = new Intl.DateTimeFormat("en-US", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hourCycle: "h23", + timeZoneName: "short", + }).formatToParts(date); + const pick = (type: string) => parts.find((part) => part.type === type)?.value; + const yyyy = pick("year"); + const mm = pick("month"); + const dd = pick("day"); + const hh = pick("hour"); + const min = pick("minute"); + const tz = [...parts] + .reverse() + .find((part) => part.type === "timeZoneName") + ?.value?.trim(); + if (!yyyy || !mm || !dd || !hh || !min) return undefined; + return `${yyyy}-${mm}-${dd} ${hh}:${min}${tz ? ` ${tz}` : ""}`; +} + +function formatTimestamp(ts: number | Date | undefined, options?: EnvelopeFormatOptions): string | undefined { + if (!ts) return undefined; + const resolved = normalizeEnvelopeOptions(options); + if (!resolved.includeTimestamp) return undefined; + const date = ts instanceof Date ? ts : new Date(ts); + if (Number.isNaN(date.getTime())) return undefined; + const zone = resolveEnvelopeTimezone(resolved); + if (zone.mode === "utc") return formatUtcTimestamp(date); + if (zone.mode === "local") return formatZonedTimestamp(date); + return formatZonedTimestamp(date, zone.timeZone); +} + +function formatElapsedTime(currentMs: number, previousMs: number): string | undefined { + const elapsedMs = currentMs - previousMs; + if (!Number.isFinite(elapsedMs) || elapsedMs < 0) return undefined; + + const seconds = Math.floor(elapsedMs / 1000); + if (seconds < 60) return `${seconds}s`; + + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m`; + + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h`; + + const days = Math.floor(hours / 24); + return `${days}d`; +} + export function formatAgentEnvelope(params: AgentEnvelopeParams): string { const channel = params.channel?.trim() || "Channel"; const parts: string[] = [channel]; - if (params.from?.trim()) parts.push(params.from.trim()); + const resolved = normalizeEnvelopeOptions(params.envelope); + const elapsed = + resolved.includeElapsed && params.timestamp && params.previousTimestamp + ? formatElapsedTime( + params.timestamp instanceof Date ? params.timestamp.getTime() : params.timestamp, + params.previousTimestamp instanceof Date + ? params.previousTimestamp.getTime() + : params.previousTimestamp, + ) + : undefined; + if (params.from?.trim()) { + const from = params.from.trim(); + parts.push(elapsed ? `${from} +${elapsed}` : from); + } else if (elapsed) { + parts.push(`+${elapsed}`); + } if (params.host?.trim()) parts.push(params.host.trim()); if (params.ip?.trim()) parts.push(params.ip.trim()); - const ts = formatTimestamp(params.timestamp); + const ts = formatTimestamp(params.timestamp, resolved); if (ts) parts.push(ts); const header = `[${parts.join(" ")}]`; return `${header} ${params.body}`; @@ -46,6 +179,8 @@ export function formatInboundEnvelope(params: { chatType?: string; senderLabel?: string; sender?: SenderLabelParams; + previousTimestamp?: number | Date; + envelope?: EnvelopeFormatOptions; }): string { const chatType = normalizeChatType(params.chatType); const isDirect = !chatType || chatType === "direct"; @@ -55,6 +190,8 @@ export function formatInboundEnvelope(params: { channel: params.channel, from: params.from, timestamp: params.timestamp, + previousTimestamp: params.previousTimestamp, + envelope: params.envelope, body, }); } @@ -85,11 +222,13 @@ export function formatThreadStarterEnvelope(params: { author?: string; timestamp?: number | Date; body: string; + envelope?: EnvelopeFormatOptions; }): string { return formatAgentEnvelope({ channel: params.channel, from: params.author, timestamp: params.timestamp, + envelope: params.envelope, body: params.body, }); } diff --git a/src/config/schema.ts b/src/config/schema.ts index 30e643c2f..854c56986 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -172,6 +172,9 @@ const FIELD_LABELS: Record = { "skills.load.watchDebounceMs": "Skills Watch Debounce (ms)", "agents.defaults.workspace": "Workspace", "agents.defaults.bootstrapMaxChars": "Bootstrap Max Chars", + "agents.defaults.envelopeTimezone": "Envelope Timezone", + "agents.defaults.envelopeTimestamp": "Envelope Timestamp", + "agents.defaults.envelopeElapsed": "Envelope Elapsed", "agents.defaults.memorySearch": "Memory Search", "agents.defaults.memorySearch.enabled": "Enable Memory Search", "agents.defaults.memorySearch.sources": "Memory Search Sources", @@ -371,6 +374,12 @@ const FIELD_HELP: Record = { "auth.cooldowns.failureWindowHours": "Failure window (hours) for backoff counters (default: 24).", "agents.defaults.bootstrapMaxChars": "Max characters of each workspace bootstrap file injected into the system prompt before truncation (default: 20000).", + "agents.defaults.envelopeTimezone": + 'Timezone for message envelopes ("utc", "local", "user", or an IANA timezone string).', + "agents.defaults.envelopeTimestamp": + 'Include absolute timestamps in message envelopes ("on" or "off").', + "agents.defaults.envelopeElapsed": + 'Include elapsed time in message envelopes ("on" or "off").', "agents.defaults.models": "Configured model catalog (keys are full provider/model IDs).", "agents.defaults.memorySearch": "Vector search over MEMORY.md and memory/*.md (per-agent overrides supported).", diff --git a/src/config/sessions/store.ts b/src/config/sessions/store.ts index 006acc9f7..8ecf576f7 100644 --- a/src/config/sessions/store.ts +++ b/src/config/sessions/store.ts @@ -155,6 +155,18 @@ export function loadSessionStore( return structuredClone(store); } +export function readSessionUpdatedAt(params: { + storePath: string; + sessionKey: string; +}): number | undefined { + try { + const store = loadSessionStore(params.storePath); + return store[params.sessionKey]?.updatedAt; + } catch { + return undefined; + } +} + async function saveSessionStoreUnlocked( storePath: string, store: Record, diff --git a/src/config/types.agent-defaults.ts b/src/config/types.agent-defaults.ts index aa4d48b3b..85eff97f2 100644 --- a/src/config/types.agent-defaults.ts +++ b/src/config/types.agent-defaults.ts @@ -105,6 +105,18 @@ export type AgentDefaultsConfig = { userTimezone?: string; /** Time format in system prompt: auto (OS preference), 12-hour, or 24-hour. */ timeFormat?: "auto" | "12" | "24"; + /** + * Envelope timestamp timezone: "utc" (default), "local", "user", or an IANA timezone string. + */ + envelopeTimezone?: string; + /** + * Include absolute timestamps in message envelopes ("on" | "off", default: "on"). + */ + envelopeTimestamp?: "on" | "off"; + /** + * Include elapsed time in message envelopes ("on" | "off", default: "on"). + */ + envelopeElapsed?: "on" | "off"; /** Optional display-only context window override (used for % in status UIs). */ contextTokens?: number; /** Optional CLI backends for text-only fallback (claude-cli, etc.). */ diff --git a/src/config/zod-schema.agent-defaults.ts b/src/config/zod-schema.agent-defaults.ts index d32a2cb45..869f7ee21 100644 --- a/src/config/zod-schema.agent-defaults.ts +++ b/src/config/zod-schema.agent-defaults.ts @@ -42,6 +42,9 @@ export const AgentDefaultsSchema = z bootstrapMaxChars: z.number().int().positive().optional(), userTimezone: z.string().optional(), timeFormat: z.union([z.literal("auto"), z.literal("12"), z.literal("24")]).optional(), + envelopeTimezone: z.string().optional(), + envelopeTimestamp: z.union([z.literal("on"), z.literal("off")]).optional(), + envelopeElapsed: z.union([z.literal("on"), z.literal("off")]).optional(), contextTokens: z.number().int().positive().optional(), cliBackends: z.record(z.string(), CliBackendSchema).optional(), memorySearch: MemorySearchSchema, diff --git a/src/discord/monitor/message-handler.process.ts b/src/discord/monitor/message-handler.process.ts index 39d4f47d4..4838c9d44 100644 --- a/src/discord/monitor/message-handler.process.ts +++ b/src/discord/monitor/message-handler.process.ts @@ -8,7 +8,11 @@ import { extractShortModelName, type ResponsePrefixContext, } from "../../auto-reply/reply/response-prefix-template.js"; -import { formatInboundEnvelope, formatThreadStarterEnvelope } from "../../auto-reply/envelope.js"; +import { + formatInboundEnvelope, + formatThreadStarterEnvelope, + resolveEnvelopeFormatOptions, +} from "../../auto-reply/envelope.js"; import { dispatchReplyFromConfig } from "../../auto-reply/reply/dispatch-from-config.js"; import { buildPendingHistoryContextFromMap, @@ -18,6 +22,7 @@ import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.j import { createReplyDispatcherWithTyping } from "../../auto-reply/reply/reply-dispatcher.js"; import type { ReplyPayload } from "../../auto-reply/types.js"; import { + readSessionUpdatedAt, recordSessionMetaFromInbound, resolveStorePath, updateLastRoute, @@ -137,6 +142,14 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) ].filter((entry): entry is string => Boolean(entry)); const groupSystemPrompt = systemPromptParts.length > 0 ? systemPromptParts.join("\n\n") : undefined; + const storePath = resolveStorePath(cfg.session?.store, { + agentId: route.agentId, + }); + const envelopeOptions = resolveEnvelopeFormatOptions(cfg); + const previousTimestamp = readSessionUpdatedAt({ + storePath, + sessionKey: route.sessionKey, + }); let combinedBody = formatInboundEnvelope({ channel: "Discord", from: fromLabel, @@ -144,6 +157,8 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) body: text, chatType: isDirectMessage ? "direct" : "channel", senderLabel, + previousTimestamp, + envelope: envelopeOptions, }); const shouldIncludeChannelHistory = !isDirectMessage && !(isGuildMessage && channelConfig?.autoThread && !threadChannel); @@ -161,10 +176,13 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) body: `${entry.body} [id:${entry.messageId ?? "unknown"} channel:${message.channelId}]`, chatType: "channel", senderLabel: entry.sender, + envelope: envelopeOptions, }), }); } - const replyContext = resolveReplyContext(message, resolveDiscordMessageText); + const replyContext = resolveReplyContext(message, resolveDiscordMessageText, { + envelope: envelopeOptions, + }); if (replyContext) { combinedBody = `[Replied message - for context]\n${replyContext}\n\n${combinedBody}`; } @@ -186,6 +204,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) author: starter.author, timestamp: starter.timestamp, body: starter.text, + envelope: envelopeOptions, }); threadStarterBody = starterEnvelope; } @@ -268,9 +287,6 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) OriginatingTo: autoThreadContext?.OriginatingTo ?? replyTarget, }); - const storePath = resolveStorePath(cfg.session?.store, { - agentId: route.agentId, - }); void recordSessionMetaFromInbound({ storePath, sessionKey: ctxPayload.SessionKey ?? route.sessionKey, diff --git a/src/discord/monitor/reply-context.ts b/src/discord/monitor/reply-context.ts index 0106f8b8f..a3095050f 100644 --- a/src/discord/monitor/reply-context.ts +++ b/src/discord/monitor/reply-context.ts @@ -1,11 +1,12 @@ import type { Guild, Message, User } from "@buape/carbon"; -import { formatAgentEnvelope } from "../../auto-reply/envelope.js"; +import { formatAgentEnvelope, type EnvelopeFormatOptions } from "../../auto-reply/envelope.js"; import { formatDiscordUserTag, resolveTimestampMs } from "./format.js"; export function resolveReplyContext( message: Message, resolveDiscordMessageText: (message: Message, options?: { includeForwarded?: boolean }) => string, + options?: { envelope?: EnvelopeFormatOptions }, ): string | null { const referenced = message.referencedMessage; if (!referenced?.author) return null; @@ -20,6 +21,7 @@ export function resolveReplyContext( from: fromLabel, timestamp: resolveTimestampMs(referenced.timestamp), body, + envelope: options?.envelope, }); } diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index 4ac678b1f..7c2b5c581 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { resolveThinkingDefault } from "../../agents/model-selection.js"; import { resolveAgentTimeoutMs } from "../../agents/timeout.js"; +import { formatInboundEnvelope, resolveEnvelopeFormatOptions } from "../../auto-reply/envelope.js"; import { agentCommand } from "../../commands/agent.js"; import { mergeSessionEntry, updateSessionStore } from "../../config/sessions.js"; import { registerAgentRunContext } from "../../infra/agent-events.js"; @@ -300,10 +301,20 @@ export const chatHandlers: GatewayRequestHandlers = { }; respond(true, ackPayload, undefined, { runId: clientRunId }); + const envelopeOptions = resolveEnvelopeFormatOptions(cfg); + const envelopedMessage = formatInboundEnvelope({ + channel: "WebChat", + from: p.sessionKey, + timestamp: now, + body: parsedMessage, + chatType: "direct", + previousTimestamp: entry?.updatedAt, + envelope: envelopeOptions, + }); const lane = isAcpSessionKey(p.sessionKey) ? p.sessionKey : undefined; void agentCommand( { - message: parsedMessage, + message: envelopedMessage, images: parsedImages.length > 0 ? parsedImages : undefined, sessionId, sessionKey: p.sessionKey, diff --git a/src/imessage/monitor/monitor-provider.ts b/src/imessage/monitor/monitor-provider.ts index 60dea2c24..2e65848ac 100644 --- a/src/imessage/monitor/monitor-provider.ts +++ b/src/imessage/monitor/monitor-provider.ts @@ -11,7 +11,11 @@ import { } from "../../auto-reply/reply/response-prefix-template.js"; import { resolveTextChunkLimit } from "../../auto-reply/chunk.js"; import { hasControlCommand } from "../../auto-reply/command-detection.js"; -import { formatInboundEnvelope, formatInboundFromLabel } from "../../auto-reply/envelope.js"; +import { + formatInboundEnvelope, + formatInboundFromLabel, + resolveEnvelopeFormatOptions, +} from "../../auto-reply/envelope.js"; import { createInboundDebouncer, resolveInboundDebounceMs, @@ -33,6 +37,7 @@ import { resolveChannelGroupRequireMention, } from "../../config/group-policy.js"; import { + readSessionUpdatedAt, recordSessionMetaFromInbound, resolveStorePath, updateLastRoute, @@ -401,6 +406,14 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P directLabel: senderNormalized, directId: sender, }); + const storePath = resolveStorePath(cfg.session?.store, { + agentId: route.agentId, + }); + const envelopeOptions = resolveEnvelopeFormatOptions(cfg); + const previousTimestamp = readSessionUpdatedAt({ + storePath, + sessionKey: route.sessionKey, + }); const body = formatInboundEnvelope({ channel: "iMessage", from: fromLabel, @@ -408,6 +421,8 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P body: bodyText, chatType: isGroup ? "group" : "direct", sender: { name: senderNormalized, id: sender }, + previousTimestamp, + envelope: envelopeOptions, }); let combinedBody = body; if (isGroup && historyKey && historyLimit > 0) { @@ -424,6 +439,7 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P body: `${entry.body}${entry.messageId ? ` [id:${entry.messageId}]` : ""}`, chatType: "group", senderLabel: entry.sender, + envelope: envelopeOptions, }), }); } @@ -461,9 +477,6 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P OriginatingTo: imessageTo, }); - const storePath = resolveStorePath(cfg.session?.store, { - agentId: route.agentId, - }); void recordSessionMetaFromInbound({ storePath, sessionKey: ctxPayload.SessionKey ?? route.sessionKey, diff --git a/src/plugins/runtime/index.ts b/src/plugins/runtime/index.ts index 7b38fd967..51eff2734 100644 --- a/src/plugins/runtime/index.ts +++ b/src/plugins/runtime/index.ts @@ -11,7 +11,11 @@ import { createInboundDebouncer, resolveInboundDebounceMs, } from "../../auto-reply/inbound-debounce.js"; -import { formatAgentEnvelope } from "../../auto-reply/envelope.js"; +import { + formatAgentEnvelope, + formatInboundEnvelope, + resolveEnvelopeFormatOptions, +} from "../../auto-reply/envelope.js"; import { dispatchReplyFromConfig } from "../../auto-reply/reply/dispatch-from-config.js"; import { buildMentionRegexes, matchesMentionPatterns } from "../../auto-reply/reply/mentions.js"; import { dispatchReplyWithBufferedBlockDispatcher } from "../../auto-reply/reply/provider-dispatcher.js"; @@ -33,6 +37,7 @@ import { import { resolveStateDir } from "../../config/paths.js"; import { loadConfig, writeConfigFile } from "../../config/config.js"; import { + readSessionUpdatedAt, recordSessionMetaFromInbound, resolveStorePath, updateLastRoute, @@ -157,6 +162,8 @@ export function createPluginRuntime(): PluginRuntime { dispatchReplyFromConfig, finalizeInboundContext, formatAgentEnvelope, + formatInboundEnvelope, + resolveEnvelopeFormatOptions, }, routing: { resolveAgentRoute, @@ -172,6 +179,7 @@ export function createPluginRuntime(): PluginRuntime { }, session: { resolveStorePath, + readSessionUpdatedAt, recordSessionMetaFromInbound, updateLastRoute, }, diff --git a/src/plugins/runtime/types.ts b/src/plugins/runtime/types.ts index 3a7c211da..397a79f09 100644 --- a/src/plugins/runtime/types.ts +++ b/src/plugins/runtime/types.ts @@ -44,10 +44,14 @@ type DispatchReplyFromConfig = type FinalizeInboundContext = typeof import("../../auto-reply/reply/inbound-context.js").finalizeInboundContext; type FormatAgentEnvelope = typeof import("../../auto-reply/envelope.js").formatAgentEnvelope; +type FormatInboundEnvelope = typeof import("../../auto-reply/envelope.js").formatInboundEnvelope; +type ResolveEnvelopeFormatOptions = + typeof import("../../auto-reply/envelope.js").resolveEnvelopeFormatOptions; type ResolveStateDir = typeof import("../../config/paths.js").resolveStateDir; type RecordSessionMetaFromInbound = typeof import("../../config/sessions.js").recordSessionMetaFromInbound; type ResolveStorePath = typeof import("../../config/sessions.js").resolveStorePath; +type ReadSessionUpdatedAt = typeof import("../../config/sessions.js").readSessionUpdatedAt; type UpdateLastRoute = typeof import("../../config/sessions.js").updateLastRoute; type LoadConfig = typeof import("../../config/config.js").loadConfig; type WriteConfigFile = typeof import("../../config/config.js").writeConfigFile; @@ -169,6 +173,8 @@ export type PluginRuntime = { dispatchReplyFromConfig: DispatchReplyFromConfig; finalizeInboundContext: FinalizeInboundContext; formatAgentEnvelope: FormatAgentEnvelope; + formatInboundEnvelope: FormatInboundEnvelope; + resolveEnvelopeFormatOptions: ResolveEnvelopeFormatOptions; }; routing: { resolveAgentRoute: ResolveAgentRoute; @@ -184,6 +190,7 @@ export type PluginRuntime = { }; session: { resolveStorePath: ResolveStorePath; + readSessionUpdatedAt: ReadSessionUpdatedAt; recordSessionMetaFromInbound: RecordSessionMetaFromInbound; updateLastRoute: UpdateLastRoute; }; diff --git a/src/signal/monitor/event-handler.ts b/src/signal/monitor/event-handler.ts index bfbd9bd72..4bc29b7e8 100644 --- a/src/signal/monitor/event-handler.ts +++ b/src/signal/monitor/event-handler.ts @@ -8,7 +8,11 @@ import { type ResponsePrefixContext, } from "../../auto-reply/reply/response-prefix-template.js"; import { hasControlCommand } from "../../auto-reply/command-detection.js"; -import { formatInboundEnvelope, formatInboundFromLabel } from "../../auto-reply/envelope.js"; +import { + formatInboundEnvelope, + formatInboundFromLabel, + resolveEnvelopeFormatOptions, +} from "../../auto-reply/envelope.js"; import { createInboundDebouncer, resolveInboundDebounceMs, @@ -21,6 +25,7 @@ import { import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js"; import { createReplyDispatcher } from "../../auto-reply/reply/reply-dispatcher.js"; import { + readSessionUpdatedAt, recordSessionMetaFromInbound, resolveStorePath, updateLastRoute, @@ -77,6 +82,23 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { directLabel: entry.senderName, directId: entry.senderDisplay, }); + const route = resolveAgentRoute({ + cfg: deps.cfg, + channel: "signal", + accountId: deps.accountId, + peer: { + kind: entry.isGroup ? "group" : "dm", + id: entry.isGroup ? (entry.groupId ?? "unknown") : entry.senderPeerId, + }, + }); + const storePath = resolveStorePath(deps.cfg.session?.store, { + agentId: route.agentId, + }); + const envelopeOptions = resolveEnvelopeFormatOptions(deps.cfg); + const previousTimestamp = readSessionUpdatedAt({ + storePath, + sessionKey: route.sessionKey, + }); const body = formatInboundEnvelope({ channel: "Signal", from: fromLabel, @@ -84,6 +106,8 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { body: entry.bodyText, chatType: entry.isGroup ? "group" : "direct", sender: { name: entry.senderName, id: entry.senderDisplay }, + previousTimestamp, + envelope: envelopeOptions, }); let combinedBody = body; const historyKey = entry.isGroup ? String(entry.groupId ?? "unknown") : undefined; @@ -103,19 +127,10 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { }`, chatType: "group", senderLabel: historyEntry.sender, + envelope: envelopeOptions, }), }); } - - const route = resolveAgentRoute({ - cfg: deps.cfg, - channel: "signal", - accountId: deps.accountId, - peer: { - kind: entry.isGroup ? "group" : "dm", - id: entry.isGroup ? (entry.groupId ?? "unknown") : entry.senderPeerId, - }, - }); const signalTo = entry.isGroup ? `group:${entry.groupId}` : `signal:${entry.senderRecipient}`; const ctxPayload = finalizeInboundContext({ Body: combinedBody, @@ -144,9 +159,6 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { OriginatingTo: signalTo, }); - const storePath = resolveStorePath(deps.cfg.session?.store, { - agentId: route.agentId, - }); void recordSessionMetaFromInbound({ storePath, sessionKey: ctxPayload.SessionKey ?? route.sessionKey, diff --git a/src/slack/monitor/message-handler/prepare.ts b/src/slack/monitor/message-handler/prepare.ts index a1fe35675..27872151e 100644 --- a/src/slack/monitor/message-handler/prepare.ts +++ b/src/slack/monitor/message-handler/prepare.ts @@ -5,6 +5,7 @@ import type { FinalizedMsgContext } from "../../../auto-reply/templating.js"; import { formatInboundEnvelope, formatThreadStarterEnvelope, + resolveEnvelopeFormatOptions, } from "../../../auto-reply/envelope.js"; import { buildPendingHistoryContextFromMap, @@ -21,7 +22,11 @@ import { resolveThreadSessionKeys } from "../../../routing/session-key.js"; import { resolveMentionGatingWithBypass } from "../../../channels/mention-gating.js"; import { resolveConversationLabel } from "../../../channels/conversation-label.js"; import { resolveControlCommandGate } from "../../../channels/command-gating.js"; -import { recordSessionMetaFromInbound, resolveStorePath } from "../../../config/sessions.js"; +import { + readSessionUpdatedAt, + recordSessionMetaFromInbound, + resolveStorePath, +} from "../../../config/sessions.js"; import type { ResolvedSlackAccount } from "../../accounts.js"; import { reactSlackMessage } from "../../actions.js"; @@ -372,6 +377,14 @@ export async function prepareSlackMessage(params: { From: slackFrom, }) ?? (isDirectMessage ? senderName : roomLabel); const textWithId = `${rawBody}\n[slack message id: ${message.ts} channel: ${message.channel}]`; + const storePath = resolveStorePath(ctx.cfg.session?.store, { + agentId: route.agentId, + }); + const envelopeOptions = resolveEnvelopeFormatOptions(ctx.cfg); + const previousTimestamp = readSessionUpdatedAt({ + storePath, + sessionKey: route.sessionKey, + }); const body = formatInboundEnvelope({ channel: "Slack", from: envelopeFrom, @@ -379,6 +392,8 @@ export async function prepareSlackMessage(params: { body: textWithId, chatType: isDirectMessage ? "direct" : "channel", sender: { name: senderName, id: senderId }, + previousTimestamp, + envelope: envelopeOptions, }); let combinedBody = body; @@ -389,17 +404,18 @@ export async function prepareSlackMessage(params: { limit: ctx.historyLimit, currentMessage: combinedBody, formatEntry: (entry) => - formatInboundEnvelope({ - channel: "Slack", - from: roomLabel, - timestamp: entry.timestamp, - body: `${entry.body}${ - entry.messageId ? ` [id:${entry.messageId} channel:${message.channel}]` : "" - }`, - chatType: "channel", - senderLabel: entry.sender, - }), - }); + formatInboundEnvelope({ + channel: "Slack", + from: roomLabel, + timestamp: entry.timestamp, + body: `${entry.body}${ + entry.messageId ? ` [id:${entry.messageId} channel:${message.channel}]` : "" + }`, + chatType: "channel", + senderLabel: entry.sender, + envelope: envelopeOptions, + }), + }); } const slackTo = isDirectMessage ? `user:${message.user}` : `channel:${message.channel}`; @@ -433,6 +449,7 @@ export async function prepareSlackMessage(params: { author: starterName, timestamp: starter.ts ? Math.round(Number(starter.ts) * 1000) : undefined, body: starterWithId, + envelope: envelopeOptions, }); const snippet = starter.text.replace(/\s+/g, " ").slice(0, 80); threadLabel = `Slack thread ${roomLabel}${snippet ? `: ${snippet}` : ""}`; @@ -472,9 +489,6 @@ export async function prepareSlackMessage(params: { OriginatingTo: slackTo, }) satisfies FinalizedMsgContext; - const storePath = resolveStorePath(ctx.cfg.session?.store, { - agentId: route.agentId, - }); void recordSessionMetaFromInbound({ storePath, sessionKey: sessionKey, diff --git a/src/telegram/bot-message-context.ts b/src/telegram/bot-message-context.ts index deecb7385..8c01b0d5f 100644 --- a/src/telegram/bot-message-context.ts +++ b/src/telegram/bot-message-context.ts @@ -3,7 +3,7 @@ import type { Bot } from "grammy"; import { resolveAckReaction } from "../agents/identity.js"; import { hasControlCommand } from "../auto-reply/command-detection.js"; import { normalizeCommandBody } from "../auto-reply/commands-registry.js"; -import { formatInboundEnvelope } from "../auto-reply/envelope.js"; +import { formatInboundEnvelope, resolveEnvelopeFormatOptions } from "../auto-reply/envelope.js"; import { buildPendingHistoryContextFromMap, recordPendingHistoryEntry, @@ -13,6 +13,7 @@ import { finalizeInboundContext } from "../auto-reply/reply/inbound-context.js"; import { buildMentionRegexes, matchesMentionPatterns } from "../auto-reply/reply/mentions.js"; import { formatLocationText, toLocationContext } from "../channels/location.js"; import { + readSessionUpdatedAt, recordSessionMetaFromInbound, resolveStorePath, updateLastRoute, @@ -417,6 +418,14 @@ export const buildTelegramMessageContext = async ({ const conversationLabel = isGroup ? (groupLabel ?? `group:${chatId}`) : buildSenderLabel(msg, senderId || chatId); + const storePath = resolveStorePath(cfg.session?.store, { + agentId: route.agentId, + }); + const envelopeOptions = resolveEnvelopeFormatOptions(cfg); + const previousTimestamp = readSessionUpdatedAt({ + storePath, + sessionKey: route.sessionKey, + }); const body = formatInboundEnvelope({ channel: "Telegram", from: conversationLabel, @@ -428,6 +437,8 @@ export const buildTelegramMessageContext = async ({ username: senderUsername || undefined, id: senderId || undefined, }, + previousTimestamp, + envelope: envelopeOptions, }); let combinedBody = body; if (isGroup && historyKey && historyLimit > 0) { @@ -444,6 +455,7 @@ export const buildTelegramMessageContext = async ({ body: `${entry.body} [id:${entry.messageId ?? "unknown"} chat:${chatId}]`, chatType: "group", senderLabel: entry.sender, + envelope: envelopeOptions, }), }); } @@ -504,9 +516,6 @@ export const buildTelegramMessageContext = async ({ OriginatingTo: `telegram:${chatId}`, }); - const storePath = resolveStorePath(cfg.session?.store, { - agentId: route.agentId, - }); void recordSessionMetaFromInbound({ storePath, sessionKey: ctxPayload.SessionKey ?? route.sessionKey, diff --git a/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts b/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts index 83297495c..7134d7d3b 100644 --- a/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts +++ b/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts @@ -176,7 +176,7 @@ describe("createTelegramBot", () => { expect(payload.WasMentioned).toBe(true); expect(payload.SenderName).toBe("Ada"); expect(payload.SenderId).toBe("9"); - expect(payload.Body).toMatch(/^\[Telegram Test Group id:7 2025-01-09T00:00Z\]/); + expect(payload.Body).toMatch(/^\[Telegram Test Group id:7 (\+\d+[smhd] )?2025-01-09T00:00Z\]/); }); it("keeps group envelope headers stable (sender identity is separate)", async () => { onSpy.mockReset(); @@ -217,7 +217,7 @@ describe("createTelegramBot", () => { expect(payload.SenderName).toBe("Ada Lovelace"); expect(payload.SenderId).toBe("99"); expect(payload.SenderUsername).toBe("ada"); - expect(payload.Body).toMatch(/^\[Telegram Ops id:42 2025-01-09T00:00Z\]/); + expect(payload.Body).toMatch(/^\[Telegram Ops id:42 (\+\d+[smhd] )?2025-01-09T00:00Z\]/); }); it("reacts to mention-gated group messages when ackReaction is enabled", async () => { onSpy.mockReset(); diff --git a/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts b/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts index 3a143a4f1..fc34477c1 100644 --- a/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts +++ b/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts @@ -330,7 +330,7 @@ describe("createTelegramBot", () => { expect(replySpy).toHaveBeenCalledTimes(1); const payload = replySpy.mock.calls[0][0]; expect(payload.Body).toMatch( - /^\[Telegram Ada Lovelace \(@ada_bot\) id:1234 2025-01-09T00:00Z\]/, + /^\[Telegram Ada Lovelace \(@ada_bot\) id:1234 (\+\d+[smhd] )?2025-01-09T00:00Z\]/, ); expect(payload.Body).toContain("hello world"); } finally { diff --git a/src/telegram/bot.test.ts b/src/telegram/bot.test.ts index c0d81a8f8..d7b37daa3 100644 --- a/src/telegram/bot.test.ts +++ b/src/telegram/bot.test.ts @@ -452,7 +452,7 @@ describe("createTelegramBot", () => { expect(replySpy).toHaveBeenCalledTimes(1); const payload = replySpy.mock.calls[0][0]; expect(payload.Body).toMatch( - /^\[Telegram Ada Lovelace \(@ada_bot\) id:1234 2025-01-09T00:00Z\]/, + /^\[Telegram Ada Lovelace \(@ada_bot\) id:1234 (\+\d+[smhd] )?2025-01-09T00:00Z\]/, ); expect(payload.Body).toContain("hello world"); } finally { @@ -586,7 +586,7 @@ describe("createTelegramBot", () => { const payload = replySpy.mock.calls[0][0]; expectInboundContextContract(payload); expect(payload.WasMentioned).toBe(true); - expect(payload.Body).toMatch(/^\[Telegram Test Group id:7 2025-01-09T00:00Z\]/); + expect(payload.Body).toMatch(/^\[Telegram Test Group id:7 (\+\d+[smhd] )?2025-01-09T00:00Z\]/); expect(payload.SenderName).toBe("Ada"); expect(payload.SenderId).toBe("9"); }); @@ -628,7 +628,7 @@ describe("createTelegramBot", () => { expect(replySpy).toHaveBeenCalledTimes(1); const payload = replySpy.mock.calls[0][0]; expectInboundContextContract(payload); - expect(payload.Body).toMatch(/^\[Telegram Ops id:42 2025-01-09T00:00Z\]/); + expect(payload.Body).toMatch(/^\[Telegram Ops id:42 (\+\d+[smhd] )?2025-01-09T00:00Z\]/); expect(payload.SenderName).toBe("Ada Lovelace"); expect(payload.SenderId).toBe("99"); expect(payload.SenderUsername).toBe("ada"); diff --git a/src/web/auto-reply.web-auto-reply.reconnects-after-connection-close.test.ts b/src/web/auto-reply.web-auto-reply.reconnects-after-connection-close.test.ts index c12d5284d..411875e21 100644 --- a/src/web/auto-reply.web-auto-reply.reconnects-after-connection-close.test.ts +++ b/src/web/auto-reply.web-auto-reply.reconnects-after-connection-close.test.ts @@ -328,9 +328,13 @@ describe("web auto-reply", () => { expect(resolver).toHaveBeenCalledTimes(2); const firstArgs = resolver.mock.calls[0][0]; const secondArgs = resolver.mock.calls[1][0]; - expect(firstArgs.Body).toContain("[WhatsApp +1 2025-01-01T00:00Z] [clawdbot] first"); + expect(firstArgs.Body).toMatch( + /\[WhatsApp \+1 (\+\d+[smhd] )?2025-01-01T00:00Z\] \[clawdbot\] first/, + ); expect(firstArgs.Body).not.toContain("second"); - expect(secondArgs.Body).toContain("[WhatsApp +1 2025-01-01T01:00Z] [clawdbot] second"); + expect(secondArgs.Body).toMatch( + /\[WhatsApp \+1 (\+\d+[smhd] )?2025-01-01T01:00Z\] \[clawdbot\] second/, + ); expect(secondArgs.Body).not.toContain("first"); // Max listeners bumped to avoid warnings in multi-instance test runs diff --git a/src/web/auto-reply/monitor/message-line.ts b/src/web/auto-reply/monitor/message-line.ts index 336dd2abc..ad51574f4 100644 --- a/src/web/auto-reply/monitor/message-line.ts +++ b/src/web/auto-reply/monitor/message-line.ts @@ -1,5 +1,8 @@ import { resolveMessagePrefix } from "../../../agents/identity.js"; -import { formatInboundEnvelope } from "../../../auto-reply/envelope.js"; +import { + formatInboundEnvelope, + type EnvelopeFormatOptions, +} from "../../../auto-reply/envelope.js"; import type { loadConfig } from "../../../config/config.js"; import type { WebInboundMsg } from "../types.js"; @@ -14,8 +17,10 @@ export function buildInboundLine(params: { cfg: ReturnType; msg: WebInboundMsg; agentId: string; + previousTimestamp?: number; + envelope?: EnvelopeFormatOptions; }) { - const { cfg, msg, agentId } = params; + const { cfg, msg, agentId, previousTimestamp, envelope } = params; // WhatsApp inbound prefix: channels.whatsapp.messagePrefix > legacy messages.messagePrefix > identity/defaults const messagePrefix = resolveMessagePrefix(cfg, agentId, { configured: cfg.channels?.whatsapp?.messagePrefix, @@ -37,5 +42,7 @@ export function buildInboundLine(params: { e164: msg.senderE164, id: msg.senderJid, }, + previousTimestamp, + envelope, }); } diff --git a/src/web/auto-reply/monitor/process-message.ts b/src/web/auto-reply/monitor/process-message.ts index 9f90e7591..ea9895853 100644 --- a/src/web/auto-reply/monitor/process-message.ts +++ b/src/web/auto-reply/monitor/process-message.ts @@ -8,7 +8,10 @@ import { type ResponsePrefixContext, } from "../../../auto-reply/reply/response-prefix-template.js"; import { resolveTextChunkLimit } from "../../../auto-reply/chunk.js"; -import { formatInboundEnvelope } from "../../../auto-reply/envelope.js"; +import { + formatInboundEnvelope, + resolveEnvelopeFormatOptions, +} from "../../../auto-reply/envelope.js"; import { buildHistoryContextFromEntries, type HistoryEntry, @@ -20,7 +23,11 @@ import { shouldComputeCommandAuthorized } from "../../../auto-reply/command-dete import { finalizeInboundContext } from "../../../auto-reply/reply/inbound-context.js"; import { toLocationContext } from "../../../channels/location.js"; import type { loadConfig } from "../../../config/config.js"; -import { recordSessionMetaFromInbound, resolveStorePath } from "../../../config/sessions.js"; +import { + readSessionUpdatedAt, + recordSessionMetaFromInbound, + resolveStorePath, +} from "../../../config/sessions.js"; import { logVerbose, shouldLogVerbose } from "../../../globals.js"; import type { getChildLogger } from "../../../logging.js"; import { readChannelAllowFromStore } from "../../../pairing/pairing-store.js"; @@ -121,10 +128,20 @@ export async function processMessage(params: { suppressGroupHistoryClear?: boolean; }) { const conversationId = params.msg.conversationId ?? params.msg.from; + const storePath = resolveStorePath(params.cfg.session?.store, { + agentId: params.route.agentId, + }); + const envelopeOptions = resolveEnvelopeFormatOptions(params.cfg); + const previousTimestamp = readSessionUpdatedAt({ + storePath, + sessionKey: params.route.sessionKey, + }); let combinedBody = buildInboundLine({ cfg: params.cfg, msg: params.msg, agentId: params.route.agentId, + previousTimestamp, + envelope: envelopeOptions, }); let shouldClearGroupHistory = false; @@ -152,6 +169,7 @@ export async function processMessage(params: { body: bodyWithId, chatType: "group", senderLabel: entry.sender, + envelope: envelopeOptions, }); }, }); @@ -288,9 +306,6 @@ export async function processMessage(params: { }); } - const storePath = resolveStorePath(params.cfg.session?.store, { - agentId: params.route.agentId, - }); const metaTask = recordSessionMetaFromInbound({ storePath, sessionKey: params.route.sessionKey, diff --git a/ui/src/ui/chat/message-extract.test.ts b/ui/src/ui/chat/message-extract.test.ts new file mode 100644 index 000000000..2147d915e --- /dev/null +++ b/ui/src/ui/chat/message-extract.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { stripEnvelope } from "./message-extract"; + +describe("stripEnvelope", () => { + it("strips UTC envelope", () => { + const text = "[WebChat agent:main:main 2026-01-18T05:19Z] hello world"; + expect(stripEnvelope(text)).toBe("hello world"); + }); + + it("strips local-time envelope", () => { + const text = "[Telegram Ada Lovelace (@ada) id:1234 2026-01-18 19:29 GMT+1] test"; + expect(stripEnvelope(text)).toBe("test"); + }); + + it("strips envelopes without timestamps for known channels", () => { + const text = "[WhatsApp +1234567890] hi there"; + expect(stripEnvelope(text)).toBe("hi there"); + }); + + it("handles multi-line messages", () => { + const text = "[Slack #general 2026-01-18T05:19Z] first line\nsecond line"; + expect(stripEnvelope(text)).toBe("first line\nsecond line"); + }); + + it("returns text as-is when no envelope present", () => { + const text = "just a regular message"; + expect(stripEnvelope(text)).toBe("just a regular message"); + }); + + it("does not strip non-envelope brackets", () => { + expect(stripEnvelope("[OK] hello")).toBe("[OK] hello"); + expect(stripEnvelope("[1/2] step one")).toBe("[1/2] step one"); + }); +}); diff --git a/ui/src/ui/chat/message-extract.ts b/ui/src/ui/chat/message-extract.ts index d08c3258b..0a9874856 100644 --- a/ui/src/ui/chat/message-extract.ts +++ b/ui/src/ui/chat/message-extract.ts @@ -1,11 +1,42 @@ import { stripThinkingTags } from "../format"; +const ENVELOPE_PREFIX = /^\[([^\]]+)\]\s*/; +const ENVELOPE_CHANNELS = [ + "WebChat", + "WhatsApp", + "Telegram", + "Signal", + "Slack", + "Discord", + "iMessage", + "Teams", + "Matrix", + "Zalo", + "Zalo Personal", + "BlueBubbles", +]; + +function looksLikeEnvelopeHeader(header: string): boolean { + if (/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}Z\b/.test(header)) return true; + if (/\d{4}-\d{2}-\d{2} \d{2}:\d{2}\b/.test(header)) return true; + return ENVELOPE_CHANNELS.some((label) => header.startsWith(`${label} `)); +} + +export function stripEnvelope(text: string): string { + const match = text.match(ENVELOPE_PREFIX); + if (!match) return text; + const header = match[1] ?? ""; + if (!looksLikeEnvelopeHeader(header)) return text; + return text.slice(match[0].length); +} + export function extractText(message: unknown): string | null { const m = message as Record; const role = typeof m.role === "string" ? m.role : ""; const content = m.content; if (typeof content === "string") { - return role === "assistant" ? stripThinkingTags(content) : content; + const processed = role === "assistant" ? stripThinkingTags(content) : stripEnvelope(content); + return processed; } if (Array.isArray(content)) { const parts = content @@ -17,11 +48,13 @@ export function extractText(message: unknown): string | null { .filter((v): v is string => typeof v === "string"); if (parts.length > 0) { const joined = parts.join("\n"); - return role === "assistant" ? stripThinkingTags(joined) : joined; + const processed = role === "assistant" ? stripThinkingTags(joined) : stripEnvelope(joined); + return processed; } } if (typeof m.text === "string") { - return role === "assistant" ? stripThinkingTags(m.text) : m.text; + const processed = role === "assistant" ? stripThinkingTags(m.text) : stripEnvelope(m.text); + return processed; } return null; } @@ -83,4 +116,3 @@ export function formatReasoningMarkdown(text: string): string { .map((line) => `_${line}_`); return lines.length ? ["_Reasoning:_", ...lines].join("\n") : ""; } - diff --git a/ui/src/ui/controllers/chat.ts b/ui/src/ui/controllers/chat.ts index 8ea5ad84d..53027c6ea 100644 --- a/ui/src/ui/controllers/chat.ts +++ b/ui/src/ui/controllers/chat.ts @@ -1,5 +1,5 @@ import type { GatewayBrowserClient } from "../gateway"; -import { stripThinkingTags } from "../format"; +import { extractText } from "../chat/message-extract"; import { generateUUID } from "../uuid"; export type ChatState = { @@ -142,29 +142,3 @@ export function handleChatEvent( } return payload.state; } - -function extractText(message: unknown): string | null { - const m = message as Record; - const role = typeof m.role === "string" ? m.role : ""; - const content = m.content; - if (typeof content === "string") { - return role === "assistant" ? stripThinkingTags(content) : content; - } - if (Array.isArray(content)) { - const parts = content - .map((p) => { - const item = p as Record; - if (item.type === "text" && typeof item.text === "string") return item.text; - return null; - }) - .filter((v): v is string => typeof v === "string"); - if (parts.length > 0) { - const joined = parts.join("\n"); - return role === "assistant" ? stripThinkingTags(joined) : joined; - } - } - if (typeof m.text === "string") { - return role === "assistant" ? stripThinkingTags(m.text) : m.text; - } - return null; -} From 7c493261910ae9ec92cf40caef9a38ba41727aee Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 18 Jan 2026 18:50:52 +0000 Subject: [PATCH 07/11] fix: satisfy oxlint spread rule --- src/plugins/slots.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/plugins/slots.ts b/src/plugins/slots.ts index 7762b5ccd..05e06dc5c 100644 --- a/src/plugins/slots.ts +++ b/src/plugins/slots.ts @@ -47,7 +47,7 @@ export function applyExclusiveSlotSelection(params: { const pluginsConfig = params.config.plugins ?? {}; const prevSlot = pluginsConfig.slots?.[slotKey]; const slots = { - ...(pluginsConfig.slots ?? {}), + ...pluginsConfig.slots, [slotKey]: params.selectedId, }; @@ -58,7 +58,7 @@ export function applyExclusiveSlotSelection(params: { ); } - const entries = { ...(pluginsConfig.entries ?? {}) }; + const entries = { ...pluginsConfig.entries }; const disabledIds: string[] = []; if (params.registry) { for (const plugin of params.registry.plugins) { @@ -67,7 +67,7 @@ export function applyExclusiveSlotSelection(params: { const entry = entries[plugin.id]; if (!entry || entry.enabled !== false) { entries[plugin.id] = { - ...(entry ?? {}), + ...entry, enabled: false, }; disabledIds.push(plugin.id); From 7e0bebd6694f05ace102ec67474c56d668c0b81d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 18 Jan 2026 18:51:08 +0000 Subject: [PATCH 08/11] docs: update clawtributors --- README.md | 42 +++++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 21 deletions(-) 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:

- steipete bohdanpodvirnyi joaohlisboa mneves75 MatthieuBizien rahthakor vrknetha joshp123 mukhtharcm maxsumrall - xadenryan Tobias Bischoff juanpablodlc hsrvc magimetal meaningfool NicholasSpisak abhisekbasu1 claude jamesgroat - Hyaxia dantelex daveonkels radek-paclt mteam88 Eng. Juan Combetto dbhurley Mariano Belinky julianengel benithors - timolins nachx639 sreekaransrinath gupsammy cristip73 nachoiacovino Vasanth Rao Naik Sabavat cpojer lc0rp scald - andranik-sahakyan davidguttman sleontenko sircrumpet peschee rafaelreis-r ratulsarna thewilloftheshadow lutr0 danielz1z - gumadeiras emanuelst KristijanJovanovski CashWilliams rdev osolmaz kiranjd adityashaw2 sebslight sheeek - artuskg onutc manuelhettich minghinmatthewlam myfunc buddyh connorshea mcinteerj timkrase gerardward2007 - obviyus tosh-hamburg azade-c roshanasingh4 bjesuiter Josh Phillips YuriNachos zerone0x superman32432432 Yurii Chukhlib - antons austinm911 blacksmith-sh[bot] dan-dr grp06 HeimdallStrategy imfing jalehman jarvis-medmatic kkarimi - mahmoudashraf93 petter-b pkrmf RandyVentures erikpr1994 jonasjancarik Keith the Silly Goose L36 Server Marc mitschabaude-bot - neist tyler6204 chrisrodz Friederike Seiler gabriel-trigo iamadig Kit koala73 manmal ngutman - ogulcancelik pasogott petradonka rubyrunsstuff VACInc wes-davis zats Chris Taylor Django Navarro evalexpr - henrino3 mkbehr oswalpalash pcty-nextgen-service-account Syhids Aaron Konyer adam91holt erik-agens fcatuhe ivanrvpereira - jayhickey jeffersonwarrior jeffersonwarrior Jonathan D. Rhyne (DJ-D) jverdi mickahouan mjrussell p6l-richard philipp-spiess robaxelsen - Sash Catanzarite sibbl VAC zknicker alejandro maza andrewting19 Asleep123 bolismauro cash-echo-bot Clawd - conhecendocontato Dimitrios Ploutarchos Drake Thomsen gtsifrikas HazAT hrdwdmrbl hugobarauna Jamie Openshaw Jarvis Jefferson Nunn - kitze levifig Lloyd longmaba loukotal martinpucik Miles mrdbstn MSch Mustafa Tag Eldeen - ndraiman nexty5870 prathamdby reeltimeapps RLTCmpe Rolf Fredheim Rony Kelner Samrat Jha siraht snopoke - suminhthanh The Admiral thesash Ubuntu voidserf wstock Zach Knickerbocker Alphonse-arianee Azade carlulsoe - ddyo Erik latitudeki5223 Manuel Maly Mourad Boustani pcty-nextgen-ios-builder Quentin Randy Torres rhjoh ronak-guliani - William Stock + steipete bohdanpodvirnyi joaohlisboa mneves75 MatthieuBizien rahthakor vrknetha radek-paclt joshp123 mukhtharcm + maxsumrall xadenryan Tobias Bischoff juanpablodlc hsrvc magimetal meaningfool NicholasSpisak abhisekbasu1 claude + jamesgroat sebslight Hyaxia dantelex daveonkels mteam88 Eng. Juan Combetto dbhurley Mariano Belinky TSavo + julianengel benithors timolins nachx639 sreekaransrinath gupsammy cristip73 nachoiacovino Vasanth Rao Naik Sabavat cpojer + lc0rp scald andranik-sahakyan davidguttman sleontenko sircrumpet peschee rafaelreis-r thewilloftheshadow ratulsarna + lutr0 danielz1z gumadeiras emanuelst KristijanJovanovski CashWilliams rdev osolmaz joshrad-dev kiranjd + adityashaw2 sheeek artuskg onutc manuelhettich minghinmatthewlam myfunc buddyh connorshea mcinteerj + timkrase zerone0x gerardward2007 obviyus tosh-hamburg azade-c roshanasingh4 bjesuiter cheeeee Josh Phillips + YuriNachos tyler6204 superman32432432 Yurii Chukhlib antons austinm911 blacksmith-sh[bot] dan-dr grp06 HeimdallStrategy + imfing jalehman jarvis-medmatic kkarimi mahmoudashraf93 petter-b pkrmf RandyVentures erikpr1994 jonasjancarik + Keith the Silly Goose L36 Server Marc mitschabaude-bot neist ngutman chrisrodz Friederike Seiler gabriel-trigo iamadig + Kit koala73 manmal ogulcancelik pasogott petradonka rubyrunsstuff VACInc wes-davis zats + Chris Taylor Django Navarro evalexpr henrino3 mkbehr oswalpalash pcty-nextgen-service-account sibbl Syhids Aaron Konyer + adam91holt erik-agens fcatuhe ivanrvpereira jayhickey jeffersonwarrior jeffersonwarrior Jonathan D. Rhyne (DJ-D) jverdi mickahouan + mjrussell p6l-richard philipp-spiess robaxelsen Sash Catanzarite VAC zknicker alejandro maza andrewting19 anpoirier + Asleep123 bolismauro cash-echo-bot Clawd conhecendocontato Dimitrios Ploutarchos Drake Thomsen Ghost gtsifrikas HazAT + hrdwdmrbl hugobarauna Jamie Openshaw Jarvis Jefferson Nunn Kevin Lin kitze levifig Lloyd longmaba + loukotal martinpucik Miles mrdbstn MSch Mustafa Tag Eldeen ndraiman nexty5870 prathamdby reeltimeapps + RLTCmpe rodrigouroz Rolf Fredheim Rony Kelner Samrat Jha siraht snopoke suminhthanh The Admiral thesash + Ubuntu voidserf wstock Zach Knickerbocker Alphonse-arianee Azade carlulsoe ddyo Erik latitudeki5223 + Manuel Maly Mourad Boustani pcty-nextgen-ios-builder Quentin Randy Torres rhjoh ronak-guliani William Stock

From c50cde2170b4886608f71de9e1ca4c4e296a3e0f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 18 Jan 2026 18:51:43 +0000 Subject: [PATCH 09/11] fix: clean up lint in gateway tests --- src/commands/onboard-non-interactive.remote.test.ts | 2 +- src/gateway/gateway.wizard.e2e.test.ts | 2 +- src/gateway/server.channels.test.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/commands/onboard-non-interactive.remote.test.ts b/src/commands/onboard-non-interactive.remote.test.ts index bd8d6cc49..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) => { diff --git a/src/gateway/gateway.wizard.e2e.test.ts b/src/gateway/gateway.wizard.e2e.test.ts index b35151ff0..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"; diff --git a/src/gateway/server.channels.test.ts b/src/gateway/server.channels.test.ts index f3a5f6d71..19410d8e3 100644 --- a/src/gateway/server.channels.test.ts +++ b/src/gateway/server.channels.test.ts @@ -74,7 +74,7 @@ const createStubChannelPlugin = (params: { status: { buildChannelSummary: async () => ({ configured: false, - ...(params.summary ?? {}), + ...params.summary, }), }, gateway: { From 3cf92152c346e749ac5f5c536e1704a668eb6579 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 18 Jan 2026 18:54:59 +0000 Subject: [PATCH 10/11] fix: appease tsc in test helpers --- src/gateway/test-helpers.mocks.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/gateway/test-helpers.mocks.ts b/src/gateway/test-helpers.mocks.ts index b98aad469..36c25897b 100644 --- a/src/gateway/test-helpers.mocks.ts +++ b/src/gateway/test-helpers.mocks.ts @@ -281,7 +281,7 @@ vi.mock("../config/config.js", async () => { ? { ...testState.channelsConfig } : {}; const existing = baseChannels.whatsapp; - const mergedWhatsApp = + const mergedWhatsApp: Record = existing && typeof existing === "object" && !Array.isArray(existing) ? { ...existing } : {}; From e17cb408a59c42b66b58d5fe8e4e4daf64a03b66 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 18 Jan 2026 18:56:34 +0000 Subject: [PATCH 11/11] fix(cli): load pairing channels after plugins --- src/cli/pairing-cli.ts | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/src/cli/pairing-cli.ts b/src/cli/pairing-cli.ts index cde8c6534..1582accc3 100644 --- a/src/cli/pairing-cli.ts +++ b/src/cli/pairing-cli.ts @@ -11,10 +11,8 @@ import { import { formatDocsLink } from "../terminal/links.js"; import { theme } from "../terminal/theme.js"; -const CHANNELS: PairingChannel[] = listPairingChannels(); - /** Parse channel, allowing extension channels not in core registry. */ -function parseChannel(raw: unknown): PairingChannel { +function parseChannel(raw: unknown, channels: PairingChannel[]): PairingChannel { const value = ( typeof raw === "string" ? raw @@ -28,7 +26,7 @@ function parseChannel(raw: unknown): PairingChannel { const normalized = normalizeChannelId(value); if (normalized) { - if (!CHANNELS.includes(normalized as PairingChannel)) { + if (!channels.includes(normalized as PairingChannel)) { throw new Error(`Channel ${normalized} does not support pairing`); } return normalized as PairingChannel; @@ -45,6 +43,7 @@ async function notifyApproved(channel: PairingChannel, id: string) { } export function registerPairingCli(program: Command) { + const channels = listPairingChannels(); const pairing = program .command("pairing") .description("Secure DM pairing (approve inbound requests)") @@ -57,17 +56,17 @@ export function registerPairingCli(program: Command) { pairing .command("list") .description("List pending pairing requests") - .option("--channel ", `Channel (${CHANNELS.join(", ")})`) - .argument("[channel]", `Channel (${CHANNELS.join(", ")})`) + .option("--channel ", `Channel (${channels.join(", ")})`) + .argument("[channel]", `Channel (${channels.join(", ")})`) .option("--json", "Print JSON", false) .action(async (channelArg, opts) => { const channelRaw = opts.channel ?? channelArg; if (!channelRaw) { throw new Error( - `Channel required. Use --channel or pass it as the first argument (expected one of: ${CHANNELS.join(", ")})`, + `Channel required. Use --channel or pass it as the first argument (expected one of: ${channels.join(", ")})`, ); } - const channel = parseChannel(channelRaw); + const channel = parseChannel(channelRaw, channels); const requests = await listChannelPairingRequests(channel); if (opts.json) { console.log(JSON.stringify({ channel, requests }, null, 2)); @@ -87,7 +86,7 @@ export function registerPairingCli(program: Command) { pairing .command("approve") .description("Approve a pairing code and allow that sender") - .option("--channel ", `Channel (${CHANNELS.join(", ")})`) + .option("--channel ", `Channel (${channels.join(", ")})`) .argument("", "Pairing code (or channel when using 2 args)") .argument("[code]", "Pairing code (when channel is passed as the 1st arg)") .option("--notify", "Notify the requester on the same channel", false) @@ -104,7 +103,7 @@ export function registerPairingCli(program: Command) { `Too many arguments. Use: clawdbot pairing approve --channel `, ); } - const channel = parseChannel(channelRaw); + const channel = parseChannel(channelRaw, channels); const approved = await approveChannelPairingCode({ channel, code: String(resolvedCode),