refactor(src): split oversized modules

This commit is contained in:
Peter Steinberger
2026-01-14 01:08:15 +00:00
parent b2179de839
commit bcbfb357be
675 changed files with 91476 additions and 73453 deletions
BIN
View File
Binary file not shown.
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, it, vi } from "vitest";
describe("broadcast", () => {
it("accepts a broadcast peer map with strategy", async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({
agents: {
list: [{ id: "alfred" }, { id: "baerbel" }],
},
broadcast: {
strategy: "parallel",
"120363403215116621@g.us": ["alfred", "baerbel"],
},
});
expect(res.ok).toBe(true);
});
it("rejects invalid broadcast strategy", async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({
broadcast: { strategy: "nope" },
});
expect(res.ok).toBe(false);
});
it("rejects non-array broadcast entries", async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({
broadcast: { "120363403215116621@g.us": 123 },
});
expect(res.ok).toBe(false);
});
});
@@ -0,0 +1,56 @@
import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { withTempHome } from "./test-helpers.js";
describe("config compaction settings", () => {
it("preserves memory flush config values", async () => {
await withTempHome(async (home) => {
const configDir = path.join(home, ".clawdbot");
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(
path.join(configDir, "clawdbot.json"),
JSON.stringify(
{
agents: {
defaults: {
compaction: {
mode: "safeguard",
reserveTokensFloor: 12_345,
memoryFlush: {
enabled: false,
softThresholdTokens: 1234,
prompt: "Write notes.",
systemPrompt: "Flush memory now.",
},
},
},
},
},
null,
2,
),
"utf-8",
);
vi.resetModules();
const { loadConfig } = await import("./config.js");
const cfg = loadConfig();
expect(cfg.agents?.defaults?.compaction?.reserveTokensFloor).toBe(12_345);
expect(cfg.agents?.defaults?.compaction?.mode).toBe("safeguard");
expect(cfg.agents?.defaults?.compaction?.memoryFlush?.enabled).toBe(
false,
);
expect(
cfg.agents?.defaults?.compaction?.memoryFlush?.softThresholdTokens,
).toBe(1234);
expect(cfg.agents?.defaults?.compaction?.memoryFlush?.prompt).toBe(
"Write notes.",
);
expect(cfg.agents?.defaults?.compaction?.memoryFlush?.systemPrompt).toBe(
"Flush memory now.",
);
});
});
});
+68
View File
@@ -0,0 +1,68 @@
import fs from "node:fs/promises";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { withTempHome } from "./test-helpers.js";
describe("config discord", () => {
let previousHome: string | undefined;
beforeEach(() => {
previousHome = process.env.HOME;
});
afterEach(() => {
process.env.HOME = previousHome;
});
it("loads discord guild map + dm group settings", async () => {
await withTempHome(async (home) => {
const configDir = path.join(home, ".clawdbot");
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(
path.join(configDir, "clawdbot.json"),
JSON.stringify(
{
channels: {
discord: {
enabled: true,
dm: {
enabled: true,
allowFrom: ["steipete"],
groupEnabled: true,
groupChannels: ["clawd-dm"],
},
guilds: {
"123": {
slug: "friends-of-clawd",
requireMention: false,
users: ["steipete"],
channels: {
general: { allow: true },
},
},
},
},
},
},
null,
2,
),
"utf-8",
);
vi.resetModules();
const { loadConfig } = await import("./config.js");
const cfg = loadConfig();
expect(cfg.channels?.discord?.enabled).toBe(true);
expect(cfg.channels?.discord?.dm?.groupEnabled).toBe(true);
expect(cfg.channels?.discord?.dm?.groupChannels).toEqual(["clawd-dm"]);
expect(cfg.channels?.discord?.guilds?.["123"]?.slug).toBe(
"friends-of-clawd",
);
expect(
cfg.channels?.discord?.guilds?.["123"]?.channels?.general?.allow,
).toBe(true);
});
});
});
+81
View File
@@ -0,0 +1,81 @@
import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { withEnvOverride, withTempHome } from "./test-helpers.js";
describe("config env vars", () => {
it("applies env vars from env block when missing", async () => {
await withTempHome(async (home) => {
const configDir = path.join(home, ".clawdbot");
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(
path.join(configDir, "clawdbot.json"),
JSON.stringify(
{
env: { OPENROUTER_API_KEY: "config-key" },
},
null,
2,
),
"utf-8",
);
await withEnvOverride({ OPENROUTER_API_KEY: undefined }, async () => {
const { loadConfig } = await import("./config.js");
loadConfig();
expect(process.env.OPENROUTER_API_KEY).toBe("config-key");
});
});
});
it("does not override existing env vars", async () => {
await withTempHome(async (home) => {
const configDir = path.join(home, ".clawdbot");
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(
path.join(configDir, "clawdbot.json"),
JSON.stringify(
{
env: { OPENROUTER_API_KEY: "config-key" },
},
null,
2,
),
"utf-8",
);
await withEnvOverride(
{ OPENROUTER_API_KEY: "existing-key" },
async () => {
const { loadConfig } = await import("./config.js");
loadConfig();
expect(process.env.OPENROUTER_API_KEY).toBe("existing-key");
},
);
});
});
it("applies env vars from env.vars when missing", async () => {
await withTempHome(async (home) => {
const configDir = path.join(home, ".clawdbot");
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(
path.join(configDir, "clawdbot.json"),
JSON.stringify(
{
env: { vars: { GROQ_API_KEY: "gsk-config" } },
},
null,
2,
),
"utf-8",
);
await withEnvOverride({ GROQ_API_KEY: undefined }, async () => {
const { loadConfig } = await import("./config.js");
loadConfig();
expect(process.env.GROQ_API_KEY).toBe("gsk-config");
});
});
});
});
+356
View File
@@ -0,0 +1,356 @@
import fs from "node:fs/promises";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { withTempHome } from "./test-helpers.js";
describe("config identity defaults", () => {
let previousHome: string | undefined;
beforeEach(() => {
previousHome = process.env.HOME;
});
afterEach(() => {
process.env.HOME = previousHome;
});
it("does not derive mentionPatterns when identity is set", async () => {
await withTempHome(async (home) => {
const configDir = path.join(home, ".clawdbot");
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(
path.join(configDir, "clawdbot.json"),
JSON.stringify(
{
agents: {
list: [
{
id: "main",
identity: {
name: "Samantha",
theme: "helpful sloth",
emoji: "🦥",
},
},
],
},
messages: {},
},
null,
2,
),
"utf-8",
);
vi.resetModules();
const { loadConfig } = await import("./config.js");
const cfg = loadConfig();
expect(cfg.messages?.responsePrefix).toBeUndefined();
expect(cfg.messages?.groupChat?.mentionPatterns).toBeUndefined();
});
});
it("defaults ackReactionScope without setting ackReaction", async () => {
await withTempHome(async (home) => {
const configDir = path.join(home, ".clawdbot");
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(
path.join(configDir, "clawdbot.json"),
JSON.stringify(
{
agents: {
list: [
{
id: "main",
identity: {
name: "Samantha",
theme: "helpful sloth",
emoji: "🦥",
},
},
],
},
messages: {},
},
null,
2,
),
"utf-8",
);
vi.resetModules();
const { loadConfig } = await import("./config.js");
const cfg = loadConfig();
expect(cfg.messages?.ackReaction).toBeUndefined();
expect(cfg.messages?.ackReactionScope).toBe("group-mentions");
});
});
it("keeps ackReaction unset when identity is missing", async () => {
await withTempHome(async (home) => {
const configDir = path.join(home, ".clawdbot");
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(
path.join(configDir, "clawdbot.json"),
JSON.stringify(
{
messages: {},
},
null,
2,
),
"utf-8",
);
vi.resetModules();
const { loadConfig } = await import("./config.js");
const cfg = loadConfig();
expect(cfg.messages?.ackReaction).toBeUndefined();
expect(cfg.messages?.ackReactionScope).toBe("group-mentions");
});
});
it("does not override explicit values", async () => {
await withTempHome(async (home) => {
const configDir = path.join(home, ".clawdbot");
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(
path.join(configDir, "clawdbot.json"),
JSON.stringify(
{
agents: {
list: [
{
id: "main",
identity: {
name: "Samantha Sloth",
theme: "space lobster",
emoji: "🦞",
},
groupChat: { mentionPatterns: ["@clawd"] },
},
],
},
messages: {
responsePrefix: "✅",
},
},
null,
2,
),
"utf-8",
);
vi.resetModules();
const { loadConfig } = await import("./config.js");
const cfg = loadConfig();
expect(cfg.messages?.responsePrefix).toBe("✅");
expect(cfg.agents?.list?.[0]?.groupChat?.mentionPatterns).toEqual([
"@clawd",
]);
});
});
it("supports provider textChunkLimit config", async () => {
await withTempHome(async (home) => {
const configDir = path.join(home, ".clawdbot");
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(
path.join(configDir, "clawdbot.json"),
JSON.stringify(
{
messages: {
messagePrefix: "[clawdbot]",
responsePrefix: "🦞",
// legacy field should be ignored (moved to providers)
textChunkLimit: 9999,
},
channels: {
whatsapp: { allowFrom: ["+15555550123"], textChunkLimit: 4444 },
telegram: { enabled: true, textChunkLimit: 3333 },
discord: {
enabled: true,
textChunkLimit: 1999,
maxLinesPerMessage: 17,
},
signal: { enabled: true, textChunkLimit: 2222 },
imessage: { enabled: true, textChunkLimit: 1111 },
},
},
null,
2,
),
"utf-8",
);
vi.resetModules();
const { loadConfig } = await import("./config.js");
const cfg = loadConfig();
expect(cfg.channels?.whatsapp?.textChunkLimit).toBe(4444);
expect(cfg.channels?.telegram?.textChunkLimit).toBe(3333);
expect(cfg.channels?.discord?.textChunkLimit).toBe(1999);
expect(cfg.channels?.discord?.maxLinesPerMessage).toBe(17);
expect(cfg.channels?.signal?.textChunkLimit).toBe(2222);
expect(cfg.channels?.imessage?.textChunkLimit).toBe(1111);
const legacy = (cfg.messages as unknown as Record<string, unknown>)
.textChunkLimit;
expect(legacy).toBeUndefined();
});
});
it("accepts blank model provider apiKey values", async () => {
await withTempHome(async (home) => {
const configDir = path.join(home, ".clawdbot");
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(
path.join(configDir, "clawdbot.json"),
JSON.stringify(
{
models: {
mode: "merge",
providers: {
minimax: {
baseUrl: "https://api.minimax.io/anthropic",
apiKey: "",
api: "anthropic-messages",
models: [
{
id: "MiniMax-M2.1",
name: "MiniMax M2.1",
reasoning: false,
input: ["text"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 200000,
maxTokens: 8192,
},
],
},
},
},
},
null,
2,
),
"utf-8",
);
vi.resetModules();
const { loadConfig } = await import("./config.js");
const cfg = loadConfig();
expect(cfg.models?.providers?.minimax?.baseUrl).toBe(
"https://api.minimax.io/anthropic",
);
});
});
it("respects empty responsePrefix to disable identity defaults", async () => {
await withTempHome(async (home) => {
const configDir = path.join(home, ".clawdbot");
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(
path.join(configDir, "clawdbot.json"),
JSON.stringify(
{
agents: {
list: [
{
id: "main",
identity: {
name: "Samantha",
theme: "helpful sloth",
emoji: "🦥",
},
},
],
},
messages: { responsePrefix: "" },
},
null,
2,
),
"utf-8",
);
vi.resetModules();
const { loadConfig } = await import("./config.js");
const cfg = loadConfig();
expect(cfg.messages?.responsePrefix).toBe("");
});
});
it("does not synthesize agent/session when absent", async () => {
await withTempHome(async (home) => {
const configDir = path.join(home, ".clawdbot");
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(
path.join(configDir, "clawdbot.json"),
JSON.stringify(
{
messages: {},
},
null,
2,
),
"utf-8",
);
vi.resetModules();
const { loadConfig } = await import("./config.js");
const cfg = loadConfig();
expect(cfg.messages?.responsePrefix).toBeUndefined();
expect(cfg.messages?.groupChat?.mentionPatterns).toBeUndefined();
expect(cfg.agents).toBeUndefined();
expect(cfg.session).toBeUndefined();
});
});
it("does not derive responsePrefix from identity emoji", async () => {
await withTempHome(async (home) => {
const configDir = path.join(home, ".clawdbot");
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(
path.join(configDir, "clawdbot.json"),
JSON.stringify(
{
agents: {
list: [
{
id: "main",
identity: {
name: "Clawd",
theme: "space lobster",
emoji: "🦞",
},
},
],
},
messages: {},
},
null,
2,
),
"utf-8",
);
vi.resetModules();
const { loadConfig } = await import("./config.js");
const cfg = loadConfig();
expect(cfg.messages?.responsePrefix).toBeUndefined();
});
});
});
@@ -0,0 +1,415 @@
import { describe, expect, it, vi } from "vitest";
describe("legacy config detection", () => {
it("rejects routing.allowFrom", async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({
routing: { allowFrom: ["+15555550123"] },
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.issues[0]?.path).toBe("routing.allowFrom");
}
});
it("rejects routing.groupChat.requireMention", async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({
routing: { groupChat: { requireMention: false } },
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.issues[0]?.path).toBe("routing.groupChat.requireMention");
}
});
it("migrates routing.allowFrom to channels.whatsapp.allowFrom", async () => {
vi.resetModules();
const { migrateLegacyConfig } = await import("./config.js");
const res = migrateLegacyConfig({
routing: { allowFrom: ["+15555550123"] },
});
expect(res.changes).toContain(
"Moved routing.allowFrom → channels.whatsapp.allowFrom.",
);
expect(res.config?.channels?.whatsapp?.allowFrom).toEqual(["+15555550123"]);
expect(res.config?.routing?.allowFrom).toBeUndefined();
});
it("migrates routing.groupChat.requireMention to channels whatsapp/telegram/imessage groups", async () => {
vi.resetModules();
const { migrateLegacyConfig } = await import("./config.js");
const res = migrateLegacyConfig({
routing: { groupChat: { requireMention: false } },
});
expect(res.changes).toContain(
'Moved routing.groupChat.requireMention → channels.whatsapp.groups."*".requireMention.',
);
expect(res.changes).toContain(
'Moved routing.groupChat.requireMention → channels.telegram.groups."*".requireMention.',
);
expect(res.changes).toContain(
'Moved routing.groupChat.requireMention → channels.imessage.groups."*".requireMention.',
);
expect(res.config?.channels?.whatsapp?.groups?.["*"]?.requireMention).toBe(
false,
);
expect(res.config?.channels?.telegram?.groups?.["*"]?.requireMention).toBe(
false,
);
expect(res.config?.channels?.imessage?.groups?.["*"]?.requireMention).toBe(
false,
);
expect(res.config?.routing?.groupChat?.requireMention).toBeUndefined();
});
it("migrates routing.groupChat.mentionPatterns to messages.groupChat.mentionPatterns", async () => {
vi.resetModules();
const { migrateLegacyConfig } = await import("./config.js");
const res = migrateLegacyConfig({
routing: { groupChat: { mentionPatterns: ["@clawd"] } },
});
expect(res.changes).toContain(
"Moved routing.groupChat.mentionPatterns → messages.groupChat.mentionPatterns.",
);
expect(res.config?.messages?.groupChat?.mentionPatterns).toEqual([
"@clawd",
]);
expect(res.config?.routing?.groupChat?.mentionPatterns).toBeUndefined();
});
it("migrates routing agentToAgent/queue/transcribeAudio to tools/messages/audio", async () => {
vi.resetModules();
const { migrateLegacyConfig } = await import("./config.js");
const res = migrateLegacyConfig({
routing: {
agentToAgent: { enabled: true, allow: ["main"] },
queue: { mode: "queue", cap: 3 },
transcribeAudio: {
command: ["whisper", "--model", "base"],
timeoutSeconds: 2,
},
},
});
expect(res.changes).toContain(
"Moved routing.agentToAgent → tools.agentToAgent.",
);
expect(res.changes).toContain("Moved routing.queue → messages.queue.");
expect(res.changes).toContain(
"Moved routing.transcribeAudio → tools.audio.transcription.",
);
expect(res.config?.tools?.agentToAgent).toEqual({
enabled: true,
allow: ["main"],
});
expect(res.config?.messages?.queue).toEqual({
mode: "queue",
cap: 3,
});
expect(res.config?.tools?.audio?.transcription).toEqual({
args: ["--model", "base"],
timeoutSeconds: 2,
});
expect(res.config?.routing).toBeUndefined();
});
it("migrates agent config into agents.defaults and tools", async () => {
vi.resetModules();
const { migrateLegacyConfig } = await import("./config.js");
const res = migrateLegacyConfig({
agent: {
model: "openai/gpt-5.2",
tools: { allow: ["sessions.list"], deny: ["danger"] },
elevated: { enabled: true, allowFrom: { discord: ["user:1"] } },
bash: { timeoutSec: 12 },
sandbox: { tools: { allow: ["browser.open"] } },
subagents: { tools: { deny: ["sandbox"] } },
},
});
expect(res.changes).toContain("Moved agent.tools.allow → tools.allow.");
expect(res.changes).toContain("Moved agent.tools.deny → tools.deny.");
expect(res.changes).toContain("Moved agent.elevated → tools.elevated.");
expect(res.changes).toContain("Moved agent.bash → tools.exec.");
expect(res.changes).toContain(
"Moved agent.sandbox.tools → tools.sandbox.tools.",
);
expect(res.changes).toContain(
"Moved agent.subagents.tools → tools.subagents.tools.",
);
expect(res.changes).toContain("Moved agent → agents.defaults.");
expect(res.config?.agents?.defaults?.model).toEqual({
primary: "openai/gpt-5.2",
fallbacks: [],
});
expect(res.config?.tools?.allow).toEqual(["sessions.list"]);
expect(res.config?.tools?.deny).toEqual(["danger"]);
expect(res.config?.tools?.elevated).toEqual({
enabled: true,
allowFrom: { discord: ["user:1"] },
});
expect(res.config?.tools?.exec).toEqual({ timeoutSec: 12 });
expect(res.config?.tools?.sandbox?.tools).toEqual({
allow: ["browser.open"],
});
expect(res.config?.tools?.subagents?.tools).toEqual({
deny: ["sandbox"],
});
expect((res.config as { agent?: unknown }).agent).toBeUndefined();
});
it("accepts per-agent tools.elevated overrides", async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({
tools: {
elevated: {
allowFrom: { whatsapp: ["+15555550123"] },
},
},
agents: {
list: [
{
id: "work",
workspace: "~/clawd-work",
tools: {
elevated: {
enabled: false,
allowFrom: { whatsapp: ["+15555550123"] },
},
},
},
],
},
});
expect(res.ok).toBe(true);
if (res.ok) {
expect(res.config?.agents?.list?.[0]?.tools?.elevated).toEqual({
enabled: false,
allowFrom: { whatsapp: ["+15555550123"] },
});
}
});
it("rejects telegram.requireMention", async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({
telegram: { requireMention: true },
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(
res.issues.some((issue) => issue.path === "telegram.requireMention"),
).toBe(true);
}
});
it("rejects gateway.token", async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({
gateway: { token: "legacy-token" },
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.issues[0]?.path).toBe("gateway.token");
}
});
it("migrates gateway.token to gateway.auth.token", async () => {
vi.resetModules();
const { migrateLegacyConfig } = await import("./config.js");
const res = migrateLegacyConfig({
gateway: { token: "legacy-token" },
});
expect(res.changes).toContain("Moved gateway.token → gateway.auth.token.");
expect(res.config?.gateway?.auth?.token).toBe("legacy-token");
expect(res.config?.gateway?.auth?.mode).toBe("token");
expect((res.config?.gateway as { token?: string })?.token).toBeUndefined();
});
it("migrates gateway.bind and bridge.bind from 'tailnet' to 'auto'", async () => {
vi.resetModules();
const { migrateLegacyConfig } = await import("./config.js");
const res = migrateLegacyConfig({
gateway: { bind: "tailnet" as const },
bridge: { bind: "tailnet" as const },
});
expect(res.changes).toContain(
"Migrated gateway.bind from 'tailnet' to 'auto'.",
);
expect(res.changes).toContain(
"Migrated bridge.bind from 'tailnet' to 'auto'.",
);
expect(res.config?.gateway?.bind).toBe("auto");
expect(res.config?.bridge?.bind).toBe("auto");
});
it('rejects telegram.dmPolicy="open" without allowFrom "*"', async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({
channels: { telegram: { dmPolicy: "open", allowFrom: ["123456789"] } },
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.issues[0]?.path).toBe("channels.telegram.allowFrom");
}
});
it('accepts telegram.dmPolicy="open" with allowFrom "*"', async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({
channels: { telegram: { dmPolicy: "open", allowFrom: ["*"] } },
});
expect(res.ok).toBe(true);
if (res.ok) {
expect(res.config.channels?.telegram?.dmPolicy).toBe("open");
}
});
it("defaults telegram.dmPolicy to pairing when telegram section exists", async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({ channels: { telegram: {} } });
expect(res.ok).toBe(true);
if (res.ok) {
expect(res.config.channels?.telegram?.dmPolicy).toBe("pairing");
}
});
it("defaults telegram.groupPolicy to allowlist when telegram section exists", async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({ channels: { telegram: {} } });
expect(res.ok).toBe(true);
if (res.ok) {
expect(res.config.channels?.telegram?.groupPolicy).toBe("allowlist");
}
});
it("defaults telegram.streamMode to partial when telegram section exists", async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({ channels: { telegram: {} } });
expect(res.ok).toBe(true);
if (res.ok) {
expect(res.config.channels?.telegram?.streamMode).toBe("partial");
}
});
it('rejects whatsapp.dmPolicy="open" without allowFrom "*"', async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({
channels: {
whatsapp: { dmPolicy: "open", allowFrom: ["+15555550123"] },
},
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.issues[0]?.path).toBe("channels.whatsapp.allowFrom");
}
});
it('accepts whatsapp.dmPolicy="open" with allowFrom "*"', async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({
channels: { whatsapp: { dmPolicy: "open", allowFrom: ["*"] } },
});
expect(res.ok).toBe(true);
if (res.ok) {
expect(res.config.channels?.whatsapp?.dmPolicy).toBe("open");
}
});
it("defaults whatsapp.dmPolicy to pairing when whatsapp section exists", async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({ channels: { whatsapp: {} } });
expect(res.ok).toBe(true);
if (res.ok) {
expect(res.config.channels?.whatsapp?.dmPolicy).toBe("pairing");
}
});
it("defaults whatsapp.groupPolicy to allowlist when whatsapp section exists", async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({ channels: { whatsapp: {} } });
expect(res.ok).toBe(true);
if (res.ok) {
expect(res.config.channels?.whatsapp?.groupPolicy).toBe("allowlist");
}
});
it('rejects signal.dmPolicy="open" without allowFrom "*"', async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({
channels: { signal: { dmPolicy: "open", allowFrom: ["+15555550123"] } },
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.issues[0]?.path).toBe("channels.signal.allowFrom");
}
});
it('accepts signal.dmPolicy="open" with allowFrom "*"', async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({
channels: { signal: { dmPolicy: "open", allowFrom: ["*"] } },
});
expect(res.ok).toBe(true);
if (res.ok) {
expect(res.config.channels?.signal?.dmPolicy).toBe("open");
}
});
it("defaults signal.dmPolicy to pairing when signal section exists", async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({ channels: { signal: {} } });
expect(res.ok).toBe(true);
if (res.ok) {
expect(res.config.channels?.signal?.dmPolicy).toBe("pairing");
}
});
it("defaults signal.groupPolicy to allowlist when signal section exists", async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({ channels: { signal: {} } });
expect(res.ok).toBe(true);
if (res.ok) {
expect(res.config.channels?.signal?.groupPolicy).toBe("allowlist");
}
});
it("accepts historyLimit overrides per provider and account", async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({
messages: { groupChat: { historyLimit: 12 } },
channels: {
whatsapp: { historyLimit: 9, accounts: { work: { historyLimit: 4 } } },
telegram: { historyLimit: 8, accounts: { ops: { historyLimit: 3 } } },
slack: { historyLimit: 7, accounts: { ops: { historyLimit: 2 } } },
signal: { historyLimit: 6 },
imessage: { historyLimit: 5 },
msteams: { historyLimit: 4 },
discord: { historyLimit: 3 },
},
});
expect(res.ok).toBe(true);
if (res.ok) {
expect(res.config.channels?.whatsapp?.historyLimit).toBe(9);
expect(res.config.channels?.whatsapp?.accounts?.work?.historyLimit).toBe(
4,
);
expect(res.config.channels?.telegram?.historyLimit).toBe(8);
expect(res.config.channels?.telegram?.accounts?.ops?.historyLimit).toBe(
3,
);
expect(res.config.channels?.slack?.historyLimit).toBe(7);
expect(res.config.channels?.slack?.accounts?.ops?.historyLimit).toBe(2);
expect(res.config.channels?.signal?.historyLimit).toBe(6);
expect(res.config.channels?.imessage?.historyLimit).toBe(5);
expect(res.config.channels?.msteams?.historyLimit).toBe(4);
expect(res.config.channels?.discord?.historyLimit).toBe(3);
}
});
it('rejects imessage.dmPolicy="open" without allowFrom "*"', async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({
channels: {
imessage: { dmPolicy: "open", allowFrom: ["+15555550123"] },
},
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.issues[0]?.path).toBe("channels.imessage.allowFrom");
}
});
});
@@ -0,0 +1,416 @@
import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { withTempHome } from "./test-helpers.js";
describe("legacy config detection", () => {
it('accepts imessage.dmPolicy="open" with allowFrom "*"', async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({
channels: { imessage: { dmPolicy: "open", allowFrom: ["*"] } },
});
expect(res.ok).toBe(true);
if (res.ok) {
expect(res.config.channels?.imessage?.dmPolicy).toBe("open");
}
});
it("defaults imessage.dmPolicy to pairing when imessage section exists", async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({ channels: { imessage: {} } });
expect(res.ok).toBe(true);
if (res.ok) {
expect(res.config.channels?.imessage?.dmPolicy).toBe("pairing");
}
});
it("defaults imessage.groupPolicy to allowlist when imessage section exists", async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({ channels: { imessage: {} } });
expect(res.ok).toBe(true);
if (res.ok) {
expect(res.config.channels?.imessage?.groupPolicy).toBe("allowlist");
}
});
it("defaults discord.groupPolicy to allowlist when discord section exists", async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({ channels: { discord: {} } });
expect(res.ok).toBe(true);
if (res.ok) {
expect(res.config.channels?.discord?.groupPolicy).toBe("allowlist");
}
});
it("defaults slack.groupPolicy to allowlist when slack section exists", async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({ channels: { slack: {} } });
expect(res.ok).toBe(true);
if (res.ok) {
expect(res.config.channels?.slack?.groupPolicy).toBe("allowlist");
}
});
it("defaults msteams.groupPolicy to allowlist when msteams section exists", async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({ channels: { msteams: {} } });
expect(res.ok).toBe(true);
if (res.ok) {
expect(res.config.channels?.msteams?.groupPolicy).toBe("allowlist");
}
});
it("rejects unsafe executable config values", async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({
channels: { imessage: { cliPath: "imsg; rm -rf /" } },
tools: { audio: { transcription: { args: ["--model", "base"] } } },
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(
res.issues.some((i) => i.path === "channels.imessage.cliPath"),
).toBe(true);
}
});
it("accepts tools audio transcription without cli", async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({
tools: { audio: { transcription: { args: ["--model", "base"] } } },
});
expect(res.ok).toBe(true);
});
it("accepts path-like executable values with spaces", async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({
channels: { imessage: { cliPath: "/Applications/Imsg Tools/imsg" } },
tools: {
audio: {
transcription: {
args: ["--model"],
},
},
},
});
expect(res.ok).toBe(true);
});
it('rejects discord.dm.policy="open" without allowFrom "*"', async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({
channels: { discord: { dm: { policy: "open", allowFrom: ["123"] } } },
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.issues[0]?.path).toBe("channels.discord.dm.allowFrom");
}
});
it('rejects slack.dm.policy="open" without allowFrom "*"', async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({
channels: { slack: { dm: { policy: "open", allowFrom: ["U123"] } } },
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.issues[0]?.path).toBe("channels.slack.dm.allowFrom");
}
});
it("rejects legacy agent.model string", async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({
agent: { model: "anthropic/claude-opus-4-5" },
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.issues.some((i) => i.path === "agent.model")).toBe(true);
}
});
it("migrates telegram.requireMention to channels.telegram.groups.*.requireMention", async () => {
vi.resetModules();
const { migrateLegacyConfig } = await import("./config.js");
const res = migrateLegacyConfig({
telegram: { requireMention: false },
});
expect(res.changes).toContain(
'Moved telegram.requireMention → channels.telegram.groups."*".requireMention.',
);
expect(res.config?.channels?.telegram?.groups?.["*"]?.requireMention).toBe(
false,
);
expect(res.config?.channels?.telegram?.requireMention).toBeUndefined();
});
it("migrates legacy model config to agent.models + model lists", async () => {
vi.resetModules();
const { migrateLegacyConfig } = await import("./config.js");
const res = migrateLegacyConfig({
agent: {
model: "anthropic/claude-opus-4-5",
modelFallbacks: ["openai/gpt-4.1-mini"],
imageModel: "openai/gpt-4.1-mini",
imageModelFallbacks: ["anthropic/claude-opus-4-5"],
allowedModels: ["anthropic/claude-opus-4-5", "openai/gpt-4.1-mini"],
modelAliases: { Opus: "anthropic/claude-opus-4-5" },
},
});
expect(res.config?.agents?.defaults?.model?.primary).toBe(
"anthropic/claude-opus-4-5",
);
expect(res.config?.agents?.defaults?.model?.fallbacks).toEqual([
"openai/gpt-4.1-mini",
]);
expect(res.config?.agents?.defaults?.imageModel?.primary).toBe(
"openai/gpt-4.1-mini",
);
expect(res.config?.agents?.defaults?.imageModel?.fallbacks).toEqual([
"anthropic/claude-opus-4-5",
]);
expect(
res.config?.agents?.defaults?.models?.["anthropic/claude-opus-4-5"],
).toMatchObject({ alias: "Opus" });
expect(
res.config?.agents?.defaults?.models?.["openai/gpt-4.1-mini"],
).toBeTruthy();
expect(res.config?.agent).toBeUndefined();
});
it("auto-migrates legacy config in snapshot (no legacyIssues)", async () => {
await withTempHome(async (home) => {
const configPath = path.join(home, ".clawdbot", "clawdbot.json");
await fs.mkdir(path.dirname(configPath), { recursive: true });
await fs.writeFile(
configPath,
JSON.stringify({ routing: { allowFrom: ["+15555550123"] } }),
"utf-8",
);
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
vi.resetModules();
try {
const { readConfigFileSnapshot } = await import("./config.js");
const snap = await readConfigFileSnapshot();
expect(snap.valid).toBe(true);
expect(snap.legacyIssues.length).toBe(0);
const raw = await fs.readFile(configPath, "utf-8");
const parsed = JSON.parse(raw) as {
channels?: { whatsapp?: { allowFrom?: string[] } };
routing?: unknown;
};
expect(parsed.channels?.whatsapp?.allowFrom).toEqual(["+15555550123"]);
expect(parsed.routing).toBeUndefined();
expect(
warnSpy.mock.calls.some(([msg]) =>
String(msg).includes("Auto-migrated config"),
),
).toBe(true);
} finally {
warnSpy.mockRestore();
}
});
});
it("auto-migrates legacy provider sections on load and writes back", async () => {
await withTempHome(async (home) => {
const configPath = path.join(home, ".clawdbot", "clawdbot.json");
await fs.mkdir(path.dirname(configPath), { recursive: true });
await fs.writeFile(
configPath,
JSON.stringify({ whatsapp: { allowFrom: ["+1555"] } }, null, 2),
"utf-8",
);
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
vi.resetModules();
try {
const { loadConfig } = await import("./config.js");
const cfg = loadConfig();
expect(cfg.channels?.whatsapp?.allowFrom).toEqual(["+1555"]);
const raw = await fs.readFile(configPath, "utf-8");
const parsed = JSON.parse(raw) as {
channels?: { whatsapp?: { allowFrom?: string[] } };
whatsapp?: unknown;
};
expect(parsed.channels?.whatsapp?.allowFrom).toEqual(["+1555"]);
expect(parsed.whatsapp).toBeUndefined();
expect(
warnSpy.mock.calls.some(([msg]) =>
String(msg).includes("Auto-migrated config"),
),
).toBe(true);
} finally {
warnSpy.mockRestore();
}
});
});
it("auto-migrates routing.allowFrom on load and writes back", async () => {
await withTempHome(async (home) => {
const configPath = path.join(home, ".clawdbot", "clawdbot.json");
await fs.mkdir(path.dirname(configPath), { recursive: true });
await fs.writeFile(
configPath,
JSON.stringify({ routing: { allowFrom: ["+1666"] } }, null, 2),
"utf-8",
);
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
vi.resetModules();
try {
const { loadConfig } = await import("./config.js");
const cfg = loadConfig();
expect(cfg.channels?.whatsapp?.allowFrom).toEqual(["+1666"]);
const raw = await fs.readFile(configPath, "utf-8");
const parsed = JSON.parse(raw) as {
channels?: { whatsapp?: { allowFrom?: string[] } };
routing?: unknown;
};
expect(parsed.channels?.whatsapp?.allowFrom).toEqual(["+1666"]);
expect(parsed.routing).toBeUndefined();
} finally {
warnSpy.mockRestore();
}
});
});
it("auto-migrates bindings[].match.provider on load and writes back", async () => {
await withTempHome(async (home) => {
const configPath = path.join(home, ".clawdbot", "clawdbot.json");
await fs.mkdir(path.dirname(configPath), { recursive: true });
await fs.writeFile(
configPath,
JSON.stringify(
{
bindings: [{ agentId: "main", match: { provider: "slack" } }],
},
null,
2,
),
"utf-8",
);
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
vi.resetModules();
try {
const { loadConfig } = await import("./config.js");
const cfg = loadConfig();
expect(cfg.bindings?.[0]?.match?.channel).toBe("slack");
const raw = await fs.readFile(configPath, "utf-8");
const parsed = JSON.parse(raw) as {
bindings?: Array<{ match?: { channel?: string; provider?: string } }>;
};
expect(parsed.bindings?.[0]?.match?.channel).toBe("slack");
expect(parsed.bindings?.[0]?.match?.provider).toBeUndefined();
expect(
warnSpy.mock.calls.some(([msg]) =>
String(msg).includes("Auto-migrated config"),
),
).toBe(true);
} finally {
warnSpy.mockRestore();
}
});
});
it("auto-migrates session.sendPolicy.rules[].match.provider on load and writes back", async () => {
await withTempHome(async (home) => {
const configPath = path.join(home, ".clawdbot", "clawdbot.json");
await fs.mkdir(path.dirname(configPath), { recursive: true });
await fs.writeFile(
configPath,
JSON.stringify(
{
session: {
sendPolicy: {
rules: [{ action: "deny", match: { provider: "telegram" } }],
},
},
},
null,
2,
),
"utf-8",
);
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
vi.resetModules();
try {
const { loadConfig } = await import("./config.js");
const cfg = loadConfig();
expect(cfg.session?.sendPolicy?.rules?.[0]?.match?.channel).toBe(
"telegram",
);
const raw = await fs.readFile(configPath, "utf-8");
const parsed = JSON.parse(raw) as {
session?: {
sendPolicy?: {
rules?: Array<{
match?: { channel?: string; provider?: string };
}>;
};
};
};
expect(parsed.session?.sendPolicy?.rules?.[0]?.match?.channel).toBe(
"telegram",
);
expect(
parsed.session?.sendPolicy?.rules?.[0]?.match?.provider,
).toBeUndefined();
expect(
warnSpy.mock.calls.some(([msg]) =>
String(msg).includes("Auto-migrated config"),
),
).toBe(true);
} finally {
warnSpy.mockRestore();
}
});
});
it("auto-migrates messages.queue.byProvider on load and writes back", async () => {
await withTempHome(async (home) => {
const configPath = path.join(home, ".clawdbot", "clawdbot.json");
await fs.mkdir(path.dirname(configPath), { recursive: true });
await fs.writeFile(
configPath,
JSON.stringify(
{ messages: { queue: { byProvider: { whatsapp: "queue" } } } },
null,
2,
),
"utf-8",
);
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
vi.resetModules();
try {
const { loadConfig } = await import("./config.js");
const cfg = loadConfig();
expect(cfg.messages?.queue?.byChannel?.whatsapp).toBe("queue");
const raw = await fs.readFile(configPath, "utf-8");
const parsed = JSON.parse(raw) as {
messages?: {
queue?: {
byChannel?: Record<string, unknown>;
byProvider?: unknown;
};
};
};
expect(parsed.messages?.queue?.byChannel?.whatsapp).toBe("queue");
expect(parsed.messages?.queue?.byProvider).toBeUndefined();
expect(
warnSpy.mock.calls.some(([msg]) =>
String(msg).includes("Auto-migrated config"),
),
).toBe(true);
} finally {
warnSpy.mockRestore();
}
});
});
});
+43
View File
@@ -0,0 +1,43 @@
import { describe, expect, it, vi } from "vitest";
describe("config msteams", () => {
it("accepts replyStyle at global/team/channel levels", async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({
channels: {
msteams: {
replyStyle: "top-level",
teams: {
team123: {
replyStyle: "thread",
channels: {
chan456: { replyStyle: "top-level" },
},
},
},
},
},
});
expect(res.ok).toBe(true);
if (res.ok) {
expect(res.config.channels?.msteams?.replyStyle).toBe("top-level");
expect(res.config.channels?.msteams?.teams?.team123?.replyStyle).toBe(
"thread",
);
expect(
res.config.channels?.msteams?.teams?.team123?.channels?.chan456
?.replyStyle,
).toBe("top-level");
}
});
it("rejects invalid replyStyle", async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({
channels: { msteams: { replyStyle: "nope" } },
});
expect(res.ok).toBe(false);
});
});
@@ -0,0 +1,57 @@
import fs from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { withTempHome } from "./test-helpers.js";
describe("multi-agent agentDir validation", () => {
it("rejects shared agents.list agentDir", async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const shared = path.join(tmpdir(), "clawdbot-shared-agentdir");
const res = validateConfigObject({
agents: {
list: [
{ id: "a", agentDir: shared },
{ id: "b", agentDir: shared },
],
},
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.issues.some((i) => i.path === "agents.list")).toBe(true);
expect(res.issues[0]?.message).toContain("Duplicate agentDir");
}
});
it("throws on shared agentDir during loadConfig()", async () => {
await withTempHome(async (home) => {
const configDir = path.join(home, ".clawdbot");
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(
path.join(configDir, "clawdbot.json"),
JSON.stringify(
{
agents: {
list: [
{ id: "a", agentDir: "~/.clawdbot/agents/shared/agent" },
{ id: "b", agentDir: "~/.clawdbot/agents/shared/agent" },
],
},
bindings: [{ agentId: "a", match: { provider: "telegram" } }],
},
null,
2,
),
"utf-8",
);
vi.resetModules();
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
const { loadConfig } = await import("./config.js");
expect(() => loadConfig()).toThrow(/duplicate agentDir/i);
expect(spy.mock.calls.flat().join(" ")).toMatch(/Duplicate agentDir/i);
spy.mockRestore();
});
});
});
@@ -0,0 +1,271 @@
import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { withEnvOverride, withTempHome } from "./test-helpers.js";
describe("Nix integration (U3, U5, U9)", () => {
describe("U3: isNixMode env var detection", () => {
it("isNixMode is false when CLAWDBOT_NIX_MODE is not set", async () => {
await withEnvOverride({ CLAWDBOT_NIX_MODE: undefined }, async () => {
const { isNixMode } = await import("./config.js");
expect(isNixMode).toBe(false);
});
});
it("isNixMode is false when CLAWDBOT_NIX_MODE is empty", async () => {
await withEnvOverride({ CLAWDBOT_NIX_MODE: "" }, async () => {
const { isNixMode } = await import("./config.js");
expect(isNixMode).toBe(false);
});
});
it("isNixMode is false when CLAWDBOT_NIX_MODE is not '1'", async () => {
await withEnvOverride({ CLAWDBOT_NIX_MODE: "true" }, async () => {
const { isNixMode } = await import("./config.js");
expect(isNixMode).toBe(false);
});
});
it("isNixMode is true when CLAWDBOT_NIX_MODE=1", async () => {
await withEnvOverride({ CLAWDBOT_NIX_MODE: "1" }, async () => {
const { isNixMode } = await import("./config.js");
expect(isNixMode).toBe(true);
});
});
});
describe("U5: CONFIG_PATH and STATE_DIR env var overrides", () => {
it("STATE_DIR_CLAWDBOT defaults to ~/.clawdbot when env not set", async () => {
await withEnvOverride({ CLAWDBOT_STATE_DIR: undefined }, async () => {
const { STATE_DIR_CLAWDBOT } = await import("./config.js");
expect(STATE_DIR_CLAWDBOT).toMatch(/\.clawdbot$/);
});
});
it("STATE_DIR_CLAWDBOT respects CLAWDBOT_STATE_DIR override", async () => {
await withEnvOverride(
{ CLAWDBOT_STATE_DIR: "/custom/state/dir" },
async () => {
const { STATE_DIR_CLAWDBOT } = await import("./config.js");
expect(STATE_DIR_CLAWDBOT).toBe(path.resolve("/custom/state/dir"));
},
);
});
it("CONFIG_PATH_CLAWDBOT defaults to ~/.clawdbot/clawdbot.json when env not set", async () => {
await withEnvOverride(
{ CLAWDBOT_CONFIG_PATH: undefined, CLAWDBOT_STATE_DIR: undefined },
async () => {
const { CONFIG_PATH_CLAWDBOT } = await import("./config.js");
expect(CONFIG_PATH_CLAWDBOT).toMatch(
/\.clawdbot[\\/]clawdbot\.json$/,
);
},
);
});
it("CONFIG_PATH_CLAWDBOT respects CLAWDBOT_CONFIG_PATH override", async () => {
await withEnvOverride(
{ CLAWDBOT_CONFIG_PATH: "/nix/store/abc/clawdbot.json" },
async () => {
const { CONFIG_PATH_CLAWDBOT } = await import("./config.js");
expect(CONFIG_PATH_CLAWDBOT).toBe(
path.resolve("/nix/store/abc/clawdbot.json"),
);
},
);
});
it("CONFIG_PATH_CLAWDBOT expands ~ in CLAWDBOT_CONFIG_PATH override", async () => {
await withTempHome(async (home) => {
await withEnvOverride(
{ CLAWDBOT_CONFIG_PATH: "~/.clawdbot/custom.json" },
async () => {
const { CONFIG_PATH_CLAWDBOT } = await import("./config.js");
expect(CONFIG_PATH_CLAWDBOT).toBe(
path.join(home, ".clawdbot", "custom.json"),
);
},
);
});
});
it("CONFIG_PATH_CLAWDBOT uses STATE_DIR_CLAWDBOT when only state dir is overridden", async () => {
await withEnvOverride(
{
CLAWDBOT_CONFIG_PATH: undefined,
CLAWDBOT_STATE_DIR: "/custom/state",
},
async () => {
const { CONFIG_PATH_CLAWDBOT } = await import("./config.js");
expect(CONFIG_PATH_CLAWDBOT).toBe(
path.join(path.resolve("/custom/state"), "clawdbot.json"),
);
},
);
});
});
describe("U5b: tilde expansion for config paths", () => {
it("expands ~ in common path-ish config fields", async () => {
await withTempHome(async (home) => {
const configDir = path.join(home, ".clawdbot");
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(
path.join(configDir, "clawdbot.json"),
JSON.stringify(
{
plugins: {
load: {
paths: ["~/plugins/demo-plugin"],
},
},
agents: {
defaults: { workspace: "~/ws-default" },
list: [
{
id: "main",
workspace: "~/ws-agent",
agentDir: "~/.clawdbot/agents/main",
sandbox: { workspaceRoot: "~/sandbox-root" },
},
],
},
channels: {
whatsapp: {
accounts: {
personal: {
authDir: "~/.clawdbot/credentials/wa-personal",
},
},
},
},
},
null,
2,
),
"utf-8",
);
vi.resetModules();
const { loadConfig } = await import("./config.js");
const cfg = loadConfig();
expect(cfg.plugins?.load?.paths?.[0]).toBe(
path.join(home, "plugins", "demo-plugin"),
);
expect(cfg.agents?.defaults?.workspace).toBe(
path.join(home, "ws-default"),
);
expect(cfg.agents?.list?.[0]?.workspace).toBe(
path.join(home, "ws-agent"),
);
expect(cfg.agents?.list?.[0]?.agentDir).toBe(
path.join(home, ".clawdbot", "agents", "main"),
);
expect(cfg.agents?.list?.[0]?.sandbox?.workspaceRoot).toBe(
path.join(home, "sandbox-root"),
);
expect(cfg.channels?.whatsapp?.accounts?.personal?.authDir).toBe(
path.join(home, ".clawdbot", "credentials", "wa-personal"),
);
});
});
});
describe("U6: gateway port resolution", () => {
it("uses default when env and config are unset", async () => {
await withEnvOverride({ CLAWDBOT_GATEWAY_PORT: undefined }, async () => {
const { DEFAULT_GATEWAY_PORT, resolveGatewayPort } = await import(
"./config.js"
);
expect(resolveGatewayPort({})).toBe(DEFAULT_GATEWAY_PORT);
});
});
it("prefers CLAWDBOT_GATEWAY_PORT over config", async () => {
await withEnvOverride({ CLAWDBOT_GATEWAY_PORT: "19001" }, async () => {
const { resolveGatewayPort } = await import("./config.js");
expect(resolveGatewayPort({ gateway: { port: 19002 } })).toBe(19001);
});
});
it("falls back to config when env is invalid", async () => {
await withEnvOverride({ CLAWDBOT_GATEWAY_PORT: "nope" }, async () => {
const { resolveGatewayPort } = await import("./config.js");
expect(resolveGatewayPort({ gateway: { port: 19003 } })).toBe(19003);
});
});
});
describe("U9: telegram.tokenFile schema validation", () => {
it("accepts config with only botToken", async () => {
await withTempHome(async (home) => {
const configDir = path.join(home, ".clawdbot");
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(
path.join(configDir, "clawdbot.json"),
JSON.stringify({
channels: { telegram: { botToken: "123:ABC" } },
}),
"utf-8",
);
vi.resetModules();
const { loadConfig } = await import("./config.js");
const cfg = loadConfig();
expect(cfg.channels?.telegram?.botToken).toBe("123:ABC");
expect(cfg.channels?.telegram?.tokenFile).toBeUndefined();
});
});
it("accepts config with only tokenFile", async () => {
await withTempHome(async (home) => {
const configDir = path.join(home, ".clawdbot");
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(
path.join(configDir, "clawdbot.json"),
JSON.stringify({
channels: { telegram: { tokenFile: "/run/agenix/telegram-token" } },
}),
"utf-8",
);
vi.resetModules();
const { loadConfig } = await import("./config.js");
const cfg = loadConfig();
expect(cfg.channels?.telegram?.tokenFile).toBe(
"/run/agenix/telegram-token",
);
expect(cfg.channels?.telegram?.botToken).toBeUndefined();
});
});
it("accepts config with both botToken and tokenFile", async () => {
await withTempHome(async (home) => {
const configDir = path.join(home, ".clawdbot");
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(
path.join(configDir, "clawdbot.json"),
JSON.stringify({
channels: {
telegram: {
botToken: "fallback:token",
tokenFile: "/run/agenix/telegram-token",
},
},
}),
"utf-8",
);
vi.resetModules();
const { loadConfig } = await import("./config.js");
const cfg = loadConfig();
expect(cfg.channels?.telegram?.botToken).toBe("fallback:token");
expect(cfg.channels?.telegram?.tokenFile).toBe(
"/run/agenix/telegram-token",
);
});
});
});
});
@@ -0,0 +1,50 @@
import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { withTempHome } from "./test-helpers.js";
describe("config preservation on validation failure", () => {
it("preserves unknown fields via passthrough", async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({
agents: { list: [{ id: "pi" }] },
customUnknownField: { nested: "value" },
});
expect(res.ok).toBe(true);
expect(
(res as { config: Record<string, unknown> }).config.customUnknownField,
).toEqual({
nested: "value",
});
});
it("preserves config data when validation fails", async () => {
await withTempHome(async (home) => {
const configDir = path.join(home, ".clawdbot");
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(
path.join(configDir, "clawdbot.json"),
JSON.stringify({
agents: { list: [{ id: "pi" }] },
routing: { allowFrom: ["+15555550123"] },
customData: { preserved: true },
}),
"utf-8",
);
vi.resetModules();
const { readConfigFileSnapshot } = await import("./config.js");
const snap = await readConfigFileSnapshot();
expect(snap.valid).toBe(true);
expect(snap.legacyIssues).toHaveLength(0);
expect((snap.config as Record<string, unknown>).customData).toEqual({
preserved: true,
});
expect(snap.config.channels?.whatsapp?.allowFrom).toEqual([
"+15555550123",
]);
});
});
});
@@ -0,0 +1,46 @@
import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { withTempHome } from "./test-helpers.js";
describe("config pruning defaults", () => {
it("defaults contextPruning mode to adaptive", async () => {
await withTempHome(async (home) => {
const configDir = path.join(home, ".clawdbot");
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(
path.join(configDir, "clawdbot.json"),
JSON.stringify({ agents: { defaults: {} } }, null, 2),
"utf-8",
);
vi.resetModules();
const { loadConfig } = await import("./config.js");
const cfg = loadConfig();
expect(cfg.agents?.defaults?.contextPruning?.mode).toBe("adaptive");
});
});
it("does not override explicit contextPruning mode", async () => {
await withTempHome(async (home) => {
const configDir = path.join(home, ".clawdbot");
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(
path.join(configDir, "clawdbot.json"),
JSON.stringify(
{ agents: { defaults: { contextPruning: { mode: "off" } } } },
null,
2,
),
"utf-8",
);
vi.resetModules();
const { loadConfig } = await import("./config.js");
const cfg = loadConfig();
expect(cfg.agents?.defaults?.contextPruning?.mode).toBe("off");
});
});
});
@@ -0,0 +1,51 @@
import fs from "node:fs/promises";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { withTempHome } from "./test-helpers.js";
describe("talk api key fallback", () => {
let previousEnv: string | undefined;
beforeEach(() => {
previousEnv = process.env.ELEVENLABS_API_KEY;
delete process.env.ELEVENLABS_API_KEY;
});
afterEach(() => {
process.env.ELEVENLABS_API_KEY = previousEnv;
});
it("injects talk.apiKey from profile when config is missing", async () => {
await withTempHome(async (home) => {
await fs.writeFile(
path.join(home, ".profile"),
"export ELEVENLABS_API_KEY=profile-key\n",
"utf-8",
);
vi.resetModules();
const { readConfigFileSnapshot } = await import("./config.js");
const snap = await readConfigFileSnapshot();
expect(snap.config?.talk?.apiKey).toBe("profile-key");
expect(snap.exists).toBe(false);
});
});
it("prefers ELEVENLABS_API_KEY env over profile", async () => {
await withTempHome(async (home) => {
await fs.writeFile(
path.join(home, ".profile"),
"export ELEVENLABS_API_KEY=profile-key\n",
"utf-8",
);
process.env.ELEVENLABS_API_KEY = "env-key";
vi.resetModules();
const { readConfigFileSnapshot } = await import("./config.js");
const snap = await readConfigFileSnapshot();
expect(snap.config?.talk?.apiKey).toBe("env-key");
});
});
});
@@ -0,0 +1,30 @@
import { describe, expect, it, vi } from "vitest";
describe("talk.voiceAliases", () => {
it("accepts a string map of voice aliases", async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({
talk: {
voiceAliases: {
Clawd: "EXAVITQu4vr4xnSDxMaL",
Roger: "CwhRBWXzGAHq8TQ4Fs17",
},
},
});
expect(res.ok).toBe(true);
});
it("rejects non-string voice alias values", async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({
talk: {
voiceAliases: {
Clawd: 123,
},
},
});
expect(res.ok).toBe(false);
});
});
File diff suppressed because it is too large Load Diff
+6 -709
View File
@@ -1,709 +1,6 @@
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { Skill } from "@mariozechner/pi-coding-agent";
import JSON5 from "json5";
import type { MsgContext } from "../auto-reply/templating.js";
import type { ChannelId } from "../channels/plugins/types.js";
import { CHANNEL_IDS } from "../channels/registry.js";
import {
buildAgentMainSessionKey,
DEFAULT_AGENT_ID,
normalizeAgentId,
normalizeMainKey,
resolveAgentIdFromSessionKey,
} from "../routing/session-key.js";
import { normalizeE164 } from "../utils.js";
import {
getFileMtimeMs,
isCacheEnabled,
resolveCacheTtlMs,
} from "./cache-utils.js";
import { loadConfig } from "./config.js";
import { resolveStateDir } from "./paths.js";
// ============================================================================
// Session Store Cache with TTL Support
// ============================================================================
type SessionStoreCacheEntry = {
store: Record<string, SessionEntry>;
loadedAt: number;
storePath: string;
mtimeMs?: number;
};
const SESSION_STORE_CACHE = new Map<string, SessionStoreCacheEntry>();
const DEFAULT_SESSION_STORE_TTL_MS = 45_000; // 45 seconds (between 30-60s)
function getSessionStoreTtl(): number {
return resolveCacheTtlMs({
envValue: process.env.CLAWDBOT_SESSION_CACHE_TTL_MS,
defaultTtlMs: DEFAULT_SESSION_STORE_TTL_MS,
});
}
function isSessionStoreCacheEnabled(): boolean {
return isCacheEnabled(getSessionStoreTtl());
}
function isSessionStoreCacheValid(entry: SessionStoreCacheEntry): boolean {
const now = Date.now();
const ttl = getSessionStoreTtl();
return now - entry.loadedAt <= ttl;
}
function invalidateSessionStoreCache(storePath: string): void {
SESSION_STORE_CACHE.delete(storePath);
}
export function clearSessionStoreCacheForTest(): void {
SESSION_STORE_CACHE.clear();
}
export type SessionScope = "per-sender" | "global";
export type SessionChannelId = ChannelId | "webchat";
const GROUP_SURFACES = new Set<string>([...CHANNEL_IDS, "webchat"]);
export type SessionChatType = "direct" | "group" | "room";
export type SessionEntry = {
sessionId: string;
updatedAt: number;
sessionFile?: string;
/** Parent session key that spawned this session (used for sandbox session-tool scoping). */
spawnedBy?: string;
systemSent?: boolean;
abortedLastRun?: boolean;
chatType?: SessionChatType;
thinkingLevel?: string;
verboseLevel?: string;
reasoningLevel?: string;
elevatedLevel?: string;
responseUsage?: "on" | "off";
providerOverride?: string;
modelOverride?: string;
authProfileOverride?: string;
groupActivation?: "mention" | "always";
groupActivationNeedsSystemIntro?: boolean;
sendPolicy?: "allow" | "deny";
queueMode?:
| "steer"
| "followup"
| "collect"
| "steer-backlog"
| "steer+backlog"
| "queue"
| "interrupt";
queueDebounceMs?: number;
queueCap?: number;
queueDrop?: "old" | "new" | "summarize";
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
modelProvider?: string;
model?: string;
contextTokens?: number;
compactionCount?: number;
memoryFlushAt?: number;
memoryFlushCompactionCount?: number;
cliSessionIds?: Record<string, string>;
claudeCliSessionId?: string;
label?: string;
displayName?: string;
channel?: string;
subject?: string;
room?: string;
space?: string;
lastChannel?: SessionChannelId;
lastTo?: string;
lastAccountId?: string;
skillsSnapshot?: SessionSkillSnapshot;
};
export function mergeSessionEntry(
existing: SessionEntry | undefined,
patch: Partial<SessionEntry>,
): SessionEntry {
const sessionId =
patch.sessionId ?? existing?.sessionId ?? crypto.randomUUID();
const updatedAt = Math.max(
existing?.updatedAt ?? 0,
patch.updatedAt ?? 0,
Date.now(),
);
if (!existing) return { ...patch, sessionId, updatedAt };
return { ...existing, ...patch, sessionId, updatedAt };
}
export type GroupKeyResolution = {
key: string;
legacyKey?: string;
channel?: string;
id?: string;
chatType?: SessionChatType;
};
export type SessionSkillSnapshot = {
prompt: string;
skills: Array<{ name: string; primaryEnv?: string }>;
resolvedSkills?: Skill[];
};
function resolveAgentSessionsDir(
agentId?: string,
env: NodeJS.ProcessEnv = process.env,
homedir: () => string = os.homedir,
): string {
const root = resolveStateDir(env, homedir);
const id = normalizeAgentId(agentId ?? DEFAULT_AGENT_ID);
return path.join(root, "agents", id, "sessions");
}
export function resolveSessionTranscriptsDir(
env: NodeJS.ProcessEnv = process.env,
homedir: () => string = os.homedir,
): string {
return resolveAgentSessionsDir(DEFAULT_AGENT_ID, env, homedir);
}
export function resolveSessionTranscriptsDirForAgent(
agentId?: string,
env: NodeJS.ProcessEnv = process.env,
homedir: () => string = os.homedir,
): string {
return resolveAgentSessionsDir(agentId, env, homedir);
}
export function resolveDefaultSessionStorePath(agentId?: string): string {
return path.join(resolveAgentSessionsDir(agentId), "sessions.json");
}
export const DEFAULT_RESET_TRIGGER = "/new";
export const DEFAULT_RESET_TRIGGERS = ["/new", "/reset"];
export const DEFAULT_IDLE_MINUTES = 60;
export function resolveSessionTranscriptPath(
sessionId: string,
agentId?: string,
topicId?: number,
): string {
const fileName =
topicId !== undefined
? `${sessionId}-topic-${topicId}.jsonl`
: `${sessionId}.jsonl`;
return path.join(resolveAgentSessionsDir(agentId), fileName);
}
export function resolveSessionFilePath(
sessionId: string,
entry?: SessionEntry,
opts?: { agentId?: string },
): string {
const candidate = entry?.sessionFile?.trim();
return candidate
? candidate
: resolveSessionTranscriptPath(sessionId, opts?.agentId);
}
export function resolveStorePath(store?: string, opts?: { agentId?: string }) {
const agentId = normalizeAgentId(opts?.agentId ?? DEFAULT_AGENT_ID);
if (!store) return resolveDefaultSessionStorePath(agentId);
if (store.includes("{agentId}")) {
const expanded = store.replaceAll("{agentId}", agentId);
if (expanded.startsWith("~")) {
return path.resolve(expanded.replace(/^~(?=$|[\\/])/, os.homedir()));
}
return path.resolve(expanded);
}
if (store.startsWith("~"))
return path.resolve(store.replace(/^~(?=$|[\\/])/, os.homedir()));
return path.resolve(store);
}
export function resolveMainSessionKey(cfg?: {
session?: { scope?: SessionScope; mainKey?: string };
agents?: { list?: Array<{ id?: string; default?: boolean }> };
}): string {
if (cfg?.session?.scope === "global") return "global";
const agents = cfg?.agents?.list ?? [];
const defaultAgentId =
agents.find((agent) => agent?.default)?.id ??
agents[0]?.id ??
DEFAULT_AGENT_ID;
const agentId = normalizeAgentId(defaultAgentId);
const mainKey = normalizeMainKey(cfg?.session?.mainKey);
return buildAgentMainSessionKey({ agentId, mainKey });
}
export function resolveMainSessionKeyFromConfig(): string {
return resolveMainSessionKey(loadConfig());
}
export { resolveAgentIdFromSessionKey };
export function resolveAgentMainSessionKey(params: {
cfg?: { session?: { mainKey?: string } };
agentId: string;
}): string {
const mainKey = normalizeMainKey(params.cfg?.session?.mainKey);
return buildAgentMainSessionKey({ agentId: params.agentId, mainKey });
}
export function canonicalizeMainSessionAlias(params: {
cfg?: { session?: { scope?: SessionScope; mainKey?: string } };
agentId: string;
sessionKey: string;
}): string {
const raw = params.sessionKey.trim();
if (!raw) return raw;
const agentId = normalizeAgentId(params.agentId);
const mainKey = normalizeMainKey(params.cfg?.session?.mainKey);
const agentMainSessionKey = buildAgentMainSessionKey({ agentId, mainKey });
const agentMainAliasKey = buildAgentMainSessionKey({
agentId,
mainKey: "main",
});
const isMainAlias =
raw === "main" ||
raw === mainKey ||
raw === agentMainSessionKey ||
raw === agentMainAliasKey;
if (params.cfg?.session?.scope === "global" && isMainAlias) return "global";
if (isMainAlias) return agentMainSessionKey;
return raw;
}
function normalizeGroupLabel(raw?: string) {
const trimmed = raw?.trim().toLowerCase() ?? "";
if (!trimmed) return "";
const dashed = trimmed.replace(/\s+/g, "-");
const cleaned = dashed.replace(/[^a-z0-9#@._+-]+/g, "-");
return cleaned.replace(/-{2,}/g, "-").replace(/^[-.]+|[-.]+$/g, "");
}
function shortenGroupId(value?: string) {
const trimmed = value?.trim() ?? "";
if (!trimmed) return "";
if (trimmed.length <= 14) return trimmed;
return `${trimmed.slice(0, 6)}...${trimmed.slice(-4)}`;
}
export function buildGroupDisplayName(params: {
provider?: string;
subject?: string;
room?: string;
space?: string;
id?: string;
key: string;
}) {
const providerKey = (params.provider?.trim().toLowerCase() || "group").trim();
const room = params.room?.trim();
const space = params.space?.trim();
const subject = params.subject?.trim();
const detail =
(room && space
? `${space}${room.startsWith("#") ? "" : "#"}${room}`
: room || subject || space || "") || "";
const fallbackId = params.id?.trim() || params.key.replace(/^group:/, "");
const rawLabel = detail || fallbackId;
let token = normalizeGroupLabel(rawLabel);
if (!token) {
token = normalizeGroupLabel(shortenGroupId(rawLabel));
}
if (!params.room && token.startsWith("#")) {
token = token.replace(/^#+/, "");
}
if (
token &&
!/^[@#]/.test(token) &&
!token.startsWith("g-") &&
!token.includes("#")
) {
token = `g-${token}`;
}
return token ? `${providerKey}:${token}` : providerKey;
}
export function resolveGroupSessionKey(
ctx: MsgContext,
): GroupKeyResolution | null {
const from = typeof ctx.From === "string" ? ctx.From.trim() : "";
if (!from) return null;
const chatType = ctx.ChatType?.trim().toLowerCase();
const isGroup =
chatType === "group" ||
from.startsWith("group:") ||
from.includes("@g.us") ||
from.includes(":group:") ||
from.includes(":channel:");
if (!isGroup) return null;
const providerHint = ctx.Provider?.trim().toLowerCase();
const hasLegacyGroupPrefix = from.startsWith("group:");
const raw = (
hasLegacyGroupPrefix ? from.slice("group:".length) : from
).trim();
let provider: string | undefined;
let kind: "group" | "channel" | undefined;
let id = "";
const parseKind = (value: string) => {
if (value === "channel") return "channel";
return "group";
};
const parseParts = (parts: string[]) => {
if (parts.length >= 2 && GROUP_SURFACES.has(parts[0])) {
provider = parts[0];
if (parts.length >= 3) {
const kindCandidate = parts[1];
if (["group", "channel"].includes(kindCandidate)) {
kind = parseKind(kindCandidate);
id = parts.slice(2).join(":");
} else {
id = parts.slice(1).join(":");
}
} else {
id = parts[1];
}
return;
}
if (parts.length >= 2 && ["group", "channel"].includes(parts[0])) {
kind = parseKind(parts[0]);
id = parts.slice(1).join(":");
}
};
if (hasLegacyGroupPrefix) {
const legacyParts = raw.split(":").filter(Boolean);
if (legacyParts.length > 1) {
parseParts(legacyParts);
} else {
id = raw;
}
} else if (from.includes("@g.us") && !from.includes(":")) {
id = from;
} else {
parseParts(from.split(":").filter(Boolean));
if (!id) {
id = raw || from;
}
}
const resolvedProvider = provider ?? providerHint;
if (!resolvedProvider) {
const legacy = hasLegacyGroupPrefix ? `group:${raw}` : `group:${from}`;
return {
key: legacy,
id: raw || from,
legacyKey: legacy,
chatType: "group",
};
}
const resolvedKind = kind === "channel" ? "channel" : "group";
const key = `${resolvedProvider}:${resolvedKind}:${id || raw || from}`;
let legacyKey: string | undefined;
if (hasLegacyGroupPrefix || from.includes("@g.us")) {
legacyKey = `group:${id || raw || from}`;
}
return {
key,
legacyKey,
channel: resolvedProvider,
id: id || raw || from,
chatType: resolvedKind === "channel" ? "room" : "group",
};
}
export function loadSessionStore(
storePath: string,
): Record<string, SessionEntry> {
// Check cache first if enabled
if (isSessionStoreCacheEnabled()) {
const cached = SESSION_STORE_CACHE.get(storePath);
if (cached && isSessionStoreCacheValid(cached)) {
const currentMtimeMs = getFileMtimeMs(storePath);
if (currentMtimeMs === cached.mtimeMs) {
// Return a shallow copy to prevent external mutations affecting cache
return { ...cached.store };
}
invalidateSessionStoreCache(storePath);
}
}
// Cache miss or disabled - load from disk
let store: Record<string, SessionEntry> = {};
let mtimeMs = getFileMtimeMs(storePath);
try {
const raw = fs.readFileSync(storePath, "utf-8");
const parsed = JSON5.parse(raw);
if (parsed && typeof parsed === "object") {
store = parsed as Record<string, SessionEntry>;
}
mtimeMs = getFileMtimeMs(storePath) ?? mtimeMs;
} catch {
// ignore missing/invalid store; we'll recreate it
}
// Best-effort migration: message provider → channel naming.
for (const entry of Object.values(store)) {
if (!entry || typeof entry !== "object") continue;
const rec = entry as unknown as Record<string, unknown>;
if (typeof rec.channel !== "string" && typeof rec.provider === "string") {
rec.channel = rec.provider;
delete rec.provider;
}
if (
typeof rec.lastChannel !== "string" &&
typeof rec.lastProvider === "string"
) {
rec.lastChannel = rec.lastProvider;
delete rec.lastProvider;
}
}
// Cache the result if caching is enabled
if (isSessionStoreCacheEnabled()) {
SESSION_STORE_CACHE.set(storePath, {
store: { ...store }, // Store a copy to prevent external mutations
loadedAt: Date.now(),
storePath,
mtimeMs,
});
}
return store;
}
async function saveSessionStoreUnlocked(
storePath: string,
store: Record<string, SessionEntry>,
): Promise<void> {
// Invalidate cache on write to ensure consistency
invalidateSessionStoreCache(storePath);
await fs.promises.mkdir(path.dirname(storePath), { recursive: true });
const json = JSON.stringify(store, null, 2);
// Windows: avoid atomic rename swaps (can be flaky under concurrent access).
// We serialize writers via the session-store lock instead.
if (process.platform === "win32") {
try {
await fs.promises.writeFile(storePath, json, "utf-8");
} catch (err) {
const code =
err && typeof err === "object" && "code" in err
? String((err as { code?: unknown }).code)
: null;
if (code === "ENOENT") return;
throw err;
}
return;
}
const tmp = `${storePath}.${process.pid}.${crypto.randomUUID()}.tmp`;
try {
await fs.promises.writeFile(tmp, json, "utf-8");
await fs.promises.rename(tmp, storePath);
} catch (err) {
const code =
err && typeof err === "object" && "code" in err
? String((err as { code?: unknown }).code)
: null;
if (code === "ENOENT") {
// In tests the temp session-store directory may be deleted while writes are in-flight.
// Best-effort: try a direct write (recreating the parent dir), otherwise ignore.
try {
await fs.promises.mkdir(path.dirname(storePath), { recursive: true });
await fs.promises.writeFile(storePath, json, "utf-8");
} catch (err2) {
const code2 =
err2 && typeof err2 === "object" && "code" in err2
? String((err2 as { code?: unknown }).code)
: null;
if (code2 === "ENOENT") return;
throw err2;
}
return;
}
throw err;
} finally {
await fs.promises.rm(tmp, { force: true });
}
}
export async function saveSessionStore(
storePath: string,
store: Record<string, SessionEntry>,
): Promise<void> {
await withSessionStoreLock(storePath, async () => {
await saveSessionStoreUnlocked(storePath, store);
});
}
type SessionStoreLockOptions = {
timeoutMs?: number;
pollIntervalMs?: number;
staleMs?: number;
};
async function withSessionStoreLock<T>(
storePath: string,
fn: () => Promise<T>,
opts: SessionStoreLockOptions = {},
): Promise<T> {
const timeoutMs = opts.timeoutMs ?? 10_000;
const pollIntervalMs = opts.pollIntervalMs ?? 25;
const staleMs = opts.staleMs ?? 30_000;
const lockPath = `${storePath}.lock`;
const startedAt = Date.now();
await fs.promises.mkdir(path.dirname(storePath), { recursive: true });
while (true) {
try {
const handle = await fs.promises.open(lockPath, "wx");
try {
await handle.writeFile(
JSON.stringify({ pid: process.pid, startedAt: Date.now() }),
"utf-8",
);
} catch {
// best-effort
}
await handle.close();
break;
} catch (err) {
const code =
err && typeof err === "object" && "code" in err
? String((err as { code?: unknown }).code)
: null;
if (code === "ENOENT") {
// Store directory may be deleted/recreated in tests while writes are in-flight.
// Best-effort: recreate the parent dir and retry until timeout.
await fs.promises
.mkdir(path.dirname(storePath), { recursive: true })
.catch(() => undefined);
await new Promise((r) => setTimeout(r, pollIntervalMs));
continue;
}
if (code !== "EEXIST") throw err;
const now = Date.now();
if (now - startedAt > timeoutMs) {
throw new Error(`timeout acquiring session store lock: ${lockPath}`);
}
// Best-effort stale lock eviction (e.g. crashed process).
try {
const st = await fs.promises.stat(lockPath);
const ageMs = now - st.mtimeMs;
if (ageMs > staleMs) {
await fs.promises.unlink(lockPath);
continue;
}
} catch {
// ignore
}
await new Promise((r) => setTimeout(r, pollIntervalMs));
}
}
try {
return await fn();
} finally {
await fs.promises.unlink(lockPath).catch(() => undefined);
}
}
export async function updateSessionStoreEntry(params: {
storePath: string;
sessionKey: string;
update: (entry: SessionEntry) => Promise<Partial<SessionEntry> | null>;
}): Promise<SessionEntry | null> {
const { storePath, sessionKey, update } = params;
return await withSessionStoreLock(storePath, async () => {
const store = loadSessionStore(storePath);
const existing = store[sessionKey];
if (!existing) return null;
const patch = await update(existing);
if (!patch) return existing;
const next = mergeSessionEntry(existing, patch);
store[sessionKey] = next;
await saveSessionStoreUnlocked(storePath, store);
return next;
});
}
export async function updateLastRoute(params: {
storePath: string;
sessionKey: string;
channel: SessionEntry["lastChannel"];
to?: string;
accountId?: string;
}) {
const { storePath, sessionKey, channel, to, accountId } = params;
return await withSessionStoreLock(storePath, async () => {
const store = loadSessionStore(storePath);
const existing = store[sessionKey];
const now = Date.now();
const next = mergeSessionEntry(existing, {
updatedAt: Math.max(existing?.updatedAt ?? 0, now),
lastChannel: channel,
lastTo: to?.trim() ? to.trim() : undefined,
lastAccountId: accountId?.trim()
? accountId.trim()
: existing?.lastAccountId,
});
store[sessionKey] = next;
await saveSessionStoreUnlocked(storePath, store);
return next;
});
}
// Decide which session bucket to use (per-sender vs global).
export function deriveSessionKey(scope: SessionScope, ctx: MsgContext) {
if (scope === "global") return "global";
const resolvedGroup = resolveGroupSessionKey(ctx);
if (resolvedGroup) return resolvedGroup.key;
const from = ctx.From ? normalizeE164(ctx.From) : "";
return from || "unknown";
}
/**
* Resolve the session key with a canonical direct-chat bucket (default: "main").
* All non-group direct chats collapse to this bucket; groups stay isolated.
*/
export function resolveSessionKey(
scope: SessionScope,
ctx: MsgContext,
mainKey?: string,
) {
const explicit = ctx.SessionKey?.trim();
if (explicit) return explicit;
const raw = deriveSessionKey(scope, ctx);
if (scope === "global") return raw;
const canonicalMainKey = normalizeMainKey(mainKey);
const canonical = buildAgentMainSessionKey({
agentId: DEFAULT_AGENT_ID,
mainKey: canonicalMainKey,
});
const isGroup =
raw.startsWith("group:") ||
raw.includes(":group:") ||
raw.includes(":channel:");
if (!isGroup) return canonical;
return `agent:${DEFAULT_AGENT_ID}:${raw}`;
}
export * from "./sessions/group.js";
export * from "./sessions/main-session.js";
export * from "./sessions/paths.js";
export * from "./sessions/session-key.js";
export * from "./sessions/store.js";
export * from "./sessions/types.js";
+150
View File
@@ -0,0 +1,150 @@
import type { MsgContext } from "../../auto-reply/templating.js";
import { CHANNEL_IDS } from "../../channels/registry.js";
import type { GroupKeyResolution } from "./types.js";
const GROUP_SURFACES = new Set<string>([...CHANNEL_IDS, "webchat"]);
function normalizeGroupLabel(raw?: string) {
const trimmed = raw?.trim().toLowerCase() ?? "";
if (!trimmed) return "";
const dashed = trimmed.replace(/\s+/g, "-");
const cleaned = dashed.replace(/[^a-z0-9#@._+-]+/g, "-");
return cleaned.replace(/-{2,}/g, "-").replace(/^[-.]+|[-.]+$/g, "");
}
function shortenGroupId(value?: string) {
const trimmed = value?.trim() ?? "";
if (!trimmed) return "";
if (trimmed.length <= 14) return trimmed;
return `${trimmed.slice(0, 6)}...${trimmed.slice(-4)}`;
}
export function buildGroupDisplayName(params: {
provider?: string;
subject?: string;
room?: string;
space?: string;
id?: string;
key: string;
}) {
const providerKey = (params.provider?.trim().toLowerCase() || "group").trim();
const room = params.room?.trim();
const space = params.space?.trim();
const subject = params.subject?.trim();
const detail =
(room && space
? `${space}${room.startsWith("#") ? "" : "#"}${room}`
: room || subject || space || "") || "";
const fallbackId = params.id?.trim() || params.key.replace(/^group:/, "");
const rawLabel = detail || fallbackId;
let token = normalizeGroupLabel(rawLabel);
if (!token) {
token = normalizeGroupLabel(shortenGroupId(rawLabel));
}
if (!params.room && token.startsWith("#")) {
token = token.replace(/^#+/, "");
}
if (
token &&
!/^[@#]/.test(token) &&
!token.startsWith("g-") &&
!token.includes("#")
) {
token = `g-${token}`;
}
return token ? `${providerKey}:${token}` : providerKey;
}
export function resolveGroupSessionKey(
ctx: MsgContext,
): GroupKeyResolution | null {
const from = typeof ctx.From === "string" ? ctx.From.trim() : "";
if (!from) return null;
const chatType = ctx.ChatType?.trim().toLowerCase();
const isGroup =
chatType === "group" ||
from.startsWith("group:") ||
from.includes("@g.us") ||
from.includes(":group:") ||
from.includes(":channel:");
if (!isGroup) return null;
const providerHint = ctx.Provider?.trim().toLowerCase();
const hasLegacyGroupPrefix = from.startsWith("group:");
const raw = (
hasLegacyGroupPrefix ? from.slice("group:".length) : from
).trim();
let provider: string | undefined;
let kind: "group" | "channel" | undefined;
let id = "";
const parseKind = (value: string) => {
if (value === "channel") return "channel";
return "group";
};
const parseParts = (parts: string[]) => {
if (parts.length >= 2 && GROUP_SURFACES.has(parts[0])) {
provider = parts[0];
if (parts.length >= 3) {
const kindCandidate = parts[1];
if (["group", "channel"].includes(kindCandidate)) {
kind = parseKind(kindCandidate);
id = parts.slice(2).join(":");
} else {
id = parts.slice(1).join(":");
}
} else {
id = parts[1];
}
return;
}
if (parts.length >= 2 && ["group", "channel"].includes(parts[0])) {
kind = parseKind(parts[0]);
id = parts.slice(1).join(":");
}
};
if (hasLegacyGroupPrefix) {
const legacyParts = raw.split(":").filter(Boolean);
if (legacyParts.length > 1) {
parseParts(legacyParts);
} else {
id = raw;
}
} else if (from.includes("@g.us") && !from.includes(":")) {
id = from;
} else {
parseParts(from.split(":").filter(Boolean));
if (!id) {
id = raw || from;
}
}
const resolvedProvider = provider ?? providerHint;
if (!resolvedProvider) {
const legacy = hasLegacyGroupPrefix ? `group:${raw}` : `group:${from}`;
return {
key: legacy,
id: raw || from,
legacyKey: legacy,
chatType: "group",
};
}
const resolvedKind = kind === "channel" ? "channel" : "group";
const key = `${resolvedProvider}:${resolvedKind}:${id || raw || from}`;
let legacyKey: string | undefined;
if (hasLegacyGroupPrefix || from.includes("@g.us")) {
legacyKey = `group:${id || raw || from}`;
}
return {
key,
legacyKey,
channel: resolvedProvider,
id: id || raw || from,
chatType: resolvedKind === "channel" ? "room" : "group",
};
}
+65
View File
@@ -0,0 +1,65 @@
import {
buildAgentMainSessionKey,
DEFAULT_AGENT_ID,
normalizeAgentId,
normalizeMainKey,
resolveAgentIdFromSessionKey,
} from "../../routing/session-key.js";
import { loadConfig } from "../config.js";
import type { SessionScope } from "./types.js";
export function resolveMainSessionKey(cfg?: {
session?: { scope?: SessionScope; mainKey?: string };
agents?: { list?: Array<{ id?: string; default?: boolean }> };
}): string {
if (cfg?.session?.scope === "global") return "global";
const agents = cfg?.agents?.list ?? [];
const defaultAgentId =
agents.find((agent) => agent?.default)?.id ??
agents[0]?.id ??
DEFAULT_AGENT_ID;
const agentId = normalizeAgentId(defaultAgentId);
const mainKey = normalizeMainKey(cfg?.session?.mainKey);
return buildAgentMainSessionKey({ agentId, mainKey });
}
export function resolveMainSessionKeyFromConfig(): string {
return resolveMainSessionKey(loadConfig());
}
export { resolveAgentIdFromSessionKey };
export function resolveAgentMainSessionKey(params: {
cfg?: { session?: { mainKey?: string } };
agentId: string;
}): string {
const mainKey = normalizeMainKey(params.cfg?.session?.mainKey);
return buildAgentMainSessionKey({ agentId: params.agentId, mainKey });
}
export function canonicalizeMainSessionAlias(params: {
cfg?: { session?: { scope?: SessionScope; mainKey?: string } };
agentId: string;
sessionKey: string;
}): string {
const raw = params.sessionKey.trim();
if (!raw) return raw;
const agentId = normalizeAgentId(params.agentId);
const mainKey = normalizeMainKey(params.cfg?.session?.mainKey);
const agentMainSessionKey = buildAgentMainSessionKey({ agentId, mainKey });
const agentMainAliasKey = buildAgentMainSessionKey({
agentId,
mainKey: "main",
});
const isMainAlias =
raw === "main" ||
raw === mainKey ||
raw === agentMainSessionKey ||
raw === agentMainAliasKey;
if (params.cfg?.session?.scope === "global" && isMainAlias) return "global";
if (isMainAlias) return agentMainSessionKey;
return raw;
}
+75
View File
@@ -0,0 +1,75 @@
import os from "node:os";
import path from "node:path";
import {
DEFAULT_AGENT_ID,
normalizeAgentId,
} from "../../routing/session-key.js";
import { resolveStateDir } from "../paths.js";
import type { SessionEntry } from "./types.js";
function resolveAgentSessionsDir(
agentId?: string,
env: NodeJS.ProcessEnv = process.env,
homedir: () => string = os.homedir,
): string {
const root = resolveStateDir(env, homedir);
const id = normalizeAgentId(agentId ?? DEFAULT_AGENT_ID);
return path.join(root, "agents", id, "sessions");
}
export function resolveSessionTranscriptsDir(
env: NodeJS.ProcessEnv = process.env,
homedir: () => string = os.homedir,
): string {
return resolveAgentSessionsDir(DEFAULT_AGENT_ID, env, homedir);
}
export function resolveSessionTranscriptsDirForAgent(
agentId?: string,
env: NodeJS.ProcessEnv = process.env,
homedir: () => string = os.homedir,
): string {
return resolveAgentSessionsDir(agentId, env, homedir);
}
export function resolveDefaultSessionStorePath(agentId?: string): string {
return path.join(resolveAgentSessionsDir(agentId), "sessions.json");
}
export function resolveSessionTranscriptPath(
sessionId: string,
agentId?: string,
topicId?: number,
): string {
const fileName =
topicId !== undefined
? `${sessionId}-topic-${topicId}.jsonl`
: `${sessionId}.jsonl`;
return path.join(resolveAgentSessionsDir(agentId), fileName);
}
export function resolveSessionFilePath(
sessionId: string,
entry?: SessionEntry,
opts?: { agentId?: string },
): string {
const candidate = entry?.sessionFile?.trim();
return candidate
? candidate
: resolveSessionTranscriptPath(sessionId, opts?.agentId);
}
export function resolveStorePath(store?: string, opts?: { agentId?: string }) {
const agentId = normalizeAgentId(opts?.agentId ?? DEFAULT_AGENT_ID);
if (!store) return resolveDefaultSessionStorePath(agentId);
if (store.includes("{agentId}")) {
const expanded = store.replaceAll("{agentId}", agentId);
if (expanded.startsWith("~")) {
return path.resolve(expanded.replace(/^~(?=$|[\\/])/, os.homedir()));
}
return path.resolve(expanded);
}
if (store.startsWith("~"))
return path.resolve(store.replace(/^~(?=$|[\\/])/, os.homedir()));
return path.resolve(store);
}
+44
View File
@@ -0,0 +1,44 @@
import type { MsgContext } from "../../auto-reply/templating.js";
import {
buildAgentMainSessionKey,
DEFAULT_AGENT_ID,
normalizeMainKey,
} from "../../routing/session-key.js";
import { normalizeE164 } from "../../utils.js";
import { resolveGroupSessionKey } from "./group.js";
import type { SessionScope } from "./types.js";
// Decide which session bucket to use (per-sender vs global).
export function deriveSessionKey(scope: SessionScope, ctx: MsgContext) {
if (scope === "global") return "global";
const resolvedGroup = resolveGroupSessionKey(ctx);
if (resolvedGroup) return resolvedGroup.key;
const from = ctx.From ? normalizeE164(ctx.From) : "";
return from || "unknown";
}
/**
* Resolve the session key with a canonical direct-chat bucket (default: "main").
* All non-group direct chats collapse to this bucket; groups stay isolated.
*/
export function resolveSessionKey(
scope: SessionScope,
ctx: MsgContext,
mainKey?: string,
) {
const explicit = ctx.SessionKey?.trim();
if (explicit) return explicit;
const raw = deriveSessionKey(scope, ctx);
if (scope === "global") return raw;
const canonicalMainKey = normalizeMainKey(mainKey);
const canonical = buildAgentMainSessionKey({
agentId: DEFAULT_AGENT_ID,
mainKey: canonicalMainKey,
});
const isGroup =
raw.startsWith("group:") ||
raw.includes(":group:") ||
raw.includes(":channel:");
if (!isGroup) return canonical;
return `agent:${DEFAULT_AGENT_ID}:${raw}`;
}
+299
View File
@@ -0,0 +1,299 @@
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import JSON5 from "json5";
import {
getFileMtimeMs,
isCacheEnabled,
resolveCacheTtlMs,
} from "../cache-utils.js";
import { mergeSessionEntry, type SessionEntry } from "./types.js";
// ============================================================================
// Session Store Cache with TTL Support
// ============================================================================
type SessionStoreCacheEntry = {
store: Record<string, SessionEntry>;
loadedAt: number;
storePath: string;
mtimeMs?: number;
};
const SESSION_STORE_CACHE = new Map<string, SessionStoreCacheEntry>();
const DEFAULT_SESSION_STORE_TTL_MS = 45_000; // 45 seconds (between 30-60s)
function getSessionStoreTtl(): number {
return resolveCacheTtlMs({
envValue: process.env.CLAWDBOT_SESSION_CACHE_TTL_MS,
defaultTtlMs: DEFAULT_SESSION_STORE_TTL_MS,
});
}
function isSessionStoreCacheEnabled(): boolean {
return isCacheEnabled(getSessionStoreTtl());
}
function isSessionStoreCacheValid(entry: SessionStoreCacheEntry): boolean {
const now = Date.now();
const ttl = getSessionStoreTtl();
return now - entry.loadedAt <= ttl;
}
function invalidateSessionStoreCache(storePath: string): void {
SESSION_STORE_CACHE.delete(storePath);
}
export function clearSessionStoreCacheForTest(): void {
SESSION_STORE_CACHE.clear();
}
export function loadSessionStore(
storePath: string,
): Record<string, SessionEntry> {
// Check cache first if enabled
if (isSessionStoreCacheEnabled()) {
const cached = SESSION_STORE_CACHE.get(storePath);
if (cached && isSessionStoreCacheValid(cached)) {
const currentMtimeMs = getFileMtimeMs(storePath);
if (currentMtimeMs === cached.mtimeMs) {
// Return a shallow copy to prevent external mutations affecting cache
return { ...cached.store };
}
invalidateSessionStoreCache(storePath);
}
}
// Cache miss or disabled - load from disk
let store: Record<string, SessionEntry> = {};
let mtimeMs = getFileMtimeMs(storePath);
try {
const raw = fs.readFileSync(storePath, "utf-8");
const parsed = JSON5.parse(raw);
if (parsed && typeof parsed === "object") {
store = parsed as Record<string, SessionEntry>;
}
mtimeMs = getFileMtimeMs(storePath) ?? mtimeMs;
} catch {
// ignore missing/invalid store; we'll recreate it
}
// Best-effort migration: message provider → channel naming.
for (const entry of Object.values(store)) {
if (!entry || typeof entry !== "object") continue;
const rec = entry as unknown as Record<string, unknown>;
if (typeof rec.channel !== "string" && typeof rec.provider === "string") {
rec.channel = rec.provider;
delete rec.provider;
}
if (
typeof rec.lastChannel !== "string" &&
typeof rec.lastProvider === "string"
) {
rec.lastChannel = rec.lastProvider;
delete rec.lastProvider;
}
}
// Cache the result if caching is enabled
if (isSessionStoreCacheEnabled()) {
SESSION_STORE_CACHE.set(storePath, {
store: { ...store }, // Store a copy to prevent external mutations
loadedAt: Date.now(),
storePath,
mtimeMs,
});
}
return store;
}
async function saveSessionStoreUnlocked(
storePath: string,
store: Record<string, SessionEntry>,
): Promise<void> {
// Invalidate cache on write to ensure consistency
invalidateSessionStoreCache(storePath);
await fs.promises.mkdir(path.dirname(storePath), { recursive: true });
const json = JSON.stringify(store, null, 2);
// Windows: avoid atomic rename swaps (can be flaky under concurrent access).
// We serialize writers via the session-store lock instead.
if (process.platform === "win32") {
try {
await fs.promises.writeFile(storePath, json, "utf-8");
} catch (err) {
const code =
err && typeof err === "object" && "code" in err
? String((err as { code?: unknown }).code)
: null;
if (code === "ENOENT") return;
throw err;
}
return;
}
const tmp = `${storePath}.${process.pid}.${crypto.randomUUID()}.tmp`;
try {
await fs.promises.writeFile(tmp, json, "utf-8");
await fs.promises.rename(tmp, storePath);
} catch (err) {
const code =
err && typeof err === "object" && "code" in err
? String((err as { code?: unknown }).code)
: null;
if (code === "ENOENT") {
// In tests the temp session-store directory may be deleted while writes are in-flight.
// Best-effort: try a direct write (recreating the parent dir), otherwise ignore.
try {
await fs.promises.mkdir(path.dirname(storePath), { recursive: true });
await fs.promises.writeFile(storePath, json, "utf-8");
} catch (err2) {
const code2 =
err2 && typeof err2 === "object" && "code" in err2
? String((err2 as { code?: unknown }).code)
: null;
if (code2 === "ENOENT") return;
throw err2;
}
return;
}
throw err;
} finally {
await fs.promises.rm(tmp, { force: true });
}
}
export async function saveSessionStore(
storePath: string,
store: Record<string, SessionEntry>,
): Promise<void> {
await withSessionStoreLock(storePath, async () => {
await saveSessionStoreUnlocked(storePath, store);
});
}
type SessionStoreLockOptions = {
timeoutMs?: number;
pollIntervalMs?: number;
staleMs?: number;
};
async function withSessionStoreLock<T>(
storePath: string,
fn: () => Promise<T>,
opts: SessionStoreLockOptions = {},
): Promise<T> {
const timeoutMs = opts.timeoutMs ?? 10_000;
const pollIntervalMs = opts.pollIntervalMs ?? 25;
const staleMs = opts.staleMs ?? 30_000;
const lockPath = `${storePath}.lock`;
const startedAt = Date.now();
await fs.promises.mkdir(path.dirname(storePath), { recursive: true });
while (true) {
try {
const handle = await fs.promises.open(lockPath, "wx");
try {
await handle.writeFile(
JSON.stringify({ pid: process.pid, startedAt: Date.now() }),
"utf-8",
);
} catch {
// best-effort
}
await handle.close();
break;
} catch (err) {
const code =
err && typeof err === "object" && "code" in err
? String((err as { code?: unknown }).code)
: null;
if (code === "ENOENT") {
// Store directory may be deleted/recreated in tests while writes are in-flight.
// Best-effort: recreate the parent dir and retry until timeout.
await fs.promises
.mkdir(path.dirname(storePath), { recursive: true })
.catch(() => undefined);
await new Promise((r) => setTimeout(r, pollIntervalMs));
continue;
}
if (code !== "EEXIST") throw err;
const now = Date.now();
if (now - startedAt > timeoutMs) {
throw new Error(`timeout acquiring session store lock: ${lockPath}`);
}
// Best-effort stale lock eviction (e.g. crashed process).
try {
const st = await fs.promises.stat(lockPath);
const ageMs = now - st.mtimeMs;
if (ageMs > staleMs) {
await fs.promises.unlink(lockPath);
continue;
}
} catch {
// ignore
}
await new Promise((r) => setTimeout(r, pollIntervalMs));
}
}
try {
return await fn();
} finally {
await fs.promises.unlink(lockPath).catch(() => undefined);
}
}
export async function updateSessionStoreEntry(params: {
storePath: string;
sessionKey: string;
update: (entry: SessionEntry) => Promise<Partial<SessionEntry> | null>;
}): Promise<SessionEntry | null> {
const { storePath, sessionKey, update } = params;
return await withSessionStoreLock(storePath, async () => {
const store = loadSessionStore(storePath);
const existing = store[sessionKey];
if (!existing) return null;
const patch = await update(existing);
if (!patch) return existing;
const next = mergeSessionEntry(existing, patch);
store[sessionKey] = next;
await saveSessionStoreUnlocked(storePath, store);
return next;
});
}
export async function updateLastRoute(params: {
storePath: string;
sessionKey: string;
channel: SessionEntry["lastChannel"];
to?: string;
accountId?: string;
}) {
const { storePath, sessionKey, channel, to, accountId } = params;
return await withSessionStoreLock(storePath, async () => {
const store = loadSessionStore(storePath);
const existing = store[sessionKey];
const now = Date.now();
const next = mergeSessionEntry(existing, {
updatedAt: Math.max(existing?.updatedAt ?? 0, now),
lastChannel: channel,
lastTo: to?.trim() ? to.trim() : undefined,
lastAccountId: accountId?.trim()
? accountId.trim()
: existing?.lastAccountId,
});
store[sessionKey] = next;
await saveSessionStoreUnlocked(storePath, store);
return next;
});
}
+97
View File
@@ -0,0 +1,97 @@
import crypto from "node:crypto";
import type { Skill } from "@mariozechner/pi-coding-agent";
import type { ChannelId } from "../../channels/plugins/types.js";
export type SessionScope = "per-sender" | "global";
export type SessionChannelId = ChannelId | "webchat";
export type SessionChatType = "direct" | "group" | "room";
export type SessionEntry = {
sessionId: string;
updatedAt: number;
sessionFile?: string;
/** Parent session key that spawned this session (used for sandbox session-tool scoping). */
spawnedBy?: string;
systemSent?: boolean;
abortedLastRun?: boolean;
chatType?: SessionChatType;
thinkingLevel?: string;
verboseLevel?: string;
reasoningLevel?: string;
elevatedLevel?: string;
responseUsage?: "on" | "off";
providerOverride?: string;
modelOverride?: string;
authProfileOverride?: string;
groupActivation?: "mention" | "always";
groupActivationNeedsSystemIntro?: boolean;
sendPolicy?: "allow" | "deny";
queueMode?:
| "steer"
| "followup"
| "collect"
| "steer-backlog"
| "steer+backlog"
| "queue"
| "interrupt";
queueDebounceMs?: number;
queueCap?: number;
queueDrop?: "old" | "new" | "summarize";
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
modelProvider?: string;
model?: string;
contextTokens?: number;
compactionCount?: number;
memoryFlushAt?: number;
memoryFlushCompactionCount?: number;
cliSessionIds?: Record<string, string>;
claudeCliSessionId?: string;
label?: string;
displayName?: string;
channel?: string;
subject?: string;
room?: string;
space?: string;
lastChannel?: SessionChannelId;
lastTo?: string;
lastAccountId?: string;
skillsSnapshot?: SessionSkillSnapshot;
};
export function mergeSessionEntry(
existing: SessionEntry | undefined,
patch: Partial<SessionEntry>,
): SessionEntry {
const sessionId =
patch.sessionId ?? existing?.sessionId ?? crypto.randomUUID();
const updatedAt = Math.max(
existing?.updatedAt ?? 0,
patch.updatedAt ?? 0,
Date.now(),
);
if (!existing) return { ...patch, sessionId, updatedAt };
return { ...existing, ...patch, sessionId, updatedAt };
}
export type GroupKeyResolution = {
key: string;
legacyKey?: string;
channel?: string;
id?: string;
chatType?: SessionChatType;
};
export type SessionSkillSnapshot = {
prompt: string;
skills: Array<{ name: string; primaryEnv?: string }>;
resolvedSkills?: Skill[];
};
export const DEFAULT_RESET_TRIGGER = "/new";
export const DEFAULT_RESET_TRIGGERS = ["/new", "/reset"];
export const DEFAULT_IDLE_MINUTES = 60;
+40
View File
@@ -0,0 +1,40 @@
import { vi } from "vitest";
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
export async function withTempHome<T>(
fn: (home: string) => Promise<T>,
): Promise<T> {
return withTempHomeBase(fn, { prefix: "clawdbot-config-" });
}
/**
* Helper to test env var overrides. Saves/restores env vars and resets modules.
*/
export async function withEnvOverride<T>(
overrides: Record<string, string | undefined>,
fn: () => Promise<T>,
): Promise<T> {
const saved: Record<string, string | undefined> = {};
for (const key of Object.keys(overrides)) {
saved[key] = process.env[key];
if (overrides[key] === undefined) {
delete process.env[key];
} else {
process.env[key] = overrides[key];
}
}
vi.resetModules();
try {
return await fn();
} finally {
for (const key of Object.keys(saved)) {
if (saved[key] === undefined) {
delete process.env[key];
} else {
process.env[key] = saved[key];
}
}
vi.resetModules();
}
}
+239
View File
@@ -0,0 +1,239 @@
import type {
BlockStreamingChunkConfig,
BlockStreamingCoalesceConfig,
HumanDelayConfig,
TypingMode,
} from "./types.base.js";
import type {
SandboxBrowserSettings,
SandboxDockerSettings,
SandboxPruneSettings,
} from "./types.sandbox.js";
import type { MemorySearchConfig } from "./types.tools.js";
export type AgentModelEntryConfig = {
alias?: string;
/** Provider-specific API parameters (e.g., GLM-4.7 thinking mode). */
params?: Record<string, unknown>;
};
export type AgentModelListConfig = {
primary?: string;
fallbacks?: string[];
};
export type AgentContextPruningConfig = {
mode?: "off" | "adaptive" | "aggressive";
keepLastAssistants?: number;
softTrimRatio?: number;
hardClearRatio?: number;
minPrunableToolChars?: number;
tools?: {
allow?: string[];
deny?: string[];
};
softTrim?: {
maxChars?: number;
headChars?: number;
tailChars?: number;
};
hardClear?: {
enabled?: boolean;
placeholder?: string;
};
};
export type CliBackendConfig = {
/** CLI command to execute (absolute path or on PATH). */
command: string;
/** Base args applied to every invocation. */
args?: string[];
/** Output parsing mode (default: json). */
output?: "json" | "text" | "jsonl";
/** Output parsing mode when resuming a CLI session. */
resumeOutput?: "json" | "text" | "jsonl";
/** Prompt input mode (default: arg). */
input?: "arg" | "stdin";
/** Max prompt length for arg mode (if exceeded, stdin is used). */
maxPromptArgChars?: number;
/** Extra env vars injected for this CLI. */
env?: Record<string, string>;
/** Env vars to remove before launching this CLI. */
clearEnv?: string[];
/** Flag used to pass model id (e.g. --model). */
modelArg?: string;
/** Model aliases mapping (config model id → CLI model id). */
modelAliases?: Record<string, string>;
/** Flag used to pass session id (e.g. --session-id). */
sessionArg?: string;
/** Extra args used when resuming a session (use {sessionId} placeholder). */
sessionArgs?: string[];
/** Alternate args to use when resuming a session (use {sessionId} placeholder). */
resumeArgs?: string[];
/** When to pass session ids. */
sessionMode?: "always" | "existing" | "none";
/** JSON fields to read session id from (in order). */
sessionIdFields?: string[];
/** Flag used to pass system prompt. */
systemPromptArg?: string;
/** System prompt behavior (append vs replace). */
systemPromptMode?: "append" | "replace";
/** When to send system prompt. */
systemPromptWhen?: "first" | "always" | "never";
/** Flag used to pass image paths. */
imageArg?: string;
/** How to pass multiple images. */
imageMode?: "repeat" | "list";
/** Serialize runs for this CLI. */
serialize?: boolean;
};
export type AgentDefaultsConfig = {
/** Primary model and fallbacks (provider/model). */
model?: AgentModelListConfig;
/** Optional image-capable model and fallbacks (provider/model). */
imageModel?: AgentModelListConfig;
/** Model catalog with optional aliases (full provider/model keys). */
models?: Record<string, AgentModelEntryConfig>;
/** Agent working directory (preferred). Used as the default cwd for agent runs. */
workspace?: string;
/** Skip bootstrap (BOOTSTRAP.md creation, etc.) for pre-configured deployments. */
skipBootstrap?: boolean;
/** Max chars for injected bootstrap files before truncation (default: 20000). */
bootstrapMaxChars?: number;
/** Optional IANA timezone for the user (used in system prompt; defaults to host timezone). */
userTimezone?: string;
/** Optional display-only context window override (used for % in status UIs). */
contextTokens?: number;
/** Optional CLI backends for text-only fallback (claude-cli, etc.). */
cliBackends?: Record<string, CliBackendConfig>;
/** Opt-in: prune old tool results from the LLM context to reduce token usage. */
contextPruning?: AgentContextPruningConfig;
/** Compaction tuning and pre-compaction memory flush behavior. */
compaction?: AgentCompactionConfig;
/** Vector memory search configuration (per-agent overrides supported). */
memorySearch?: MemorySearchConfig;
/** Default thinking level when no /think directive is present. */
thinkingDefault?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
/** Default verbose level when no /verbose directive is present. */
verboseDefault?: "off" | "on";
/** Default elevated level when no /elevated directive is present. */
elevatedDefault?: "off" | "on";
/** Default block streaming level when no override is present. */
blockStreamingDefault?: "off" | "on";
/**
* Block streaming boundary:
* - "text_end": end of each assistant text content block (before tool calls)
* - "message_end": end of the whole assistant message (may include tool blocks)
*/
blockStreamingBreak?: "text_end" | "message_end";
/** Soft block chunking for streamed replies (min/max chars, prefer paragraph/newline). */
blockStreamingChunk?: BlockStreamingChunkConfig;
/**
* Block reply coalescing (merge streamed chunks before send).
* idleMs: wait time before flushing when idle.
*/
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
/** Human-like delay between block replies. */
humanDelay?: HumanDelayConfig;
timeoutSeconds?: number;
/** Max inbound media size in MB for agent-visible attachments (text note or future image attach). */
mediaMaxMb?: number;
typingIntervalSeconds?: number;
/** Typing indicator start mode (never|instant|thinking|message). */
typingMode?: TypingMode;
/** Periodic background heartbeat runs. */
heartbeat?: {
/** Heartbeat interval (duration string, default unit: minutes; default: 30m). */
every?: string;
/** Heartbeat model override (provider/model). */
model?: string;
/** Delivery target (last|whatsapp|telegram|discord|slack|msteams|signal|imessage|none). */
target?:
| "last"
| "whatsapp"
| "telegram"
| "discord"
| "slack"
| "msteams"
| "signal"
| "imessage"
| "none";
/** Optional delivery override (E.164 for WhatsApp, chat id for Telegram). */
to?: string;
/** Override the heartbeat prompt body (default: "Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time."). */
prompt?: string;
/** Max chars allowed after HEARTBEAT_OK before delivery (default: 30). */
ackMaxChars?: number;
/**
* When enabled, deliver the model's reasoning payload for heartbeat runs (when available)
* as a separate message prefixed with `Reasoning:` (same as `/reasoning on`).
*
* Default: false (only the final heartbeat payload is delivered).
*/
includeReasoning?: boolean;
};
/** Max concurrent agent runs across all conversations. Default: 1 (sequential). */
maxConcurrent?: number;
/** Sub-agent defaults (spawned via sessions_spawn). */
subagents?: {
/** Max concurrent sub-agent runs (global lane: "subagent"). Default: 1. */
maxConcurrent?: number;
/** Auto-archive sub-agent sessions after N minutes (default: 60). */
archiveAfterMinutes?: number;
/** Default model selection for spawned sub-agents (string or {primary,fallbacks}). */
model?: string | { primary?: string; fallbacks?: string[] };
};
/** Optional sandbox settings for non-main sessions. */
sandbox?: {
/** Enable sandboxing for sessions. */
mode?: "off" | "non-main" | "all";
/**
* Agent workspace access inside the sandbox.
* - "none": do not mount the agent workspace into the container; use a sandbox workspace under workspaceRoot
* - "ro": mount the agent workspace read-only; disables write/edit tools
* - "rw": mount the agent workspace read/write; enables write/edit tools
*/
workspaceAccess?: "none" | "ro" | "rw";
/**
* Session tools visibility for sandboxed sessions.
* - "spawned": only allow session tools to target sessions spawned from this session (default)
* - "all": allow session tools to target any session
*/
sessionToolsVisibility?: "spawned" | "all";
/** Container/workspace scope for sandbox isolation. */
scope?: "session" | "agent" | "shared";
/** Legacy alias for scope ("session" when true, "shared" when false). */
perSession?: boolean;
/** Root directory for sandbox workspaces. */
workspaceRoot?: string;
/** Docker-specific sandbox settings. */
docker?: SandboxDockerSettings;
/** Optional sandboxed browser settings. */
browser?: SandboxBrowserSettings;
/** Auto-prune sandbox containers. */
prune?: SandboxPruneSettings;
};
};
export type AgentCompactionMode = "default" | "safeguard";
export type AgentCompactionConfig = {
/** Compaction summarization mode. */
mode?: AgentCompactionMode;
/** Minimum reserve tokens enforced for Pi compaction (0 disables the floor). */
reserveTokensFloor?: number;
/** Pre-compaction memory flush (agentic turn). Default: enabled. */
memoryFlush?: AgentCompactionMemoryFlushConfig;
};
export type AgentCompactionMemoryFlushConfig = {
/** Enable the pre-compaction memory flush (default: true). */
enabled?: boolean;
/** Run the memory flush when context is within this many tokens of the compaction threshold. */
softThresholdTokens?: number;
/** User prompt used for the memory flush turn (NO_REPLY is enforced if missing). */
prompt?: string;
/** System prompt appended for the memory flush turn. */
systemPrompt?: string;
};
+77
View File
@@ -0,0 +1,77 @@
import type { AgentDefaultsConfig } from "./types.agent-defaults.js";
import type { HumanDelayConfig, IdentityConfig } from "./types.base.js";
import type { GroupChatConfig } from "./types.messages.js";
import type {
SandboxBrowserSettings,
SandboxDockerSettings,
SandboxPruneSettings,
} from "./types.sandbox.js";
import type { AgentToolsConfig, MemorySearchConfig } from "./types.tools.js";
export type AgentModelConfig =
| string
| {
/** Primary model (provider/model). */
primary?: string;
/** Per-agent model fallbacks (provider/model). */
fallbacks?: string[];
};
export type AgentConfig = {
id: string;
default?: boolean;
name?: string;
workspace?: string;
agentDir?: string;
model?: AgentModelConfig;
memorySearch?: MemorySearchConfig;
/** Human-like delay between block replies for this agent. */
humanDelay?: HumanDelayConfig;
identity?: IdentityConfig;
groupChat?: GroupChatConfig;
subagents?: {
/** Allow spawning sub-agents under other agent ids. Use "*" to allow any. */
allowAgents?: string[];
/** Per-agent default model for spawned sub-agents (string or {primary,fallbacks}). */
model?: string | { primary?: string; fallbacks?: string[] };
};
sandbox?: {
mode?: "off" | "non-main" | "all";
/** Agent workspace access inside the sandbox. */
workspaceAccess?: "none" | "ro" | "rw";
/**
* Session tools visibility for sandboxed sessions.
* - "spawned": only allow session tools to target sessions spawned from this session (default)
* - "all": allow session tools to target any session
*/
sessionToolsVisibility?: "spawned" | "all";
/** Container/workspace scope for sandbox isolation. */
scope?: "session" | "agent" | "shared";
/** Legacy alias for scope ("session" when true, "shared" when false). */
perSession?: boolean;
workspaceRoot?: string;
/** Docker-specific sandbox overrides for this agent. */
docker?: SandboxDockerSettings;
/** Optional sandboxed browser overrides for this agent. */
browser?: SandboxBrowserSettings;
/** Auto-prune overrides for this agent. */
prune?: SandboxPruneSettings;
};
tools?: AgentToolsConfig;
};
export type AgentsConfig = {
defaults?: AgentDefaultsConfig;
list?: AgentConfig[];
};
export type AgentBinding = {
agentId: string;
match: {
channel: string;
accountId?: string;
peer?: { kind: "dm" | "group" | "channel"; id: string };
guildId?: string;
teamId?: string;
};
};
+29
View File
@@ -0,0 +1,29 @@
export type AuthProfileConfig = {
provider: string;
/**
* Credential type expected in auth-profiles.json for this profile id.
* - api_key: static provider API key
* - oauth: refreshable OAuth credentials (access+refresh+expires)
* - token: static bearer-style token (optionally expiring; no refresh)
*/
mode: "api_key" | "oauth" | "token";
email?: string;
};
export type AuthConfig = {
profiles?: Record<string, AuthProfileConfig>;
order?: Record<string, string[]>;
cooldowns?: {
/** Default billing backoff (hours). Default: 5. */
billingBackoffHours?: number;
/** Optional per-provider billing backoff (hours). */
billingBackoffHoursByProvider?: Record<string, number>;
/** Billing backoff cap (hours). Default: 24. */
billingMaxHours?: number;
/**
* Failure window for backoff counters (hours). If no failures occur within
* this window, counters reset. Default: 24.
*/
failureWindowHours?: number;
};
};
+113
View File
@@ -0,0 +1,113 @@
export type ReplyMode = "text" | "command";
export type TypingMode = "never" | "instant" | "thinking" | "message";
export type SessionScope = "per-sender" | "global";
export type ReplyToMode = "off" | "first" | "all";
export type GroupPolicy = "open" | "disabled" | "allowlist";
export type DmPolicy = "pairing" | "allowlist" | "open" | "disabled";
export type OutboundRetryConfig = {
/** Max retry attempts for outbound requests (default: 3). */
attempts?: number;
/** Minimum retry delay in ms (default: 300-500ms depending on provider). */
minDelayMs?: number;
/** Maximum retry delay cap in ms (default: 30000). */
maxDelayMs?: number;
/** Jitter factor (0-1) applied to delays (default: 0.1). */
jitter?: number;
};
export type BlockStreamingCoalesceConfig = {
minChars?: number;
maxChars?: number;
idleMs?: number;
};
export type BlockStreamingChunkConfig = {
minChars?: number;
maxChars?: number;
breakPreference?: "paragraph" | "newline" | "sentence";
};
export type HumanDelayConfig = {
/** Delay style for block replies (off|natural|custom). */
mode?: "off" | "natural" | "custom";
/** Minimum delay in milliseconds (default: 800). */
minMs?: number;
/** Maximum delay in milliseconds (default: 2500). */
maxMs?: number;
};
export type SessionSendPolicyAction = "allow" | "deny";
export type SessionSendPolicyMatch = {
channel?: string;
chatType?: "direct" | "group" | "room";
keyPrefix?: string;
};
export type SessionSendPolicyRule = {
action: SessionSendPolicyAction;
match?: SessionSendPolicyMatch;
};
export type SessionSendPolicyConfig = {
default?: SessionSendPolicyAction;
rules?: SessionSendPolicyRule[];
};
export type SessionConfig = {
scope?: SessionScope;
resetTriggers?: string[];
idleMinutes?: number;
heartbeatIdleMinutes?: number;
store?: string;
typingIntervalSeconds?: number;
typingMode?: TypingMode;
mainKey?: string;
sendPolicy?: SessionSendPolicyConfig;
agentToAgent?: {
/** Max ping-pong turns between requester/target (05). Default: 5. */
maxPingPongTurns?: number;
};
};
export type LoggingConfig = {
level?: "silent" | "fatal" | "error" | "warn" | "info" | "debug" | "trace";
file?: string;
consoleLevel?:
| "silent"
| "fatal"
| "error"
| "warn"
| "info"
| "debug"
| "trace";
consoleStyle?: "pretty" | "compact" | "json";
/** Redact sensitive tokens in tool summaries. Default: "tools". */
redactSensitive?: "off" | "tools";
/** Regex patterns used to redact sensitive tokens (defaults apply when unset). */
redactPatterns?: string[];
};
export type WebReconnectConfig = {
initialMs?: number;
maxMs?: number;
factor?: number;
jitter?: number;
maxAttempts?: number; // 0 = unlimited
};
export type WebConfig = {
/** If false, do not start the WhatsApp web provider. Default: true. */
enabled?: boolean;
heartbeatSeconds?: number;
reconnect?: WebReconnectConfig;
};
// Provider docking: allowlists keyed by provider id (and internal "webchat").
export type AgentElevatedAllowFromConfig = Partial<
Record<string, Array<string | number>>
>;
export type IdentityConfig = {
name?: string;
theme?: string;
emoji?: string;
};
+29
View File
@@ -0,0 +1,29 @@
export type BrowserProfileConfig = {
/** CDP port for this profile. Allocated once at creation, persisted permanently. */
cdpPort?: number;
/** CDP URL for this profile (use for remote Chrome). */
cdpUrl?: string;
/** Profile color (hex). Auto-assigned at creation. */
color: string;
};
export type BrowserConfig = {
enabled?: boolean;
/** Base URL of the clawd browser control server. Default: http://127.0.0.1:18791 */
controlUrl?: string;
/** Base URL of the CDP endpoint. Default: controlUrl with port + 1. */
cdpUrl?: string;
/** Accent color for the clawd browser profile (hex). Default: #FF4500 */
color?: string;
/** Override the browser executable path (macOS/Linux). */
executablePath?: string;
/** Start Chrome headless (best-effort). Default: false */
headless?: boolean;
/** Pass --no-sandbox to Chrome (Linux containers). Default: false */
noSandbox?: boolean;
/** If true: never launch; only attach to an existing browser. Default: false */
attachOnly?: boolean;
/** Default profile to use when profile param is omitted. Default: "clawd" */
defaultProfile?: string;
/** Named browser profiles with explicit CDP ports or URLs. */
profiles?: Record<string, BrowserProfileConfig>;
};
+17
View File
@@ -0,0 +1,17 @@
import type { DiscordConfig } from "./types.discord.js";
import type { IMessageConfig } from "./types.imessage.js";
import type { MSTeamsConfig } from "./types.msteams.js";
import type { SignalConfig } from "./types.signal.js";
import type { SlackConfig } from "./types.slack.js";
import type { TelegramConfig } from "./types.telegram.js";
import type { WhatsAppConfig } from "./types.whatsapp.js";
export type ChannelsConfig = {
whatsapp?: WhatsAppConfig;
telegram?: TelegramConfig;
discord?: DiscordConfig;
slack?: SlackConfig;
signal?: SignalConfig;
imessage?: IMessageConfig;
msteams?: MSTeamsConfig;
};
+98
View File
@@ -0,0 +1,98 @@
import type { AgentBinding, AgentsConfig } from "./types.agents.js";
import type { AuthConfig } from "./types.auth.js";
import type { LoggingConfig, SessionConfig, WebConfig } from "./types.base.js";
import type { BrowserConfig } from "./types.browser.js";
import type { ChannelsConfig } from "./types.channels.js";
import type { CronConfig } from "./types.cron.js";
import type {
BridgeConfig,
CanvasHostConfig,
DiscoveryConfig,
GatewayConfig,
TalkConfig,
} from "./types.gateway.js";
import type { HooksConfig } from "./types.hooks.js";
import type {
AudioConfig,
BroadcastConfig,
CommandsConfig,
MessagesConfig,
} from "./types.messages.js";
import type { ModelsConfig } from "./types.models.js";
import type { PluginsConfig } from "./types.plugins.js";
import type { SkillsConfig } from "./types.skills.js";
import type { ToolsConfig } from "./types.tools.js";
export type ClawdbotConfig = {
auth?: AuthConfig;
env?: {
/** Opt-in: import missing secrets from a login shell environment (exec `$SHELL -l -c 'env -0'`). */
shellEnv?: {
enabled?: boolean;
/** Timeout for the login shell exec (ms). Default: 15000. */
timeoutMs?: number;
};
/** Inline env vars to apply when not already present in the process env. */
vars?: Record<string, string>;
/** Sugar: allow env vars directly under env (string values only). */
[key: string]:
| string
| Record<string, string>
| { enabled?: boolean; timeoutMs?: number }
| undefined;
};
wizard?: {
lastRunAt?: string;
lastRunVersion?: string;
lastRunCommit?: string;
lastRunCommand?: string;
lastRunMode?: "local" | "remote";
};
logging?: LoggingConfig;
browser?: BrowserConfig;
ui?: {
/** Accent color for Clawdbot UI chrome (hex). */
seamColor?: string;
};
skills?: SkillsConfig;
plugins?: PluginsConfig;
models?: ModelsConfig;
agents?: AgentsConfig;
tools?: ToolsConfig;
bindings?: AgentBinding[];
broadcast?: BroadcastConfig;
audio?: AudioConfig;
messages?: MessagesConfig;
commands?: CommandsConfig;
session?: SessionConfig;
web?: WebConfig;
channels?: ChannelsConfig;
cron?: CronConfig;
hooks?: HooksConfig;
bridge?: BridgeConfig;
discovery?: DiscoveryConfig;
canvasHost?: CanvasHostConfig;
talk?: TalkConfig;
gateway?: GatewayConfig;
};
export type ConfigValidationIssue = {
path: string;
message: string;
};
export type LegacyConfigIssue = {
path: string;
message: string;
};
export type ConfigFileSnapshot = {
path: string;
exists: boolean;
raw: string | null;
parsed: unknown;
valid: boolean;
config: ClawdbotConfig;
issues: ConfigValidationIssue[];
legacyIssues: LegacyConfigIssue[];
};
+5
View File
@@ -0,0 +1,5 @@
export type CronConfig = {
enabled?: boolean;
store?: string;
maxConcurrentRuns?: number;
};
+123
View File
@@ -0,0 +1,123 @@
import type {
BlockStreamingCoalesceConfig,
DmPolicy,
GroupPolicy,
OutboundRetryConfig,
ReplyToMode,
} from "./types.base.js";
import type { DmConfig, ProviderCommandsConfig } from "./types.messages.js";
export type DiscordDmConfig = {
/** If false, ignore all incoming Discord DMs. Default: true. */
enabled?: boolean;
/** Direct message access policy (default: pairing). */
policy?: DmPolicy;
/** Allowlist for DM senders (ids or names). */
allowFrom?: Array<string | number>;
/** If true, allow group DMs (default: false). */
groupEnabled?: boolean;
/** Optional allowlist for group DM channels (ids or slugs). */
groupChannels?: Array<string | number>;
};
export type DiscordGuildChannelConfig = {
allow?: boolean;
requireMention?: boolean;
/** If specified, only load these skills for this channel. Omit = all skills; empty = no skills. */
skills?: string[];
/** If false, disable the bot for this channel. */
enabled?: boolean;
/** Optional allowlist for channel senders (ids or names). */
users?: Array<string | number>;
/** Optional system prompt snippet for this channel. */
systemPrompt?: string;
};
export type DiscordReactionNotificationMode =
| "off"
| "own"
| "all"
| "allowlist";
export type DiscordGuildEntry = {
slug?: string;
requireMention?: boolean;
/** Reaction notification mode (off|own|all|allowlist). Default: own. */
reactionNotifications?: DiscordReactionNotificationMode;
users?: Array<string | number>;
channels?: Record<string, DiscordGuildChannelConfig>;
};
export type DiscordActionConfig = {
reactions?: boolean;
stickers?: boolean;
polls?: boolean;
permissions?: boolean;
messages?: boolean;
threads?: boolean;
pins?: boolean;
search?: boolean;
memberInfo?: boolean;
roleInfo?: boolean;
roles?: boolean;
channelInfo?: boolean;
voiceStatus?: boolean;
events?: boolean;
moderation?: boolean;
emojiUploads?: boolean;
stickerUploads?: boolean;
channels?: boolean;
};
export type DiscordAccountConfig = {
/** Optional display name for this account (used in CLI/UI lists). */
name?: string;
/** Optional provider capability tags used for agent/runtime guidance. */
capabilities?: string[];
/** Override native command registration for Discord (bool or "auto"). */
commands?: ProviderCommandsConfig;
/** If false, do not start this Discord account. Default: true. */
enabled?: boolean;
token?: string;
/** Allow bot-authored messages to trigger replies (default: false). */
allowBots?: boolean;
/**
* Controls how guild channel messages are handled:
* - "open": guild channels bypass allowlists; mention-gating applies
* - "disabled": block all guild channel messages
* - "allowlist": only allow channels present in discord.guilds.*.channels
*/
groupPolicy?: GroupPolicy;
/** Outbound text chunk size (chars). Default: 2000. */
textChunkLimit?: number;
/** Disable block streaming for this account. */
blockStreaming?: boolean;
/** Merge streamed block replies before sending. */
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
/**
* Soft max line count per Discord message.
* Discord clients can clip/collapse very tall messages; splitting by lines
* keeps replies readable in-channel. Default: 17.
*/
maxLinesPerMessage?: number;
mediaMaxMb?: number;
historyLimit?: number;
/** Max DM turns to keep as history context. */
dmHistoryLimit?: number;
/** Per-DM config overrides keyed by user ID. */
dms?: Record<string, DmConfig>;
/** Retry policy for outbound Discord API calls. */
retry?: OutboundRetryConfig;
/** Per-action tool gating (default: true for all). */
actions?: DiscordActionConfig;
/** Control reply threading when reply tags are present (off|first|all). */
replyToMode?: ReplyToMode;
dm?: DiscordDmConfig;
/** New per-guild config keyed by guild id or slug. */
guilds?: Record<string, DiscordGuildEntry>;
};
export type DiscordConfig = {
/** Optional per-account Discord configuration (multi-account). */
accounts?: Record<string, DiscordAccountConfig>;
} & DiscordAccountConfig;
+141
View File
@@ -0,0 +1,141 @@
export type BridgeBindMode = "auto" | "lan" | "loopback" | "custom";
export type BridgeConfig = {
enabled?: boolean;
port?: number;
/**
* Bind address policy for the node bridge server.
* - auto: Tailnet IPv4 if available, else 0.0.0.0 (fallback to all interfaces)
* - lan: 0.0.0.0 (all interfaces, no fallback)
* - loopback: 127.0.0.1 (local-only)
* - custom: User-specified IP, fallback to 0.0.0.0 if unavailable (requires customBindHost on gateway)
*/
bind?: BridgeBindMode;
};
export type WideAreaDiscoveryConfig = {
enabled?: boolean;
};
export type DiscoveryConfig = {
wideArea?: WideAreaDiscoveryConfig;
};
export type CanvasHostConfig = {
enabled?: boolean;
/** Directory to serve (default: ~/clawd/canvas). */
root?: string;
/** HTTP port to listen on (default: 18793). */
port?: number;
/** Enable live-reload file watching + WS reloads (default: true). */
liveReload?: boolean;
};
export type TalkConfig = {
/** Default ElevenLabs voice ID for Talk mode. */
voiceId?: string;
/** Optional voice name -> ElevenLabs voice ID map. */
voiceAliases?: Record<string, string>;
/** Default ElevenLabs model ID for Talk mode. */
modelId?: string;
/** Default ElevenLabs output format (e.g. mp3_44100_128). */
outputFormat?: string;
/** ElevenLabs API key (optional; falls back to ELEVENLABS_API_KEY). */
apiKey?: string;
/** Stop speaking when user starts talking (default: true). */
interruptOnSpeech?: boolean;
};
export type GatewayControlUiConfig = {
/** If false, the Gateway will not serve the Control UI (default /). */
enabled?: boolean;
/** Optional base path prefix for the Control UI (e.g. "/clawdbot"). */
basePath?: string;
};
export type GatewayAuthMode = "token" | "password";
export type GatewayAuthConfig = {
/** Authentication mode for Gateway connections. Defaults to token when set. */
mode?: GatewayAuthMode;
/** Shared token for token mode (stored locally for CLI auth). */
token?: string;
/** Shared password for password mode (consider env instead). */
password?: string;
/** Allow Tailscale identity headers when serve mode is enabled. */
allowTailscale?: boolean;
};
export type GatewayTailscaleMode = "off" | "serve" | "funnel";
export type GatewayTailscaleConfig = {
/** Tailscale exposure mode for the Gateway control UI. */
mode?: GatewayTailscaleMode;
/** Reset serve/funnel configuration on shutdown. */
resetOnExit?: boolean;
};
export type GatewayRemoteConfig = {
/** Remote Gateway WebSocket URL (ws:// or wss://). */
url?: string;
/** Token for remote auth (when the gateway requires token auth). */
token?: string;
/** Password for remote auth (when the gateway requires password auth). */
password?: string;
/** SSH target for tunneling remote Gateway (user@host). */
sshTarget?: string;
/** SSH identity file path for tunneling remote Gateway. */
sshIdentity?: string;
};
export type GatewayReloadMode = "off" | "restart" | "hot" | "hybrid";
export type GatewayReloadConfig = {
/** Reload strategy for config changes (default: hybrid). */
mode?: GatewayReloadMode;
/** Debounce window for config reloads (ms). Default: 300. */
debounceMs?: number;
};
export type GatewayHttpChatCompletionsConfig = {
/**
* If false, the Gateway will not serve `POST /v1/chat/completions`.
* Default: false when absent.
*/
enabled?: boolean;
};
export type GatewayHttpEndpointsConfig = {
chatCompletions?: GatewayHttpChatCompletionsConfig;
};
export type GatewayHttpConfig = {
endpoints?: GatewayHttpEndpointsConfig;
};
export type GatewayConfig = {
/** Single multiplexed port for Gateway WS + HTTP (default: 18789). */
port?: number;
/**
* Explicit gateway mode. When set to "remote", local gateway start is disabled.
* When set to "local", the CLI may start the gateway locally.
*/
mode?: "local" | "remote";
/**
* Bind address policy for the Gateway WebSocket + Control UI HTTP server.
* - auto: Tailnet IPv4 if available, else 0.0.0.0 (fallback to all interfaces)
* - lan: 0.0.0.0 (all interfaces, no fallback)
* - loopback: 127.0.0.1 (local-only)
* - custom: User-specified IP, fallback to 0.0.0.0 if unavailable (requires customBindHost)
* Default: loopback (127.0.0.1).
*/
bind?: BridgeBindMode;
/** Custom IP address for bind="custom" mode. Fallback: 0.0.0.0. */
customBindHost?: string;
controlUi?: GatewayControlUiConfig;
auth?: GatewayAuthConfig;
tailscale?: GatewayTailscaleConfig;
remote?: GatewayRemoteConfig;
reload?: GatewayReloadConfig;
http?: GatewayHttpConfig;
};
+76
View File
@@ -0,0 +1,76 @@
export type HookMappingMatch = {
path?: string;
source?: string;
};
export type HookMappingTransform = {
module: string;
export?: string;
};
export type HookMappingConfig = {
id?: string;
match?: HookMappingMatch;
action?: "wake" | "agent";
wakeMode?: "now" | "next-heartbeat";
name?: string;
sessionKey?: string;
messageTemplate?: string;
textTemplate?: string;
deliver?: boolean;
channel?:
| "last"
| "whatsapp"
| "telegram"
| "discord"
| "slack"
| "signal"
| "imessage"
| "msteams";
to?: string;
/** Override model for this hook (provider/model or alias). */
model?: string;
thinking?: string;
timeoutSeconds?: number;
transform?: HookMappingTransform;
};
export type HooksGmailTailscaleMode = "off" | "serve" | "funnel";
export type HooksGmailConfig = {
account?: string;
label?: string;
topic?: string;
subscription?: string;
pushToken?: string;
hookUrl?: string;
includeBody?: boolean;
maxBytes?: number;
renewEveryMinutes?: number;
serve?: {
bind?: string;
port?: number;
path?: string;
};
tailscale?: {
mode?: HooksGmailTailscaleMode;
path?: string;
/** Optional tailscale serve/funnel target (port, host:port, or full URL). */
target?: string;
};
/** Optional model override for Gmail hook processing (provider/model or alias). */
model?: string;
/** Optional thinking level override for Gmail hook processing. */
thinking?: "off" | "minimal" | "low" | "medium" | "high";
};
export type HooksConfig = {
enabled?: boolean;
path?: string;
token?: string;
maxBodyBytes?: number;
presets?: string[];
transformsDir?: string;
mappings?: HookMappingConfig[];
gmail?: HooksGmailConfig;
};
+62
View File
@@ -0,0 +1,62 @@
import type {
BlockStreamingCoalesceConfig,
DmPolicy,
GroupPolicy,
} from "./types.base.js";
import type { DmConfig } from "./types.messages.js";
export type IMessageAccountConfig = {
/** Optional display name for this account (used in CLI/UI lists). */
name?: string;
/** Optional provider capability tags used for agent/runtime guidance. */
capabilities?: string[];
/** If false, do not start this iMessage account. Default: true. */
enabled?: boolean;
/** imsg CLI binary path (default: imsg). */
cliPath?: string;
/** Optional Messages db path override. */
dbPath?: string;
/** Optional default send service (imessage|sms|auto). */
service?: "imessage" | "sms" | "auto";
/** Optional default region (used when sending SMS). */
region?: string;
/** Direct message access policy (default: pairing). */
dmPolicy?: DmPolicy;
/** Optional allowlist for inbound handles or chat_id targets. */
allowFrom?: Array<string | number>;
/** Optional allowlist for group senders or chat_id targets. */
groupAllowFrom?: Array<string | number>;
/**
* Controls how group messages are handled:
* - "open": groups bypass allowFrom; mention-gating applies
* - "disabled": block all group messages entirely
* - "allowlist": only allow group messages from senders in groupAllowFrom/allowFrom
*/
groupPolicy?: GroupPolicy;
/** Max group messages to keep as history context (0 disables). */
historyLimit?: number;
/** Max DM turns to keep as history context. */
dmHistoryLimit?: number;
/** Per-DM config overrides keyed by user ID. */
dms?: Record<string, DmConfig>;
/** Include attachments + reactions in watch payloads. */
includeAttachments?: boolean;
/** Max outbound media size in MB. */
mediaMaxMb?: number;
/** Outbound text chunk size (chars). Default: 4000. */
textChunkLimit?: number;
blockStreaming?: boolean;
/** Merge streamed block replies before sending. */
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
groups?: Record<
string,
{
requireMention?: boolean;
}
>;
};
export type IMessageConfig = {
/** Optional per-account iMessage configuration (multi-account). */
accounts?: Record<string, IMessageAccountConfig>;
} & IMessageAccountConfig;
+90
View File
@@ -0,0 +1,90 @@
import type {
QueueDropPolicy,
QueueMode,
QueueModeByProvider,
} from "./types.queue.js";
export type GroupChatConfig = {
mentionPatterns?: string[];
historyLimit?: number;
};
export type DmConfig = {
historyLimit?: number;
};
export type QueueConfig = {
mode?: QueueMode;
byChannel?: QueueModeByProvider;
debounceMs?: number;
cap?: number;
drop?: QueueDropPolicy;
};
export type BroadcastStrategy = "parallel" | "sequential";
export type BroadcastConfig = {
/** Default processing strategy for broadcast peers. */
strategy?: BroadcastStrategy;
/**
* Map peer IDs to arrays of agent IDs that should ALL process messages.
*
* Note: the index signature includes `undefined` so `strategy?: ...` remains type-safe.
*/
[peerId: string]: string[] | BroadcastStrategy | undefined;
};
export type AudioConfig = {
/** @deprecated Use tools.audio.transcription instead. */
transcription?: {
// Optional CLI to turn inbound audio into text; templated args, must output transcript to stdout.
command: string[];
timeoutSeconds?: number;
};
};
export type MessagesConfig = {
/** @deprecated Use `whatsapp.messagePrefix` (WhatsApp-only inbound prefix). */
messagePrefix?: string;
/**
* Prefix auto-added to all outbound replies.
* - string: explicit prefix
* - special value: `"auto"` derives `[{agents.list[].identity.name}]` for the routed agent (when set)
* Default: none
*/
responsePrefix?: string;
groupChat?: GroupChatConfig;
queue?: QueueConfig;
/** Emoji reaction used to acknowledge inbound messages (empty disables). */
ackReaction?: string;
/** When to send ack reactions. Default: "group-mentions". */
ackReactionScope?: "group-mentions" | "group-all" | "direct" | "all";
/** Remove ack reaction after reply is sent (default: false). */
removeAckAfterReply?: boolean;
};
export type NativeCommandsSetting = boolean | "auto";
export type CommandsConfig = {
/** Enable native command registration when supported (default: "auto"). */
native?: NativeCommandsSetting;
/** Enable text command parsing (default: true). */
text?: boolean;
/** Allow bash chat command (`!`; `/bash` alias) (default: false). */
bash?: boolean;
/** How long bash waits before backgrounding (default: 2000; 0 backgrounds immediately). */
bashForegroundMs?: number;
/** Allow /config command (default: false). */
config?: boolean;
/** Allow /debug command (default: false). */
debug?: boolean;
/** Allow restart commands/tools (default: false). */
restart?: boolean;
/** Enforce access-group allowlists/policies for commands (default: true). */
useAccessGroups?: boolean;
};
export type ProviderCommandsConfig = {
/** Override native command registration for this provider (bool or "auto"). */
native?: NativeCommandsSetting;
};
+45
View File
@@ -0,0 +1,45 @@
export type ModelApi =
| "openai-completions"
| "openai-responses"
| "anthropic-messages"
| "google-generative-ai"
| "github-copilot";
export type ModelCompatConfig = {
supportsStore?: boolean;
supportsDeveloperRole?: boolean;
supportsReasoningEffort?: boolean;
maxTokensField?: "max_completion_tokens" | "max_tokens";
};
export type ModelDefinitionConfig = {
id: string;
name: string;
api?: ModelApi;
reasoning: boolean;
input: Array<"text" | "image">;
cost: {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
};
contextWindow: number;
maxTokens: number;
headers?: Record<string, string>;
compat?: ModelCompatConfig;
};
export type ModelProviderConfig = {
baseUrl: string;
apiKey?: string;
api?: ModelApi;
headers?: Record<string, string>;
authHeader?: boolean;
models: ModelDefinitionConfig[];
};
export type ModelsConfig = {
mode?: "merge" | "replace";
providers?: Record<string, ModelProviderConfig>;
};
+83
View File
@@ -0,0 +1,83 @@
import type {
BlockStreamingCoalesceConfig,
DmPolicy,
GroupPolicy,
} from "./types.base.js";
import type { DmConfig } from "./types.messages.js";
export type MSTeamsWebhookConfig = {
/** Port for the webhook server. Default: 3978. */
port?: number;
/** Path for the messages endpoint. Default: /api/messages. */
path?: string;
};
/** Reply style for MS Teams messages. */
export type MSTeamsReplyStyle = "thread" | "top-level";
/** Channel-level config for MS Teams. */
export type MSTeamsChannelConfig = {
/** Require @mention to respond. Default: true. */
requireMention?: boolean;
/** Reply style: "thread" replies to the message, "top-level" posts a new message. */
replyStyle?: MSTeamsReplyStyle;
};
/** Team-level config for MS Teams. */
export type MSTeamsTeamConfig = {
/** Default requireMention for channels in this team. */
requireMention?: boolean;
/** Default reply style for channels in this team. */
replyStyle?: MSTeamsReplyStyle;
/** Per-channel overrides. Key is conversation ID (e.g., "19:...@thread.tacv2"). */
channels?: Record<string, MSTeamsChannelConfig>;
};
export type MSTeamsConfig = {
/** If false, do not start the MS Teams provider. Default: true. */
enabled?: boolean;
/** Optional provider capability tags used for agent/runtime guidance. */
capabilities?: string[];
/** Azure Bot App ID (from Azure Bot registration). */
appId?: string;
/** Azure Bot App Password / Client Secret. */
appPassword?: string;
/** Azure AD Tenant ID (for single-tenant bots). */
tenantId?: string;
/** Webhook server configuration. */
webhook?: MSTeamsWebhookConfig;
/** Direct message access policy (default: pairing). */
dmPolicy?: DmPolicy;
/** Allowlist for DM senders (AAD object IDs or UPNs). */
allowFrom?: Array<string>;
/** Optional allowlist for group/channel senders (AAD object IDs or UPNs). */
groupAllowFrom?: Array<string>;
/**
* Controls how group/channel messages are handled:
* - "open": groups bypass allowFrom; mention-gating applies
* - "disabled": block all group messages
* - "allowlist": only allow group messages from senders in groupAllowFrom/allowFrom
*/
groupPolicy?: GroupPolicy;
/** Outbound text chunk size (chars). Default: 4000. */
textChunkLimit?: number;
/** Merge streamed block replies before sending. */
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
/**
* Allowed host suffixes for inbound attachment downloads.
* Use ["*"] to allow any host (not recommended).
*/
mediaAllowHosts?: Array<string>;
/** Default: require @mention to respond in channels/groups. */
requireMention?: boolean;
/** Max group/channel messages to keep as history context (0 disables). */
historyLimit?: number;
/** Max DM turns to keep as history context. */
dmHistoryLimit?: number;
/** Per-DM config overrides keyed by user ID. */
dms?: Record<string, DmConfig>;
/** Default reply style: "thread" replies to the message, "top-level" posts a new message. */
replyStyle?: MSTeamsReplyStyle;
/** Per-team config. Key is team ID (from the /team/ URL path segment). */
teams?: Record<string, MSTeamsTeamConfig>;
};
+20
View File
@@ -0,0 +1,20 @@
export type PluginEntryConfig = {
enabled?: boolean;
config?: Record<string, unknown>;
};
export type PluginsLoadConfig = {
/** Additional plugin/extension paths to load. */
paths?: string[];
};
export type PluginsConfig = {
/** Enable or disable plugin loading. */
enabled?: boolean;
/** Optional plugin allowlist (plugin ids). */
allow?: string[];
/** Optional plugin denylist (plugin ids). */
deny?: string[];
load?: PluginsLoadConfig;
entries?: Record<string, PluginEntryConfig>;
};
+20
View File
@@ -0,0 +1,20 @@
export type QueueMode =
| "steer"
| "followup"
| "collect"
| "steer-backlog"
| "steer+backlog"
| "queue"
| "interrupt";
export type QueueDropPolicy = "old" | "new" | "summarize";
export type QueueModeByProvider = {
whatsapp?: QueueMode;
telegram?: QueueMode;
discord?: QueueMode;
slack?: QueueMode;
signal?: QueueMode;
imessage?: QueueMode;
msteams?: QueueMode;
webchat?: QueueMode;
};
+90
View File
@@ -0,0 +1,90 @@
export type SandboxDockerSettings = {
/** Docker image to use for sandbox containers. */
image?: string;
/** Prefix for sandbox container names. */
containerPrefix?: string;
/** Container workdir mount path (default: /workspace). */
workdir?: string;
/** Run container rootfs read-only. */
readOnlyRoot?: boolean;
/** Extra tmpfs mounts for read-only containers. */
tmpfs?: string[];
/** Container network mode (bridge|none|custom). */
network?: string;
/** Container user (uid:gid). */
user?: string;
/** Drop Linux capabilities. */
capDrop?: string[];
/** Extra environment variables for sandbox exec. */
env?: Record<string, string>;
/** Optional setup command run once after container creation. */
setupCommand?: string;
/** Limit container PIDs (0 = Docker default). */
pidsLimit?: number;
/** Limit container memory (e.g. 512m, 2g, or bytes as number). */
memory?: string | number;
/** Limit container memory swap (same format as memory). */
memorySwap?: string | number;
/** Limit container CPU shares (e.g. 0.5, 1, 2). */
cpus?: number;
/**
* Set ulimit values by name (e.g. nofile, nproc).
* Use "soft:hard" string, a number, or { soft, hard }.
*/
ulimits?: Record<string, string | number | { soft?: number; hard?: number }>;
/** Seccomp profile (path or profile name). */
seccompProfile?: string;
/** AppArmor profile name. */
apparmorProfile?: string;
/** DNS servers (e.g. ["1.1.1.1", "8.8.8.8"]). */
dns?: string[];
/** Extra host mappings (e.g. ["api.local:10.0.0.2"]). */
extraHosts?: string[];
/** Additional bind mounts (host:container:mode format, e.g. ["/host/path:/container/path:rw"]). */
binds?: string[];
};
export type SandboxBrowserSettings = {
enabled?: boolean;
image?: string;
containerPrefix?: string;
cdpPort?: number;
vncPort?: number;
noVncPort?: number;
headless?: boolean;
enableNoVnc?: boolean;
/**
* Allow sandboxed sessions to target the host browser control server.
* Default: false.
*/
allowHostControl?: boolean;
/**
* Allowlist of exact control URLs for target="custom".
* When set, any custom controlUrl must match this list.
*/
allowedControlUrls?: string[];
/**
* Allowlist of hostnames for control URLs (hostname only, no ports).
* When set, controlUrl hostname must match.
*/
allowedControlHosts?: string[];
/**
* Allowlist of ports for control URLs.
* When set, controlUrl port must match (defaults: http=80, https=443).
*/
allowedControlPorts?: number[];
/**
* When true (default), sandboxed browser control will try to start/reattach to
* the sandbox browser container when a tool call needs it.
*/
autoStart?: boolean;
/** Max time to wait for CDP to become reachable after auto-start (ms). */
autoStartTimeoutMs?: number;
};
export type SandboxPruneSettings = {
/** Prune if idle for more than N hours (0 disables). */
idleHours?: number;
/** Prune if older than N days (0 disables). */
maxAgeDays?: number;
};
+70
View File
@@ -0,0 +1,70 @@
import type {
BlockStreamingCoalesceConfig,
DmPolicy,
GroupPolicy,
} from "./types.base.js";
import type { DmConfig } from "./types.messages.js";
export type SignalReactionNotificationMode =
| "off"
| "own"
| "all"
| "allowlist";
export type SignalAccountConfig = {
/** Optional display name for this account (used in CLI/UI lists). */
name?: string;
/** Optional provider capability tags used for agent/runtime guidance. */
capabilities?: string[];
/** If false, do not start this Signal account. Default: true. */
enabled?: boolean;
/** Optional explicit E.164 account for signal-cli. */
account?: string;
/** Optional full base URL for signal-cli HTTP daemon. */
httpUrl?: string;
/** HTTP host for signal-cli daemon (default 127.0.0.1). */
httpHost?: string;
/** HTTP port for signal-cli daemon (default 8080). */
httpPort?: number;
/** signal-cli binary path (default: signal-cli). */
cliPath?: string;
/** Auto-start signal-cli daemon (default: true if httpUrl not set). */
autoStart?: boolean;
receiveMode?: "on-start" | "manual";
ignoreAttachments?: boolean;
ignoreStories?: boolean;
sendReadReceipts?: boolean;
/** Direct message access policy (default: pairing). */
dmPolicy?: DmPolicy;
allowFrom?: Array<string | number>;
/** Optional allowlist for Signal group senders (E.164). */
groupAllowFrom?: Array<string | number>;
/**
* Controls how group messages are handled:
* - "open": groups bypass allowFrom, no extra gating
* - "disabled": block all group messages
* - "allowlist": only allow group messages from senders in groupAllowFrom/allowFrom
*/
groupPolicy?: GroupPolicy;
/** Max group messages to keep as history context (0 disables). */
historyLimit?: number;
/** Max DM turns to keep as history context. */
dmHistoryLimit?: number;
/** Per-DM config overrides keyed by user ID. */
dms?: Record<string, DmConfig>;
/** Outbound text chunk size (chars). Default: 4000. */
textChunkLimit?: number;
blockStreaming?: boolean;
/** Merge streamed block replies before sending. */
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
mediaMaxMb?: number;
/** Reaction notification mode (off|own|all|allowlist). Default: own. */
reactionNotifications?: SignalReactionNotificationMode;
/** Allowlist for reaction notifications when mode is allowlist. */
reactionAllowlist?: Array<string | number>;
};
export type SignalConfig = {
/** Optional per-account Signal configuration (multi-account). */
accounts?: Record<string, SignalAccountConfig>;
} & SignalAccountConfig;
+27
View File
@@ -0,0 +1,27 @@
export type SkillConfig = {
enabled?: boolean;
apiKey?: string;
env?: Record<string, string>;
[key: string]: unknown;
};
export type SkillsLoadConfig = {
/**
* Additional skill folders to scan (lowest precedence).
* Each directory should contain skill subfolders with `SKILL.md`.
*/
extraDirs?: string[];
};
export type SkillsInstallConfig = {
preferBrew?: boolean;
nodeManager?: "npm" | "pnpm" | "yarn" | "bun";
};
export type SkillsConfig = {
/** Optional bundled-skill allowlist (only affects bundled skills). */
allowBundled?: string[];
load?: SkillsLoadConfig;
install?: SkillsInstallConfig;
entries?: Record<string, SkillConfig>;
};
+109
View File
@@ -0,0 +1,109 @@
import type {
BlockStreamingCoalesceConfig,
DmPolicy,
GroupPolicy,
ReplyToMode,
} from "./types.base.js";
import type { DmConfig, ProviderCommandsConfig } from "./types.messages.js";
export type SlackDmConfig = {
/** If false, ignore all incoming Slack DMs. Default: true. */
enabled?: boolean;
/** Direct message access policy (default: pairing). */
policy?: DmPolicy;
/** Allowlist for DM senders (ids). */
allowFrom?: Array<string | number>;
/** If true, allow group DMs (default: false). */
groupEnabled?: boolean;
/** Optional allowlist for group DM channels (ids or slugs). */
groupChannels?: Array<string | number>;
};
export type SlackChannelConfig = {
/** If false, disable the bot in this channel. (Alias for allow: false.) */
enabled?: boolean;
/** Legacy channel allow toggle; prefer enabled. */
allow?: boolean;
/** Require mentioning the bot to trigger replies. */
requireMention?: boolean;
/** Allow bot-authored messages to trigger replies (default: false). */
allowBots?: boolean;
/** Allowlist of users that can invoke the bot in this channel. */
users?: Array<string | number>;
/** Optional skill filter for this channel. */
skills?: string[];
/** Optional system prompt for this channel. */
systemPrompt?: string;
};
export type SlackReactionNotificationMode = "off" | "own" | "all" | "allowlist";
export type SlackActionConfig = {
reactions?: boolean;
messages?: boolean;
pins?: boolean;
search?: boolean;
permissions?: boolean;
memberInfo?: boolean;
channelInfo?: boolean;
emojiList?: boolean;
};
export type SlackSlashCommandConfig = {
/** Enable handling for the configured slash command (default: false). */
enabled?: boolean;
/** Slash command name (default: "clawd"). */
name?: string;
/** Session key prefix for slash commands (default: "slack:slash"). */
sessionPrefix?: string;
/** Reply ephemerally (default: true). */
ephemeral?: boolean;
};
export type SlackAccountConfig = {
/** Optional display name for this account (used in CLI/UI lists). */
name?: string;
/** Optional provider capability tags used for agent/runtime guidance. */
capabilities?: string[];
/** Override native command registration for Slack (bool or "auto"). */
commands?: ProviderCommandsConfig;
/** If false, do not start this Slack account. Default: true. */
enabled?: boolean;
botToken?: string;
appToken?: string;
/** Allow bot-authored messages to trigger replies (default: false). */
allowBots?: boolean;
/**
* Controls how channel messages are handled:
* - "open": channels bypass allowlists; mention-gating applies
* - "disabled": block all channel messages
* - "allowlist": only allow channels present in channels.slack.channels
*/
groupPolicy?: GroupPolicy;
/** Max channel messages to keep as history context (0 disables). */
historyLimit?: number;
/** Max DM turns to keep as history context. */
dmHistoryLimit?: number;
/** Per-DM config overrides keyed by user ID. */
dms?: Record<string, DmConfig>;
textChunkLimit?: number;
blockStreaming?: boolean;
/** Merge streamed block replies before sending. */
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
mediaMaxMb?: number;
/** Reaction notification mode (off|own|all|allowlist). Default: own. */
reactionNotifications?: SlackReactionNotificationMode;
/** Allowlist for reaction notifications when mode is allowlist. */
reactionAllowlist?: Array<string | number>;
/** Control reply threading when reply tags are present (off|first|all). */
replyToMode?: ReplyToMode;
actions?: SlackActionConfig;
slashCommand?: SlackSlashCommandConfig;
dm?: SlackDmConfig;
channels?: Record<string, SlackChannelConfig>;
};
export type SlackConfig = {
/** Optional per-account Slack configuration (multi-account). */
accounts?: Record<string, SlackAccountConfig>;
} & SlackAccountConfig;
+105
View File
@@ -0,0 +1,105 @@
import type {
BlockStreamingChunkConfig,
BlockStreamingCoalesceConfig,
DmPolicy,
GroupPolicy,
OutboundRetryConfig,
ReplyToMode,
} from "./types.base.js";
import type { DmConfig, ProviderCommandsConfig } from "./types.messages.js";
export type TelegramActionConfig = {
reactions?: boolean;
sendMessage?: boolean;
};
export type TelegramAccountConfig = {
/** Optional display name for this account (used in CLI/UI lists). */
name?: string;
/** Optional provider capability tags used for agent/runtime guidance. */
capabilities?: string[];
/** Override native command registration for Telegram (bool or "auto"). */
commands?: ProviderCommandsConfig;
/**
* Controls how Telegram direct chats (DMs) are handled:
* - "pairing" (default): unknown senders get a pairing code; owner must approve
* - "allowlist": only allow senders in allowFrom (or paired allow store)
* - "open": allow all inbound DMs (requires allowFrom to include "*")
* - "disabled": ignore all inbound DMs
*/
dmPolicy?: DmPolicy;
/** If false, do not start this Telegram account. Default: true. */
enabled?: boolean;
botToken?: string;
/** Path to file containing bot token (for secret managers like agenix). */
tokenFile?: string;
/** Control reply threading when reply tags are present (off|first|all). */
replyToMode?: ReplyToMode;
groups?: Record<string, TelegramGroupConfig>;
allowFrom?: Array<string | number>;
/** Optional allowlist for Telegram group senders (user ids or usernames). */
groupAllowFrom?: Array<string | number>;
/**
* Controls how group messages are handled:
* - "open": groups bypass allowFrom, only mention-gating applies
* - "disabled": block all group messages entirely
* - "allowlist": only allow group messages from senders in groupAllowFrom/allowFrom
*/
groupPolicy?: GroupPolicy;
/** Max group messages to keep as history context (0 disables). */
historyLimit?: number;
/** Max DM turns to keep as history context. */
dmHistoryLimit?: number;
/** Per-DM config overrides keyed by user ID. */
dms?: Record<string, DmConfig>;
/** Outbound text chunk size (chars). Default: 4000. */
textChunkLimit?: number;
/** Disable block streaming for this account. */
blockStreaming?: boolean;
/** Chunking config for draft streaming in `streamMode: "block"`. */
draftChunk?: BlockStreamingChunkConfig;
/** Merge streamed block replies before sending. */
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
/** Draft streaming mode for Telegram (off|partial|block). Default: partial. */
streamMode?: "off" | "partial" | "block";
mediaMaxMb?: number;
/** Retry policy for outbound Telegram API calls. */
retry?: OutboundRetryConfig;
proxy?: string;
webhookUrl?: string;
webhookSecret?: string;
webhookPath?: string;
/** Per-action tool gating (default: true for all). */
actions?: TelegramActionConfig;
};
export type TelegramTopicConfig = {
requireMention?: boolean;
/** If specified, only load these skills for this topic. Omit = all skills; empty = no skills. */
skills?: string[];
/** If false, disable the bot for this topic. */
enabled?: boolean;
/** Optional allowlist for topic senders (ids or usernames). */
allowFrom?: Array<string | number>;
/** Optional system prompt snippet for this topic. */
systemPrompt?: string;
};
export type TelegramGroupConfig = {
requireMention?: boolean;
/** If specified, only load these skills for this group (when no topic). Omit = all skills; empty = no skills. */
skills?: string[];
/** Per-topic configuration (key is message_thread_id as string) */
topics?: Record<string, TelegramTopicConfig>;
/** If false, disable the bot for this group (and its topics). */
enabled?: boolean;
/** Optional allowlist for group senders (ids or usernames). */
allowFrom?: Array<string | number>;
/** Optional system prompt snippet for this group. */
systemPrompt?: string;
};
export type TelegramConfig = {
/** Optional per-account Telegram configuration (multi-account). */
accounts?: Record<string, TelegramAccountConfig>;
} & TelegramAccountConfig;
+140
View File
@@ -0,0 +1,140 @@
import type { AgentElevatedAllowFromConfig } from "./types.base.js";
export type ToolProfileId = "minimal" | "coding" | "messaging" | "full";
export type AgentToolsConfig = {
/** Base tool profile applied before allow/deny lists. */
profile?: ToolProfileId;
allow?: string[];
deny?: string[];
/** Per-agent elevated exec gate (can only further restrict global tools.elevated). */
elevated?: {
/** Enable or disable elevated mode for this agent (default: true). */
enabled?: boolean;
/** Approved senders for /elevated (per-provider allowlists). */
allowFrom?: AgentElevatedAllowFromConfig;
};
sandbox?: {
tools?: {
allow?: string[];
deny?: string[];
};
};
};
export type MemorySearchConfig = {
/** Enable vector memory search (default: true). */
enabled?: boolean;
/** Embedding provider mode. */
provider?: "openai" | "local";
remote?: {
baseUrl?: string;
apiKey?: string;
headers?: Record<string, string>;
};
/** Fallback behavior when local embeddings fail. */
fallback?: "openai" | "none";
/** Embedding model id (remote) or alias (local). */
model?: string;
/** Local embedding settings (node-llama-cpp). */
local?: {
/** GGUF model path or hf: URI. */
modelPath?: string;
/** Optional cache directory for local models. */
modelCacheDir?: string;
};
/** Index storage configuration. */
store?: {
driver?: "sqlite";
path?: string;
};
/** Chunking configuration. */
chunking?: {
tokens?: number;
overlap?: number;
};
/** Sync behavior. */
sync?: {
onSessionStart?: boolean;
onSearch?: boolean;
watch?: boolean;
watchDebounceMs?: number;
intervalMinutes?: number;
};
/** Query behavior. */
query?: {
maxResults?: number;
minScore?: number;
};
};
export type ToolsConfig = {
/** Base tool profile applied before allow/deny lists. */
profile?: ToolProfileId;
allow?: string[];
deny?: string[];
audio?: {
transcription?: {
/** CLI args (template-enabled). */
args?: string[];
timeoutSeconds?: number;
};
};
agentToAgent?: {
/** Enable agent-to-agent messaging tools. Default: false. */
enabled?: boolean;
/** Allowlist of agent ids or patterns (implementation-defined). */
allow?: string[];
};
/** Elevated exec permissions for the host machine. */
elevated?: {
/** Enable or disable elevated mode (default: true). */
enabled?: boolean;
/** Approved senders for /elevated (per-provider allowlists). */
allowFrom?: AgentElevatedAllowFromConfig;
};
/** Exec tool defaults. */
exec?: {
/** Default time (ms) before an exec command auto-backgrounds. */
backgroundMs?: number;
/** Default timeout (seconds) before auto-killing exec commands. */
timeoutSec?: number;
/** How long to keep finished sessions in memory (ms). */
cleanupMs?: number;
/** apply_patch subtool configuration (experimental). */
applyPatch?: {
/** Enable apply_patch for OpenAI models (default: false). */
enabled?: boolean;
/**
* Optional allowlist of model ids that can use apply_patch.
* Accepts either raw ids (e.g. "gpt-5.2") or full ids (e.g. "openai/gpt-5.2").
*/
allowModels?: string[];
};
};
/** @deprecated Use tools.exec. */
bash?: {
/** Default time (ms) before a bash command auto-backgrounds. */
backgroundMs?: number;
/** Default timeout (seconds) before auto-killing bash commands. */
timeoutSec?: number;
/** How long to keep finished sessions in memory (ms). */
cleanupMs?: number;
};
/** Sub-agent tool policy defaults (deny wins). */
subagents?: {
/** Default model selection for spawned sub-agents (string or {primary,fallbacks}). */
model?: string | { primary?: string; fallbacks?: string[] };
tools?: {
allow?: string[];
deny?: string[];
};
};
/** Sandbox tool policy defaults (deny wins). */
sandbox?: {
tools?: {
allow?: string[];
deny?: string[];
};
};
};
+26 -1842
View File
File diff suppressed because it is too large Load Diff
+130
View File
@@ -0,0 +1,130 @@
import type {
BlockStreamingCoalesceConfig,
DmPolicy,
GroupPolicy,
} from "./types.base.js";
import type { DmConfig } from "./types.messages.js";
export type WhatsAppActionConfig = {
reactions?: boolean;
sendMessage?: boolean;
polls?: boolean;
};
export type WhatsAppConfig = {
/** Optional per-account WhatsApp configuration (multi-account). */
accounts?: Record<string, WhatsAppAccountConfig>;
/** Optional provider capability tags used for agent/runtime guidance. */
capabilities?: string[];
/**
* Inbound message prefix (WhatsApp only).
* Default: `[{agents.list[].identity.name}]` (or `[clawdbot]`) when allowFrom is empty, else `""`.
*/
messagePrefix?: string;
/** Direct message access policy (default: pairing). */
dmPolicy?: DmPolicy;
/**
* Same-phone setup (bot uses your personal WhatsApp number).
*/
selfChatMode?: boolean;
/** Optional allowlist for WhatsApp direct chats (E.164). */
allowFrom?: string[];
/** Optional allowlist for WhatsApp group senders (E.164). */
groupAllowFrom?: string[];
/**
* Controls how group messages are handled:
* - "open": groups bypass allowFrom, only mention-gating applies
* - "disabled": block all group messages entirely
* - "allowlist": only allow group messages from senders in groupAllowFrom/allowFrom
*/
groupPolicy?: GroupPolicy;
/** Max group messages to keep as history context (0 disables). */
historyLimit?: number;
/** Max DM turns to keep as history context. */
dmHistoryLimit?: number;
/** Per-DM config overrides keyed by user ID. */
dms?: Record<string, DmConfig>;
/** Outbound text chunk size (chars). Default: 4000. */
textChunkLimit?: number;
/** Maximum media file size in MB. Default: 50. */
mediaMaxMb?: number;
/** Disable block streaming for this account. */
blockStreaming?: boolean;
/** Merge streamed block replies before sending. */
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
/** Per-action tool gating (default: true for all). */
actions?: WhatsAppActionConfig;
groups?: Record<
string,
{
requireMention?: boolean;
}
>;
/** Acknowledgment reaction sent immediately upon message receipt. */
ackReaction?: {
/** Emoji to use for acknowledgment (e.g., "👀"). Empty = disabled. */
emoji?: string;
/** Send reactions in direct chats. Default: true. */
direct?: boolean;
/**
* Send reactions in group chats:
* - "always": react to all group messages
* - "mentions": react only when bot is mentioned
* - "never": never react in groups
* Default: "mentions"
*/
group?: "always" | "mentions" | "never";
};
};
export type WhatsAppAccountConfig = {
/** Optional display name for this account (used in CLI/UI lists). */
name?: string;
/** Optional provider capability tags used for agent/runtime guidance. */
capabilities?: string[];
/** If false, do not start this WhatsApp account provider. Default: true. */
enabled?: boolean;
/** Inbound message prefix override for this account (WhatsApp only). */
messagePrefix?: string;
/** Override auth directory (Baileys multi-file auth state). */
authDir?: string;
/** Direct message access policy (default: pairing). */
dmPolicy?: DmPolicy;
/** Same-phone setup for this account (bot uses your personal WhatsApp number). */
selfChatMode?: boolean;
allowFrom?: string[];
groupAllowFrom?: string[];
groupPolicy?: GroupPolicy;
/** Max group messages to keep as history context (0 disables). */
historyLimit?: number;
/** Max DM turns to keep as history context. */
dmHistoryLimit?: number;
/** Per-DM config overrides keyed by user ID. */
dms?: Record<string, DmConfig>;
textChunkLimit?: number;
mediaMaxMb?: number;
blockStreaming?: boolean;
/** Merge streamed block replies before sending. */
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
groups?: Record<
string,
{
requireMention?: boolean;
}
>;
/** Acknowledgment reaction sent immediately upon message receipt. */
ackReaction?: {
/** Emoji to use for acknowledgment (e.g., "👀"). Empty = disabled. */
emoji?: string;
/** Send reactions in direct chats. Default: true. */
direct?: boolean;
/**
* Send reactions in group chats:
* - "always": react to all group messages
* - "mentions": react only when bot is mentioned
* - "never": never react in groups
* Default: "mentions"
*/
group?: "always" | "mentions" | "never";
};
};
+172
View File
@@ -0,0 +1,172 @@
import { z } from "zod";
import {
HeartbeatSchema,
MemorySearchSchema,
SandboxBrowserSchema,
SandboxDockerSchema,
SandboxPruneSchema,
} from "./zod-schema.agent-runtime.js";
import {
BlockStreamingChunkSchema,
BlockStreamingCoalesceSchema,
CliBackendSchema,
HumanDelaySchema,
} from "./zod-schema.core.js";
export const AgentDefaultsSchema = z
.object({
model: z
.object({
primary: z.string().optional(),
fallbacks: z.array(z.string()).optional(),
})
.optional(),
imageModel: z
.object({
primary: z.string().optional(),
fallbacks: z.array(z.string()).optional(),
})
.optional(),
models: z
.record(
z.string(),
z.object({
alias: z.string().optional(),
/** Provider-specific API parameters (e.g., GLM-4.7 thinking mode). */
params: z.record(z.string(), z.unknown()).optional(),
}),
)
.optional(),
workspace: z.string().optional(),
skipBootstrap: z.boolean().optional(),
bootstrapMaxChars: z.number().int().positive().optional(),
userTimezone: z.string().optional(),
contextTokens: z.number().int().positive().optional(),
cliBackends: z.record(z.string(), CliBackendSchema).optional(),
memorySearch: MemorySearchSchema,
contextPruning: z
.object({
mode: z
.union([
z.literal("off"),
z.literal("adaptive"),
z.literal("aggressive"),
])
.optional(),
keepLastAssistants: z.number().int().nonnegative().optional(),
softTrimRatio: z.number().min(0).max(1).optional(),
hardClearRatio: z.number().min(0).max(1).optional(),
minPrunableToolChars: z.number().int().nonnegative().optional(),
tools: z
.object({
allow: z.array(z.string()).optional(),
deny: z.array(z.string()).optional(),
})
.optional(),
softTrim: z
.object({
maxChars: z.number().int().nonnegative().optional(),
headChars: z.number().int().nonnegative().optional(),
tailChars: z.number().int().nonnegative().optional(),
})
.optional(),
hardClear: z
.object({
enabled: z.boolean().optional(),
placeholder: z.string().optional(),
})
.optional(),
})
.optional(),
compaction: z
.object({
mode: z
.union([z.literal("default"), z.literal("safeguard")])
.optional(),
reserveTokensFloor: z.number().int().nonnegative().optional(),
memoryFlush: z
.object({
enabled: z.boolean().optional(),
softThresholdTokens: z.number().int().nonnegative().optional(),
prompt: z.string().optional(),
systemPrompt: z.string().optional(),
})
.optional(),
})
.optional(),
thinkingDefault: z
.union([
z.literal("off"),
z.literal("minimal"),
z.literal("low"),
z.literal("medium"),
z.literal("high"),
z.literal("xhigh"),
])
.optional(),
verboseDefault: z.union([z.literal("off"), z.literal("on")]).optional(),
elevatedDefault: z.union([z.literal("off"), z.literal("on")]).optional(),
blockStreamingDefault: z
.union([z.literal("off"), z.literal("on")])
.optional(),
blockStreamingBreak: z
.union([z.literal("text_end"), z.literal("message_end")])
.optional(),
blockStreamingChunk: BlockStreamingChunkSchema.optional(),
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
humanDelay: HumanDelaySchema.optional(),
timeoutSeconds: z.number().int().positive().optional(),
mediaMaxMb: z.number().positive().optional(),
typingIntervalSeconds: z.number().int().positive().optional(),
typingMode: z
.union([
z.literal("never"),
z.literal("instant"),
z.literal("thinking"),
z.literal("message"),
])
.optional(),
heartbeat: HeartbeatSchema,
maxConcurrent: z.number().int().positive().optional(),
subagents: z
.object({
maxConcurrent: z.number().int().positive().optional(),
archiveAfterMinutes: z.number().int().positive().optional(),
model: z
.union([
z.string(),
z.object({
primary: z.string().optional(),
fallbacks: z.array(z.string()).optional(),
}),
])
.optional(),
})
.optional(),
sandbox: z
.object({
mode: z
.union([z.literal("off"), z.literal("non-main"), z.literal("all")])
.optional(),
workspaceAccess: z
.union([z.literal("none"), z.literal("ro"), z.literal("rw")])
.optional(),
sessionToolsVisibility: z
.union([z.literal("spawned"), z.literal("all")])
.optional(),
scope: z
.union([
z.literal("session"),
z.literal("agent"),
z.literal("shared"),
])
.optional(),
perSession: z.boolean().optional(),
workspaceRoot: z.string().optional(),
docker: SandboxDockerSchema,
browser: SandboxBrowserSchema,
prune: SandboxPruneSchema,
})
.optional(),
})
.optional();
+308
View File
@@ -0,0 +1,308 @@
import { z } from "zod";
import { parseDurationMs } from "../cli/parse-duration.js";
import {
GroupChatSchema,
HumanDelaySchema,
IdentitySchema,
ToolsAudioTranscriptionSchema,
} from "./zod-schema.core.js";
export const HeartbeatSchema = z
.object({
every: z.string().optional(),
model: z.string().optional(),
includeReasoning: z.boolean().optional(),
target: z
.union([
z.literal("last"),
z.literal("whatsapp"),
z.literal("telegram"),
z.literal("discord"),
z.literal("slack"),
z.literal("msteams"),
z.literal("signal"),
z.literal("imessage"),
z.literal("none"),
])
.optional(),
to: z.string().optional(),
prompt: z.string().optional(),
ackMaxChars: z.number().int().nonnegative().optional(),
})
.superRefine((val, ctx) => {
if (!val.every) return;
try {
parseDurationMs(val.every, { defaultUnit: "m" });
} catch {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["every"],
message: "invalid duration (use ms, s, m, h)",
});
}
})
.optional();
export const SandboxDockerSchema = z
.object({
image: z.string().optional(),
containerPrefix: z.string().optional(),
workdir: z.string().optional(),
readOnlyRoot: z.boolean().optional(),
tmpfs: z.array(z.string()).optional(),
network: z.string().optional(),
user: z.string().optional(),
capDrop: z.array(z.string()).optional(),
env: z.record(z.string(), z.string()).optional(),
setupCommand: z.string().optional(),
pidsLimit: z.number().int().positive().optional(),
memory: z.union([z.string(), z.number()]).optional(),
memorySwap: z.union([z.string(), z.number()]).optional(),
cpus: z.number().positive().optional(),
ulimits: z
.record(
z.string(),
z.union([
z.string(),
z.number(),
z.object({
soft: z.number().int().nonnegative().optional(),
hard: z.number().int().nonnegative().optional(),
}),
]),
)
.optional(),
seccompProfile: z.string().optional(),
apparmorProfile: z.string().optional(),
dns: z.array(z.string()).optional(),
extraHosts: z.array(z.string()).optional(),
})
.optional();
export const SandboxBrowserSchema = z
.object({
enabled: z.boolean().optional(),
image: z.string().optional(),
containerPrefix: z.string().optional(),
cdpPort: z.number().int().positive().optional(),
vncPort: z.number().int().positive().optional(),
noVncPort: z.number().int().positive().optional(),
headless: z.boolean().optional(),
enableNoVnc: z.boolean().optional(),
allowHostControl: z.boolean().optional(),
allowedControlUrls: z.array(z.string()).optional(),
allowedControlHosts: z.array(z.string()).optional(),
allowedControlPorts: z.array(z.number().int().positive()).optional(),
autoStart: z.boolean().optional(),
autoStartTimeoutMs: z.number().int().positive().optional(),
})
.optional();
export const SandboxPruneSchema = z
.object({
idleHours: z.number().int().nonnegative().optional(),
maxAgeDays: z.number().int().nonnegative().optional(),
})
.optional();
export const ToolPolicySchema = z
.object({
allow: z.array(z.string()).optional(),
deny: z.array(z.string()).optional(),
})
.optional();
export const ToolProfileSchema = z
.union([
z.literal("minimal"),
z.literal("coding"),
z.literal("messaging"),
z.literal("full"),
])
.optional();
// Provider docking: allowlists keyed by provider id (no schema updates when adding providers).
export const ElevatedAllowFromSchema = z
.record(z.string(), z.array(z.union([z.string(), z.number()])))
.optional();
export const AgentSandboxSchema = z
.object({
mode: z
.union([z.literal("off"), z.literal("non-main"), z.literal("all")])
.optional(),
workspaceAccess: z
.union([z.literal("none"), z.literal("ro"), z.literal("rw")])
.optional(),
sessionToolsVisibility: z
.union([z.literal("spawned"), z.literal("all")])
.optional(),
scope: z
.union([z.literal("session"), z.literal("agent"), z.literal("shared")])
.optional(),
perSession: z.boolean().optional(),
workspaceRoot: z.string().optional(),
docker: SandboxDockerSchema,
browser: SandboxBrowserSchema,
prune: SandboxPruneSchema,
})
.optional();
export const AgentToolsSchema = z
.object({
profile: ToolProfileSchema,
allow: z.array(z.string()).optional(),
deny: z.array(z.string()).optional(),
elevated: z
.object({
enabled: z.boolean().optional(),
allowFrom: ElevatedAllowFromSchema,
})
.optional(),
sandbox: z
.object({
tools: ToolPolicySchema,
})
.optional(),
})
.optional();
export const MemorySearchSchema = z
.object({
enabled: z.boolean().optional(),
provider: z.union([z.literal("openai"), z.literal("local")]).optional(),
remote: z
.object({
baseUrl: z.string().optional(),
apiKey: z.string().optional(),
headers: z.record(z.string(), z.string()).optional(),
})
.optional(),
fallback: z.union([z.literal("openai"), z.literal("none")]).optional(),
model: z.string().optional(),
local: z
.object({
modelPath: z.string().optional(),
modelCacheDir: z.string().optional(),
})
.optional(),
store: z
.object({
driver: z.literal("sqlite").optional(),
path: z.string().optional(),
})
.optional(),
chunking: z
.object({
tokens: z.number().int().positive().optional(),
overlap: z.number().int().nonnegative().optional(),
})
.optional(),
sync: z
.object({
onSessionStart: z.boolean().optional(),
onSearch: z.boolean().optional(),
watch: z.boolean().optional(),
watchDebounceMs: z.number().int().nonnegative().optional(),
intervalMinutes: z.number().int().nonnegative().optional(),
})
.optional(),
query: z
.object({
maxResults: z.number().int().positive().optional(),
minScore: z.number().min(0).max(1).optional(),
})
.optional(),
})
.optional();
export const AgentModelSchema = z.union([
z.string(),
z.object({
primary: z.string().optional(),
fallbacks: z.array(z.string()).optional(),
}),
]);
export const AgentEntrySchema = z.object({
id: z.string(),
default: z.boolean().optional(),
name: z.string().optional(),
workspace: z.string().optional(),
agentDir: z.string().optional(),
model: AgentModelSchema.optional(),
memorySearch: MemorySearchSchema,
humanDelay: HumanDelaySchema.optional(),
identity: IdentitySchema,
groupChat: GroupChatSchema,
subagents: z
.object({
allowAgents: z.array(z.string()).optional(),
model: z
.union([
z.string(),
z.object({
primary: z.string().optional(),
fallbacks: z.array(z.string()).optional(),
}),
])
.optional(),
})
.optional(),
sandbox: AgentSandboxSchema,
tools: AgentToolsSchema,
});
export const ToolsSchema = z
.object({
profile: ToolProfileSchema,
allow: z.array(z.string()).optional(),
deny: z.array(z.string()).optional(),
audio: z
.object({
transcription: ToolsAudioTranscriptionSchema,
})
.optional(),
agentToAgent: z
.object({
enabled: z.boolean().optional(),
allow: z.array(z.string()).optional(),
})
.optional(),
elevated: z
.object({
enabled: z.boolean().optional(),
allowFrom: ElevatedAllowFromSchema,
})
.optional(),
exec: z
.object({
backgroundMs: z.number().int().positive().optional(),
timeoutSec: z.number().int().positive().optional(),
cleanupMs: z.number().int().positive().optional(),
applyPatch: z
.object({
enabled: z.boolean().optional(),
allowModels: z.array(z.string()).optional(),
})
.optional(),
})
.optional(),
bash: z
.object({
backgroundMs: z.number().int().positive().optional(),
timeoutSec: z.number().int().positive().optional(),
cleanupMs: z.number().int().positive().optional(),
})
.optional(),
subagents: z
.object({
tools: ToolPolicySchema,
})
.optional(),
sandbox: z
.object({
tools: ToolPolicySchema,
})
.optional(),
})
.optional();
+50
View File
@@ -0,0 +1,50 @@
import { z } from "zod";
import { AgentDefaultsSchema } from "./zod-schema.agent-defaults.js";
import { AgentEntrySchema } from "./zod-schema.agent-runtime.js";
import { TranscribeAudioSchema } from "./zod-schema.core.js";
export const AgentsSchema = z
.object({
defaults: z.lazy(() => AgentDefaultsSchema).optional(),
list: z.array(AgentEntrySchema).optional(),
})
.optional();
export const BindingsSchema = z
.array(
z.object({
agentId: z.string(),
match: z.object({
channel: z.string(),
accountId: z.string().optional(),
peer: z
.object({
kind: z.union([
z.literal("dm"),
z.literal("group"),
z.literal("channel"),
]),
id: z.string(),
})
.optional(),
guildId: z.string().optional(),
teamId: z.string().optional(),
}),
}),
)
.optional();
export const BroadcastStrategySchema = z.enum(["parallel", "sequential"]);
export const BroadcastSchema = z
.object({
strategy: BroadcastStrategySchema.optional(),
})
.catchall(z.array(z.string()))
.optional();
export const AudioSchema = z
.object({
transcription: TranscribeAudioSchema,
})
.optional();
+264
View File
@@ -0,0 +1,264 @@
import { z } from "zod";
import { isSafeExecutableValue } from "../infra/exec-safety.js";
export const ModelApiSchema = z.union([
z.literal("openai-completions"),
z.literal("openai-responses"),
z.literal("anthropic-messages"),
z.literal("google-generative-ai"),
z.literal("github-copilot"),
]);
export const ModelCompatSchema = z
.object({
supportsStore: z.boolean().optional(),
supportsDeveloperRole: z.boolean().optional(),
supportsReasoningEffort: z.boolean().optional(),
maxTokensField: z
.union([z.literal("max_completion_tokens"), z.literal("max_tokens")])
.optional(),
})
.optional();
export const ModelDefinitionSchema = z.object({
id: z.string().min(1),
name: z.string().min(1),
api: ModelApiSchema.optional(),
reasoning: z.boolean(),
input: z.array(z.union([z.literal("text"), z.literal("image")])),
cost: z.object({
input: z.number(),
output: z.number(),
cacheRead: z.number(),
cacheWrite: z.number(),
}),
contextWindow: z.number().positive(),
maxTokens: z.number().positive(),
headers: z.record(z.string(), z.string()).optional(),
compat: ModelCompatSchema,
});
export const ModelProviderSchema = z.object({
baseUrl: z.string().min(1),
apiKey: z.string().optional(),
api: ModelApiSchema.optional(),
headers: z.record(z.string(), z.string()).optional(),
authHeader: z.boolean().optional(),
models: z.array(ModelDefinitionSchema),
});
export const ModelsConfigSchema = z
.object({
mode: z.union([z.literal("merge"), z.literal("replace")]).optional(),
providers: z.record(z.string(), ModelProviderSchema).optional(),
})
.optional();
export const GroupChatSchema = z
.object({
mentionPatterns: z.array(z.string()).optional(),
historyLimit: z.number().int().positive().optional(),
})
.optional();
export const DmConfigSchema = z.object({
historyLimit: z.number().int().min(0).optional(),
});
export const IdentitySchema = z
.object({
name: z.string().optional(),
theme: z.string().optional(),
emoji: z.string().optional(),
})
.optional();
export const QueueModeSchema = z.union([
z.literal("steer"),
z.literal("followup"),
z.literal("collect"),
z.literal("steer-backlog"),
z.literal("steer+backlog"),
z.literal("queue"),
z.literal("interrupt"),
]);
export const QueueDropSchema = z.union([
z.literal("old"),
z.literal("new"),
z.literal("summarize"),
]);
export const ReplyToModeSchema = z.union([
z.literal("off"),
z.literal("first"),
z.literal("all"),
]);
// GroupPolicySchema: controls how group messages are handled
// Used with .default("allowlist").optional() pattern:
// - .optional() allows field omission in input config
// - .default("allowlist") ensures runtime always resolves to "allowlist" if not provided
export const GroupPolicySchema = z.enum(["open", "disabled", "allowlist"]);
export const DmPolicySchema = z.enum([
"pairing",
"allowlist",
"open",
"disabled",
]);
export const BlockStreamingCoalesceSchema = z.object({
minChars: z.number().int().positive().optional(),
maxChars: z.number().int().positive().optional(),
idleMs: z.number().int().nonnegative().optional(),
});
export const BlockStreamingChunkSchema = z.object({
minChars: z.number().int().positive().optional(),
maxChars: z.number().int().positive().optional(),
breakPreference: z
.union([
z.literal("paragraph"),
z.literal("newline"),
z.literal("sentence"),
])
.optional(),
});
export const HumanDelaySchema = z.object({
mode: z
.union([z.literal("off"), z.literal("natural"), z.literal("custom")])
.optional(),
minMs: z.number().int().nonnegative().optional(),
maxMs: z.number().int().nonnegative().optional(),
});
export const CliBackendSchema = z.object({
command: z.string(),
args: z.array(z.string()).optional(),
output: z
.union([z.literal("json"), z.literal("text"), z.literal("jsonl")])
.optional(),
resumeOutput: z
.union([z.literal("json"), z.literal("text"), z.literal("jsonl")])
.optional(),
input: z.union([z.literal("arg"), z.literal("stdin")]).optional(),
maxPromptArgChars: z.number().int().positive().optional(),
env: z.record(z.string(), z.string()).optional(),
clearEnv: z.array(z.string()).optional(),
modelArg: z.string().optional(),
modelAliases: z.record(z.string(), z.string()).optional(),
sessionArg: z.string().optional(),
sessionArgs: z.array(z.string()).optional(),
resumeArgs: z.array(z.string()).optional(),
sessionMode: z
.union([z.literal("always"), z.literal("existing"), z.literal("none")])
.optional(),
sessionIdFields: z.array(z.string()).optional(),
systemPromptArg: z.string().optional(),
systemPromptMode: z
.union([z.literal("append"), z.literal("replace")])
.optional(),
systemPromptWhen: z
.union([z.literal("first"), z.literal("always"), z.literal("never")])
.optional(),
imageArg: z.string().optional(),
imageMode: z.union([z.literal("repeat"), z.literal("list")]).optional(),
serialize: z.boolean().optional(),
});
export const normalizeAllowFrom = (values?: Array<string | number>): string[] =>
(values ?? []).map((v) => String(v).trim()).filter(Boolean);
export const requireOpenAllowFrom = (params: {
policy?: string;
allowFrom?: Array<string | number>;
ctx: z.RefinementCtx;
path: Array<string | number>;
message: string;
}) => {
if (params.policy !== "open") return;
const allow = normalizeAllowFrom(params.allowFrom);
if (allow.includes("*")) return;
params.ctx.addIssue({
code: z.ZodIssueCode.custom,
path: params.path,
message: params.message,
});
};
export const MSTeamsReplyStyleSchema = z.enum(["thread", "top-level"]);
export const RetryConfigSchema = z
.object({
attempts: z.number().int().min(1).optional(),
minDelayMs: z.number().int().min(0).optional(),
maxDelayMs: z.number().int().min(0).optional(),
jitter: z.number().min(0).max(1).optional(),
})
.optional();
export const QueueModeBySurfaceSchema = z
.object({
whatsapp: QueueModeSchema.optional(),
telegram: QueueModeSchema.optional(),
discord: QueueModeSchema.optional(),
slack: QueueModeSchema.optional(),
signal: QueueModeSchema.optional(),
imessage: QueueModeSchema.optional(),
msteams: QueueModeSchema.optional(),
webchat: QueueModeSchema.optional(),
})
.optional();
export const QueueSchema = z
.object({
mode: QueueModeSchema.optional(),
byChannel: QueueModeBySurfaceSchema,
debounceMs: z.number().int().nonnegative().optional(),
cap: z.number().int().positive().optional(),
drop: QueueDropSchema.optional(),
})
.optional();
export const TranscribeAudioSchema = z
.object({
command: z.array(z.string()).superRefine((value, ctx) => {
const executable = value[0];
if (!isSafeExecutableValue(executable)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: [0],
message: "expected safe executable name or path",
});
}
}),
timeoutSeconds: z.number().int().positive().optional(),
})
.optional();
export const HexColorSchema = z
.string()
.regex(/^#?[0-9a-fA-F]{6}$/, "expected hex color (RRGGBB)");
export const ExecutableTokenSchema = z
.string()
.refine(isSafeExecutableValue, "expected safe executable name or path");
export const ToolsAudioTranscriptionSchema = z
.object({
args: z.array(z.string()).optional(),
timeoutSeconds: z.number().int().positive().optional(),
})
.optional();
export const NativeCommandsSettingSchema = z.union([
z.boolean(),
z.literal("auto"),
]);
export const ProviderCommandsSchema = z
.object({
native: NativeCommandsSettingSchema.optional(),
})
.optional();
+84
View File
@@ -0,0 +1,84 @@
import { z } from "zod";
export const HookMappingSchema = z
.object({
id: z.string().optional(),
match: z
.object({
path: z.string().optional(),
source: z.string().optional(),
})
.optional(),
action: z.union([z.literal("wake"), z.literal("agent")]).optional(),
wakeMode: z
.union([z.literal("now"), z.literal("next-heartbeat")])
.optional(),
name: z.string().optional(),
sessionKey: z.string().optional(),
messageTemplate: z.string().optional(),
textTemplate: z.string().optional(),
deliver: z.boolean().optional(),
channel: z
.union([
z.literal("last"),
z.literal("whatsapp"),
z.literal("telegram"),
z.literal("discord"),
z.literal("slack"),
z.literal("signal"),
z.literal("imessage"),
z.literal("msteams"),
])
.optional(),
to: z.string().optional(),
model: z.string().optional(),
thinking: z.string().optional(),
timeoutSeconds: z.number().int().positive().optional(),
transform: z
.object({
module: z.string(),
export: z.string().optional(),
})
.optional(),
})
.optional();
export const HooksGmailSchema = z
.object({
account: z.string().optional(),
label: z.string().optional(),
topic: z.string().optional(),
subscription: z.string().optional(),
pushToken: z.string().optional(),
hookUrl: z.string().optional(),
includeBody: z.boolean().optional(),
maxBytes: z.number().int().positive().optional(),
renewEveryMinutes: z.number().int().positive().optional(),
serve: z
.object({
bind: z.string().optional(),
port: z.number().int().positive().optional(),
path: z.string().optional(),
})
.optional(),
tailscale: z
.object({
mode: z
.union([z.literal("off"), z.literal("serve"), z.literal("funnel")])
.optional(),
path: z.string().optional(),
target: z.string().optional(),
})
.optional(),
model: z.string().optional(),
thinking: z
.union([
z.literal("off"),
z.literal("minimal"),
z.literal("low"),
z.literal("medium"),
z.literal("high"),
])
.optional(),
})
.optional();
+416
View File
@@ -0,0 +1,416 @@
import { z } from "zod";
import {
BlockStreamingChunkSchema,
BlockStreamingCoalesceSchema,
DmConfigSchema,
DmPolicySchema,
ExecutableTokenSchema,
GroupPolicySchema,
MSTeamsReplyStyleSchema,
ProviderCommandsSchema,
ReplyToModeSchema,
RetryConfigSchema,
requireOpenAllowFrom,
} from "./zod-schema.core.js";
export const TelegramTopicSchema = z.object({
requireMention: z.boolean().optional(),
skills: z.array(z.string()).optional(),
enabled: z.boolean().optional(),
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
systemPrompt: z.string().optional(),
});
export const TelegramGroupSchema = z.object({
requireMention: z.boolean().optional(),
skills: z.array(z.string()).optional(),
enabled: z.boolean().optional(),
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
systemPrompt: z.string().optional(),
topics: z.record(z.string(), TelegramTopicSchema.optional()).optional(),
});
export const TelegramAccountSchemaBase = z.object({
name: z.string().optional(),
capabilities: z.array(z.string()).optional(),
enabled: z.boolean().optional(),
commands: ProviderCommandsSchema,
dmPolicy: DmPolicySchema.optional().default("pairing"),
botToken: z.string().optional(),
tokenFile: z.string().optional(),
replyToMode: ReplyToModeSchema.optional(),
groups: z.record(z.string(), TelegramGroupSchema.optional()).optional(),
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
historyLimit: z.number().int().min(0).optional(),
dmHistoryLimit: z.number().int().min(0).optional(),
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
textChunkLimit: z.number().int().positive().optional(),
blockStreaming: z.boolean().optional(),
draftChunk: BlockStreamingChunkSchema.optional(),
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
streamMode: z.enum(["off", "partial", "block"]).optional().default("partial"),
mediaMaxMb: z.number().positive().optional(),
retry: RetryConfigSchema,
proxy: z.string().optional(),
webhookUrl: z.string().optional(),
webhookSecret: z.string().optional(),
webhookPath: z.string().optional(),
actions: z
.object({
reactions: z.boolean().optional(),
})
.optional(),
});
export const TelegramAccountSchema = TelegramAccountSchemaBase.superRefine(
(value, ctx) => {
requireOpenAllowFrom({
policy: value.dmPolicy,
allowFrom: value.allowFrom,
ctx,
path: ["allowFrom"],
message:
'channels.telegram.dmPolicy="open" requires channels.telegram.allowFrom to include "*"',
});
},
);
export const TelegramConfigSchema = TelegramAccountSchemaBase.extend({
accounts: z.record(z.string(), TelegramAccountSchema.optional()).optional(),
}).superRefine((value, ctx) => {
requireOpenAllowFrom({
policy: value.dmPolicy,
allowFrom: value.allowFrom,
ctx,
path: ["allowFrom"],
message:
'channels.telegram.dmPolicy="open" requires channels.telegram.allowFrom to include "*"',
});
});
export const DiscordDmSchema = z
.object({
enabled: z.boolean().optional(),
policy: DmPolicySchema.optional().default("pairing"),
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
groupEnabled: z.boolean().optional(),
groupChannels: z.array(z.union([z.string(), z.number()])).optional(),
})
.superRefine((value, ctx) => {
requireOpenAllowFrom({
policy: value.policy,
allowFrom: value.allowFrom,
ctx,
path: ["allowFrom"],
message:
'channels.discord.dm.policy="open" requires channels.discord.dm.allowFrom to include "*"',
});
});
export const DiscordGuildChannelSchema = z.object({
allow: z.boolean().optional(),
requireMention: z.boolean().optional(),
skills: z.array(z.string()).optional(),
enabled: z.boolean().optional(),
users: z.array(z.union([z.string(), z.number()])).optional(),
systemPrompt: z.string().optional(),
autoThread: z.boolean().optional(),
});
export const DiscordGuildSchema = z.object({
slug: z.string().optional(),
requireMention: z.boolean().optional(),
reactionNotifications: z.enum(["off", "own", "all", "allowlist"]).optional(),
users: z.array(z.union([z.string(), z.number()])).optional(),
channels: z
.record(z.string(), DiscordGuildChannelSchema.optional())
.optional(),
});
export const DiscordAccountSchema = z.object({
name: z.string().optional(),
capabilities: z.array(z.string()).optional(),
enabled: z.boolean().optional(),
commands: ProviderCommandsSchema,
token: z.string().optional(),
allowBots: z.boolean().optional(),
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
historyLimit: z.number().int().min(0).optional(),
dmHistoryLimit: z.number().int().min(0).optional(),
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
textChunkLimit: z.number().int().positive().optional(),
blockStreaming: z.boolean().optional(),
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
maxLinesPerMessage: z.number().int().positive().optional(),
mediaMaxMb: z.number().positive().optional(),
retry: RetryConfigSchema,
actions: z
.object({
reactions: z.boolean().optional(),
stickers: z.boolean().optional(),
polls: z.boolean().optional(),
permissions: z.boolean().optional(),
messages: z.boolean().optional(),
threads: z.boolean().optional(),
pins: z.boolean().optional(),
search: z.boolean().optional(),
memberInfo: z.boolean().optional(),
roleInfo: z.boolean().optional(),
roles: z.boolean().optional(),
channelInfo: z.boolean().optional(),
voiceStatus: z.boolean().optional(),
events: z.boolean().optional(),
moderation: z.boolean().optional(),
})
.optional(),
replyToMode: ReplyToModeSchema.optional(),
dm: DiscordDmSchema.optional(),
guilds: z.record(z.string(), DiscordGuildSchema.optional()).optional(),
});
export const DiscordConfigSchema = DiscordAccountSchema.extend({
accounts: z.record(z.string(), DiscordAccountSchema.optional()).optional(),
});
export const SlackDmSchema = z
.object({
enabled: z.boolean().optional(),
policy: DmPolicySchema.optional().default("pairing"),
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
groupEnabled: z.boolean().optional(),
groupChannels: z.array(z.union([z.string(), z.number()])).optional(),
})
.superRefine((value, ctx) => {
requireOpenAllowFrom({
policy: value.policy,
allowFrom: value.allowFrom,
ctx,
path: ["allowFrom"],
message:
'channels.slack.dm.policy="open" requires channels.slack.dm.allowFrom to include "*"',
});
});
export const SlackChannelSchema = z.object({
enabled: z.boolean().optional(),
allow: z.boolean().optional(),
requireMention: z.boolean().optional(),
allowBots: z.boolean().optional(),
users: z.array(z.union([z.string(), z.number()])).optional(),
skills: z.array(z.string()).optional(),
systemPrompt: z.string().optional(),
});
export const SlackAccountSchema = z.object({
name: z.string().optional(),
capabilities: z.array(z.string()).optional(),
enabled: z.boolean().optional(),
commands: ProviderCommandsSchema,
botToken: z.string().optional(),
appToken: z.string().optional(),
allowBots: z.boolean().optional(),
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
historyLimit: z.number().int().min(0).optional(),
dmHistoryLimit: z.number().int().min(0).optional(),
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
textChunkLimit: z.number().int().positive().optional(),
blockStreaming: z.boolean().optional(),
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
mediaMaxMb: z.number().positive().optional(),
reactionNotifications: z.enum(["off", "own", "all", "allowlist"]).optional(),
reactionAllowlist: z.array(z.union([z.string(), z.number()])).optional(),
replyToMode: ReplyToModeSchema.optional(),
actions: z
.object({
reactions: z.boolean().optional(),
messages: z.boolean().optional(),
pins: z.boolean().optional(),
search: z.boolean().optional(),
permissions: z.boolean().optional(),
memberInfo: z.boolean().optional(),
channelInfo: z.boolean().optional(),
emojiList: z.boolean().optional(),
})
.optional(),
slashCommand: z
.object({
enabled: z.boolean().optional(),
name: z.string().optional(),
sessionPrefix: z.string().optional(),
ephemeral: z.boolean().optional(),
})
.optional(),
dm: SlackDmSchema.optional(),
channels: z.record(z.string(), SlackChannelSchema.optional()).optional(),
});
export const SlackConfigSchema = SlackAccountSchema.extend({
accounts: z.record(z.string(), SlackAccountSchema.optional()).optional(),
});
export const SignalAccountSchemaBase = z.object({
name: z.string().optional(),
capabilities: z.array(z.string()).optional(),
enabled: z.boolean().optional(),
account: z.string().optional(),
httpUrl: z.string().optional(),
httpHost: z.string().optional(),
httpPort: z.number().int().positive().optional(),
cliPath: ExecutableTokenSchema.optional(),
autoStart: z.boolean().optional(),
receiveMode: z.union([z.literal("on-start"), z.literal("manual")]).optional(),
ignoreAttachments: z.boolean().optional(),
ignoreStories: z.boolean().optional(),
sendReadReceipts: z.boolean().optional(),
dmPolicy: DmPolicySchema.optional().default("pairing"),
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
historyLimit: z.number().int().min(0).optional(),
dmHistoryLimit: z.number().int().min(0).optional(),
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
textChunkLimit: z.number().int().positive().optional(),
blockStreaming: z.boolean().optional(),
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
mediaMaxMb: z.number().int().positive().optional(),
reactionNotifications: z.enum(["off", "own", "all", "allowlist"]).optional(),
reactionAllowlist: z.array(z.union([z.string(), z.number()])).optional(),
});
export const SignalAccountSchema = SignalAccountSchemaBase.superRefine(
(value, ctx) => {
requireOpenAllowFrom({
policy: value.dmPolicy,
allowFrom: value.allowFrom,
ctx,
path: ["allowFrom"],
message:
'channels.signal.dmPolicy="open" requires channels.signal.allowFrom to include "*"',
});
},
);
export const SignalConfigSchema = SignalAccountSchemaBase.extend({
accounts: z.record(z.string(), SignalAccountSchema.optional()).optional(),
}).superRefine((value, ctx) => {
requireOpenAllowFrom({
policy: value.dmPolicy,
allowFrom: value.allowFrom,
ctx,
path: ["allowFrom"],
message:
'channels.signal.dmPolicy="open" requires channels.signal.allowFrom to include "*"',
});
});
export const IMessageAccountSchemaBase = z.object({
name: z.string().optional(),
capabilities: z.array(z.string()).optional(),
enabled: z.boolean().optional(),
cliPath: ExecutableTokenSchema.optional(),
dbPath: z.string().optional(),
service: z
.union([z.literal("imessage"), z.literal("sms"), z.literal("auto")])
.optional(),
region: z.string().optional(),
dmPolicy: DmPolicySchema.optional().default("pairing"),
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
historyLimit: z.number().int().min(0).optional(),
dmHistoryLimit: z.number().int().min(0).optional(),
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
includeAttachments: z.boolean().optional(),
mediaMaxMb: z.number().int().positive().optional(),
textChunkLimit: z.number().int().positive().optional(),
blockStreaming: z.boolean().optional(),
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
groups: z
.record(
z.string(),
z
.object({
requireMention: z.boolean().optional(),
})
.optional(),
)
.optional(),
});
export const IMessageAccountSchema = IMessageAccountSchemaBase.superRefine(
(value, ctx) => {
requireOpenAllowFrom({
policy: value.dmPolicy,
allowFrom: value.allowFrom,
ctx,
path: ["allowFrom"],
message:
'channels.imessage.dmPolicy="open" requires channels.imessage.allowFrom to include "*"',
});
},
);
export const IMessageConfigSchema = IMessageAccountSchemaBase.extend({
accounts: z.record(z.string(), IMessageAccountSchema.optional()).optional(),
}).superRefine((value, ctx) => {
requireOpenAllowFrom({
policy: value.dmPolicy,
allowFrom: value.allowFrom,
ctx,
path: ["allowFrom"],
message:
'channels.imessage.dmPolicy="open" requires channels.imessage.allowFrom to include "*"',
});
});
export const MSTeamsChannelSchema = z.object({
requireMention: z.boolean().optional(),
replyStyle: MSTeamsReplyStyleSchema.optional(),
});
export const MSTeamsTeamSchema = z.object({
requireMention: z.boolean().optional(),
replyStyle: MSTeamsReplyStyleSchema.optional(),
channels: z.record(z.string(), MSTeamsChannelSchema.optional()).optional(),
});
export const MSTeamsConfigSchema = z
.object({
enabled: z.boolean().optional(),
capabilities: z.array(z.string()).optional(),
appId: z.string().optional(),
appPassword: z.string().optional(),
tenantId: z.string().optional(),
webhook: z
.object({
port: z.number().int().positive().optional(),
path: z.string().optional(),
})
.optional(),
dmPolicy: DmPolicySchema.optional().default("pairing"),
allowFrom: z.array(z.string()).optional(),
groupAllowFrom: z.array(z.string()).optional(),
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
textChunkLimit: z.number().int().positive().optional(),
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
mediaAllowHosts: z.array(z.string()).optional(),
requireMention: z.boolean().optional(),
historyLimit: z.number().int().min(0).optional(),
dmHistoryLimit: z.number().int().min(0).optional(),
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
replyStyle: MSTeamsReplyStyleSchema.optional(),
teams: z.record(z.string(), MSTeamsTeamSchema.optional()).optional(),
})
.superRefine((value, ctx) => {
requireOpenAllowFrom({
policy: value.dmPolicy,
allowFrom: value.allowFrom,
ctx,
path: ["allowFrom"],
message:
'channels.msteams.dmPolicy="open" requires channels.msteams.allowFrom to include "*"',
});
});
+122
View File
@@ -0,0 +1,122 @@
import { z } from "zod";
import {
BlockStreamingCoalesceSchema,
DmConfigSchema,
DmPolicySchema,
GroupPolicySchema,
} from "./zod-schema.core.js";
export const WhatsAppAccountSchema = z
.object({
name: z.string().optional(),
capabilities: z.array(z.string()).optional(),
enabled: z.boolean().optional(),
messagePrefix: z.string().optional(),
/** Override auth directory for this WhatsApp account (Baileys multi-file auth state). */
authDir: z.string().optional(),
dmPolicy: DmPolicySchema.optional().default("pairing"),
selfChatMode: z.boolean().optional(),
allowFrom: z.array(z.string()).optional(),
groupAllowFrom: z.array(z.string()).optional(),
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
historyLimit: z.number().int().min(0).optional(),
dmHistoryLimit: z.number().int().min(0).optional(),
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
textChunkLimit: z.number().int().positive().optional(),
mediaMaxMb: z.number().int().positive().optional(),
blockStreaming: z.boolean().optional(),
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
groups: z
.record(
z.string(),
z
.object({
requireMention: z.boolean().optional(),
})
.optional(),
)
.optional(),
ackReaction: z
.object({
emoji: z.string().optional(),
direct: z.boolean().optional().default(true),
group: z
.enum(["always", "mentions", "never"])
.optional()
.default("mentions"),
})
.optional(),
})
.superRefine((value, ctx) => {
if (value.dmPolicy !== "open") return;
const allow = (value.allowFrom ?? [])
.map((v) => String(v).trim())
.filter(Boolean);
if (allow.includes("*")) return;
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["allowFrom"],
message:
'channels.whatsapp.accounts.*.dmPolicy="open" requires allowFrom to include "*"',
});
});
export const WhatsAppConfigSchema = z
.object({
accounts: z.record(z.string(), WhatsAppAccountSchema.optional()).optional(),
capabilities: z.array(z.string()).optional(),
dmPolicy: DmPolicySchema.optional().default("pairing"),
messagePrefix: z.string().optional(),
selfChatMode: z.boolean().optional(),
allowFrom: z.array(z.string()).optional(),
groupAllowFrom: z.array(z.string()).optional(),
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
historyLimit: z.number().int().min(0).optional(),
dmHistoryLimit: z.number().int().min(0).optional(),
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
textChunkLimit: z.number().int().positive().optional(),
mediaMaxMb: z.number().int().positive().optional().default(50),
blockStreaming: z.boolean().optional(),
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
actions: z
.object({
reactions: z.boolean().optional(),
sendMessage: z.boolean().optional(),
polls: z.boolean().optional(),
})
.optional(),
groups: z
.record(
z.string(),
z
.object({
requireMention: z.boolean().optional(),
})
.optional(),
)
.optional(),
ackReaction: z
.object({
emoji: z.string().optional(),
direct: z.boolean().optional().default(true),
group: z
.enum(["always", "mentions", "never"])
.optional()
.default("mentions"),
})
.optional(),
})
.superRefine((value, ctx) => {
if (value.dmPolicy !== "open") return;
const allow = (value.allowFrom ?? [])
.map((v) => String(v).trim())
.filter(Boolean);
if (allow.includes("*")) return;
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["allowFrom"],
message:
'channels.whatsapp.dmPolicy="open" requires channels.whatsapp.allowFrom to include "*"',
});
});
+26
View File
@@ -0,0 +1,26 @@
import { z } from "zod";
import {
DiscordConfigSchema,
IMessageConfigSchema,
MSTeamsConfigSchema,
SignalConfigSchema,
SlackConfigSchema,
TelegramConfigSchema,
} from "./zod-schema.providers-core.js";
import { WhatsAppConfigSchema } from "./zod-schema.providers-whatsapp.js";
export * from "./zod-schema.providers-core.js";
export * from "./zod-schema.providers-whatsapp.js";
export const ChannelsSchema = z
.object({
whatsapp: WhatsAppConfigSchema.optional(),
telegram: TelegramConfigSchema.optional(),
discord: DiscordConfigSchema.optional(),
slack: SlackConfigSchema.optional(),
signal: SignalConfigSchema.optional(),
imessage: IMessageConfigSchema.optional(),
msteams: MSTeamsConfigSchema.optional(),
})
.optional();
+85
View File
@@ -0,0 +1,85 @@
import { z } from "zod";
import {
GroupChatSchema,
NativeCommandsSettingSchema,
QueueSchema,
} from "./zod-schema.core.js";
export const SessionSchema = z
.object({
scope: z.union([z.literal("per-sender"), z.literal("global")]).optional(),
resetTriggers: z.array(z.string()).optional(),
idleMinutes: z.number().int().positive().optional(),
heartbeatIdleMinutes: z.number().int().positive().optional(),
store: z.string().optional(),
typingIntervalSeconds: z.number().int().positive().optional(),
typingMode: z
.union([
z.literal("never"),
z.literal("instant"),
z.literal("thinking"),
z.literal("message"),
])
.optional(),
mainKey: z.string().optional(),
sendPolicy: z
.object({
default: z.union([z.literal("allow"), z.literal("deny")]).optional(),
rules: z
.array(
z.object({
action: z.union([z.literal("allow"), z.literal("deny")]),
match: z
.object({
channel: z.string().optional(),
chatType: z
.union([
z.literal("direct"),
z.literal("group"),
z.literal("room"),
])
.optional(),
keyPrefix: z.string().optional(),
})
.optional(),
}),
)
.optional(),
})
.optional(),
agentToAgent: z
.object({
maxPingPongTurns: z.number().int().min(0).max(5).optional(),
})
.optional(),
})
.optional();
export const MessagesSchema = z
.object({
messagePrefix: z.string().optional(),
responsePrefix: z.string().optional(),
groupChat: GroupChatSchema,
queue: QueueSchema,
ackReaction: z.string().optional(),
ackReactionScope: z
.enum(["group-mentions", "group-all", "direct", "all"])
.optional(),
removeAckAfterReply: z.boolean().optional(),
})
.optional();
export const CommandsSchema = z
.object({
native: NativeCommandsSettingSchema.optional().default("auto"),
text: z.boolean().optional(),
bash: z.boolean().optional(),
bashForegroundMs: z.number().int().min(0).max(30_000).optional(),
config: z.boolean().optional(),
debug: z.boolean().optional(),
restart: z.boolean().optional(),
useAccessGroups: z.boolean().optional(),
})
.optional()
.default({ native: "auto" });
+15 -1446
View File
File diff suppressed because it is too large Load Diff