import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import JSON5 from "json5"; import { z } from "zod"; export type SessionScope = "per-sender" | "global"; export type SessionConfig = { scope?: SessionScope; resetTriggers?: string[]; idleMinutes?: number; heartbeatIdleMinutes?: number; store?: string; typingIntervalSeconds?: number; mainKey?: string; }; export type LoggingConfig = { level?: "silent" | "fatal" | "error" | "warn" | "info" | "debug" | "trace"; file?: string; }; export type WebReconnectConfig = { initialMs?: number; maxMs?: number; factor?: number; jitter?: number; maxAttempts?: number; // 0 = unlimited }; export type WebConfig = { heartbeatSeconds?: number; reconnect?: WebReconnectConfig; }; export type WebChatConfig = { enabled?: boolean; port?: number; }; export type BrowserConfig = { enabled?: boolean; /** Base URL of the clawd browser control server. Default: http://127.0.0.1:18791 */ controlUrl?: string; /** Accent color for the clawd browser profile (hex). Default: #FF4500 */ color?: string; /** Start Chrome headless (best-effort). Default: false */ headless?: boolean; /** If true: never launch; only attach to an existing browser. Default: false */ attachOnly?: boolean; }; export type CronConfig = { enabled?: boolean; store?: string; maxConcurrentRuns?: number; }; export type TelegramConfig = { botToken?: string; requireMention?: boolean; allowFrom?: Array; mediaMaxMb?: number; proxy?: string; webhookUrl?: string; webhookSecret?: string; webhookPath?: string; }; export type GroupChatConfig = { requireMention?: boolean; mentionPatterns?: string[]; historyLimit?: number; }; export type BridgeBindMode = "auto" | "lan" | "tailnet" | "loopback"; export type BridgeConfig = { enabled?: boolean; port?: number; /** * Bind address policy for the Iris bridge server. * - auto: prefer tailnet IP when present, else LAN (0.0.0.0) * - lan: 0.0.0.0 (reachable on local network + any forwarded interfaces) * - tailnet: bind only to the Tailscale interface IP (100.64.0.0/10) * - loopback: 127.0.0.1 */ bind?: BridgeBindMode; }; export type WideAreaDiscoveryConfig = { enabled?: boolean; }; export type DiscoveryConfig = { wideArea?: WideAreaDiscoveryConfig; }; export type ClawdisConfig = { identity?: { name?: string; theme?: string; emoji?: string; }; logging?: LoggingConfig; browser?: BrowserConfig; inbound?: { allowFrom?: string[]; // E.164 numbers allowed to trigger auto-reply (without whatsapp:) /** Agent working directory (preferred). Used as the default cwd for agent runs. */ workspace?: string; messagePrefix?: string; // Prefix added to all inbound messages (default: "[clawdis]" if no allowFrom, else "") responsePrefix?: string; // Prefix auto-added to all outbound replies (e.g., "🦞") timestampPrefix?: boolean | string; // true/false or IANA timezone string (default: true with UTC) transcribeAudio?: { // Optional CLI to turn inbound audio into text; templated args, must output transcript to stdout. command: string[]; timeoutSeconds?: number; }; groupChat?: GroupChatConfig; agent?: { /** Provider id, e.g. "anthropic" or "openai" (pi-ai catalog). */ provider?: string; /** Model id within provider, e.g. "claude-opus-4-5". */ model?: string; /** Optional display-only context window override (used for % in status UIs). */ contextTokens?: number; /** Default thinking level when no /think directive is present. */ thinkingDefault?: "off" | "minimal" | "low" | "medium" | "high"; /** Default verbose level when no /verbose directive is present. */ verboseDefault?: "off" | "on"; timeoutSeconds?: number; /** Max inbound media size in MB for agent-visible attachments (text note or future image attach). */ mediaMaxMb?: number; typingIntervalSeconds?: number; /** Periodic background heartbeat runs (minutes). 0 disables. */ heartbeatMinutes?: number; }; session?: SessionConfig; }; web?: WebConfig; telegram?: TelegramConfig; webchat?: WebChatConfig; cron?: CronConfig; bridge?: BridgeConfig; discovery?: DiscoveryConfig; }; // New branding path (preferred) export const CONFIG_PATH_CLAWDIS = path.join( os.homedir(), ".clawdis", "clawdis.json", ); const ClawdisSchema = z.object({ identity: z .object({ name: z.string().optional(), theme: z.string().optional(), emoji: z.string().optional(), }) .optional(), logging: z .object({ level: z .union([ z.literal("silent"), z.literal("fatal"), z.literal("error"), z.literal("warn"), z.literal("info"), z.literal("debug"), z.literal("trace"), ]) .optional(), file: z.string().optional(), }) .optional(), browser: z .object({ enabled: z.boolean().optional(), controlUrl: z.string().optional(), color: z.string().optional(), headless: z.boolean().optional(), attachOnly: z.boolean().optional(), }) .optional(), inbound: z .object({ allowFrom: z.array(z.string()).optional(), workspace: z.string().optional(), messagePrefix: z.string().optional(), responsePrefix: z.string().optional(), timestampPrefix: z.union([z.boolean(), z.string()]).optional(), groupChat: z .object({ requireMention: z.boolean().optional(), mentionPatterns: z.array(z.string()).optional(), historyLimit: z.number().int().positive().optional(), }) .optional(), transcribeAudio: z .object({ command: z.array(z.string()), timeoutSeconds: z.number().int().positive().optional(), }) .optional(), agent: z .object({ provider: z.string().optional(), model: z.string().optional(), contextTokens: z.number().int().positive().optional(), thinkingDefault: z .union([ z.literal("off"), z.literal("minimal"), z.literal("low"), z.literal("medium"), z.literal("high"), ]) .optional(), verboseDefault: z .union([z.literal("off"), z.literal("on")]) .optional(), timeoutSeconds: z.number().int().positive().optional(), mediaMaxMb: z.number().positive().optional(), typingIntervalSeconds: z.number().int().positive().optional(), heartbeatMinutes: z.number().nonnegative().optional(), }) .optional(), session: 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(), mainKey: z.string().optional(), }) .optional(), }) .optional(), cron: z .object({ enabled: z.boolean().optional(), store: z.string().optional(), maxConcurrentRuns: z.number().int().positive().optional(), }) .optional(), web: z .object({ heartbeatSeconds: z.number().int().positive().optional(), reconnect: z .object({ initialMs: z.number().positive().optional(), maxMs: z.number().positive().optional(), factor: z.number().positive().optional(), jitter: z.number().min(0).max(1).optional(), maxAttempts: z.number().int().min(0).optional(), }) .optional(), }) .optional(), webchat: z .object({ enabled: z.boolean().optional(), port: z.number().int().positive().optional(), }) .optional(), telegram: z .object({ botToken: z.string().optional(), requireMention: z.boolean().optional(), allowFrom: z.array(z.union([z.string(), z.number()])).optional(), mediaMaxMb: z.number().positive().optional(), proxy: z.string().optional(), webhookUrl: z.string().optional(), webhookSecret: z.string().optional(), webhookPath: z.string().optional(), }) .optional(), bridge: z .object({ enabled: z.boolean().optional(), port: z.number().int().positive().optional(), bind: z .union([ z.literal("auto"), z.literal("lan"), z.literal("tailnet"), z.literal("loopback"), ]) .optional(), }) .optional(), discovery: z .object({ wideArea: z .object({ enabled: z.boolean().optional(), }) .optional(), }) .optional(), }); function escapeRegExp(text: string): string { return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } function applyIdentityDefaults(cfg: ClawdisConfig): ClawdisConfig { const identity = cfg.identity; if (!identity) return cfg; const emoji = identity.emoji?.trim(); const name = identity.name?.trim(); const inbound = cfg.inbound ?? {}; const groupChat = inbound.groupChat ?? {}; let mutated = false; const next: ClawdisConfig = { ...cfg }; if (emoji && !inbound.responsePrefix) { next.inbound = { ...inbound, responsePrefix: emoji }; mutated = true; } if (name && !groupChat.mentionPatterns) { const parts = name.split(/\s+/).filter(Boolean).map(escapeRegExp); const re = parts.length ? parts.join("\\s+") : escapeRegExp(name); const pattern = `\\b@?${re}\\b`; next.inbound = { ...(next.inbound ?? inbound), groupChat: { ...groupChat, mentionPatterns: [pattern] }, }; mutated = true; } return mutated ? next : cfg; } export function loadConfig(): ClawdisConfig { // Read config file (JSON5) if present. const configPath = CONFIG_PATH_CLAWDIS; try { if (!fs.existsSync(configPath)) return {}; const raw = fs.readFileSync(configPath, "utf-8"); const parsed = JSON5.parse(raw); if (typeof parsed !== "object" || parsed === null) return {}; const validated = ClawdisSchema.safeParse(parsed); if (!validated.success) { console.error("Invalid config:"); for (const iss of validated.error.issues) { console.error(`- ${iss.path.join(".")}: ${iss.message}`); } return {}; } return applyIdentityDefaults(validated.data as ClawdisConfig); } catch (err) { console.error(`Failed to read config at ${configPath}`, err); return {}; } }