From b817225fb8abc19d3a0cf6b101ba7a809aa096db Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 14 Dec 2025 04:21:27 +0000 Subject: [PATCH] feat(agent): enforce provider/model and identity defaults --- docs/configuration.md | 21 +++++++++- src/agents/defaults.ts | 1 + src/agents/pi.ts | 55 ++++++++++++++++--------- src/agents/types.ts | 2 + src/auto-reply/command-reply.ts | 2 + src/auto-reply/reply.ts | 19 ++++++++- src/config/config.ts | 71 ++++++++++++++++++++++++++++++++- 7 files changed, 150 insertions(+), 21 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 2538b2b20..29b078c00 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -12,6 +12,7 @@ If the file is missing, CLAWDIS uses safe-ish defaults (bundled Pi in RPC mode + - restrict who can trigger the bot (`inbound.allowFrom`) - tune group mention behavior (`inbound.groupChat`) - customize the agent command (`inbound.reply.command`) + - set the agent’s identity (`identity`) ## Minimal config (recommended starting point) @@ -25,6 +26,21 @@ If the file is missing, CLAWDIS uses safe-ish defaults (bundled Pi in RPC mode + ## Common options +### `identity` + +Optional agent identity used for defaults and UX. This is written by the macOS onboarding assistant. + +If set, CLAWDIS derives defaults (only when you haven’t set them explicitly): +- `inbound.responsePrefix` from `identity.emoji` +- `inbound.groupChat.mentionPatterns` from `identity.name` (so “@Samantha” works in groups) +- `inbound.reply.session.sessionIntro` when `inbound.reply` is present (and for default Pi runs) + +```json5 +{ + identity: { name: "Samantha", theme: "helpful sloth", emoji: "🦥" } +} +``` + ### `logging` - Default log file: `/tmp/clawdis/clawdis-YYYY-MM-DD.log` @@ -69,9 +85,11 @@ Controls how CLAWDIS produces replies. Two modes: - `mode: "command"` — run a local command and use its stdout as the reply (typical) If you **omit** `inbound.reply`, CLAWDIS defaults to the bundled Pi binary in **RPC** mode: -- command: `pi --mode rpc {{BodyStripped}}` +- command (base): `pi --mode rpc {{BodyStripped}}` - per-sender sessions + `/new` resets +Safety default: when invoking Pi, CLAWDIS always passes `--provider` and `--model` (unless you already specified them). + Example command-mode config: ```json5 @@ -100,6 +118,7 @@ Example command-mode config: kind: "pi", format: "json", // Only used for status/usage labeling (Pi may report its own model) + provider: "anthropic", model: "claude-opus-4-5", contextTokens: 200000 } diff --git a/src/agents/defaults.ts b/src/agents/defaults.ts index 342c21ada..614fac3a8 100644 --- a/src/agents/defaults.ts +++ b/src/agents/defaults.ts @@ -1,5 +1,6 @@ // Defaults for agent metadata when upstream does not supply them. // Model id uses pi-ai's built-in Anthropic catalog. +export const DEFAULT_PROVIDER = "anthropic"; export const DEFAULT_MODEL = "claude-opus-4-5"; // Context window: Opus 4.5 supports ~200k tokens (per pi-ai models.generated.ts). export const DEFAULT_CONTEXT_TOKENS = 200_000; diff --git a/src/agents/pi.ts b/src/agents/pi.ts index 745aff090..48750199c 100644 --- a/src/agents/pi.ts +++ b/src/agents/pi.ts @@ -1,5 +1,6 @@ import path from "node:path"; +import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js"; import type { AgentMeta, AgentParseResult, @@ -165,33 +166,51 @@ function parsePiJson(raw: string): AgentParseResult { }; } +function isPiInvocation(argv: string[]): boolean { + if (argv.length === 0) return false; + const base = path.basename(argv[0]).replace(/\.(m?js)$/i, ""); + if (base === "pi" || base === "tau") return true; + + // Also handle node entrypoints like `node /.../pi-mono/packages/coding-agent/dist/cli.js` + if (base === "node" && argv.length > 1) { + const second = argv[1]?.toString().toLowerCase(); + return ( + second.includes("pi-mono") && + second.includes("packages") && + second.includes("coding-agent") && + (second.endsWith("cli.js") || second.includes("/dist/cli")) + ); + } + + return false; +} + export const piSpec: AgentSpec = { kind: "pi", - isInvocation: (argv) => { - if (argv.length === 0) return false; - const base = path.basename(argv[0]).replace(/\.(m?js)$/i, ""); - if (base === "pi" || base === "tau") return true; - - // Also handle node entrypoints like `node /.../pi-mono/packages/coding-agent/dist/cli.js` - if (base === "node" && argv.length > 1) { - const second = argv[1]?.toString().toLowerCase(); - return ( - second.includes("pi-mono") && - second.includes("packages") && - second.includes("coding-agent") && - (second.endsWith("cli.js") || second.includes("/dist/cli")) - ); - } - - return false; - }, + isInvocation: isPiInvocation, buildArgs: (ctx) => { const argv = [...ctx.argv]; + if (!isPiInvocation(argv)) return argv; let bodyPos = ctx.bodyIndex; const modeIdx = argv.indexOf("--mode"); const modeVal = modeIdx >= 0 ? argv[modeIdx + 1]?.toString().toLowerCase() : undefined; const isRpcMode = modeVal === "rpc"; + + const desiredProvider = (ctx.provider ?? DEFAULT_PROVIDER).trim(); + const desiredModel = (ctx.model ?? DEFAULT_MODEL).trim(); + const hasFlag = (flag: string) => + argv.includes(flag) || argv.some((a) => a.startsWith(`${flag}=`)); + + if (desiredProvider && !hasFlag("--provider")) { + argv.splice(bodyPos, 0, "--provider", desiredProvider); + bodyPos += 2; + } + if (desiredModel && !hasFlag("--model")) { + argv.splice(bodyPos, 0, "--model", desiredModel); + bodyPos += 2; + } + // Non-interactive print + JSON if (!isRpcMode && !argv.includes("-p") && !argv.includes("--print")) { argv.splice(bodyPos, 0, "-p"); diff --git a/src/agents/types.ts b/src/agents/types.ts index 78cb5dd17..a474ba3a4 100644 --- a/src/agents/types.ts +++ b/src/agents/types.ts @@ -34,6 +34,8 @@ export type BuildArgsContext = { bodyIndex: number; // index of prompt/body argument in argv isNewSession: boolean; sessionId?: string; + provider?: string; + model?: string; sendSystemOnce: boolean; systemSent: boolean; identityPrefix?: string; diff --git a/src/auto-reply/command-reply.ts b/src/auto-reply/command-reply.ts index 6d1dfd234..5d20aa030 100644 --- a/src/auto-reply/command-reply.ts +++ b/src/auto-reply/command-reply.ts @@ -482,6 +482,8 @@ export async function runCommandReply( bodyIndex, isNewSession, sessionId: templatingCtx.SessionId, + provider: agentCfg.provider, + model: agentCfg.model, sendSystemOnce, systemSent, identityPrefix: agentCfg.identityPrefix, diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts index bd8b13148..003572a8b 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -1,7 +1,11 @@ import crypto from "node:crypto"; import { lookupContextTokens } from "../agents/context.js"; -import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL } from "../agents/defaults.js"; +import { + DEFAULT_CONTEXT_TOKENS, + DEFAULT_MODEL, + DEFAULT_PROVIDER, +} from "../agents/defaults.js"; import { resolveBundledPiBinary } from "../agents/pi-path.js"; import { DEFAULT_AGENT_WORKSPACE_DIR, @@ -152,6 +156,7 @@ function makeDefaultPiReply(): ResolvedReplyConfig { command: [piBin, "--mode", "rpc", "{{BodyStripped}}"], agent: { kind: "pi" as const, + provider: DEFAULT_PROVIDER, model: DEFAULT_MODEL, contextTokens: defaultContext, format: "json" as const, @@ -177,6 +182,18 @@ export async function getReplyFromConfig( const reply: ResolvedReplyConfig = configuredReply ? { ...configuredReply, cwd: configuredReply.cwd ?? workspaceDir } : { ...makeDefaultPiReply(), cwd: workspaceDir }; + const identity = cfg.identity; + if (identity?.name?.trim() && reply.session && !reply.session.sessionIntro) { + const name = identity.name.trim(); + const theme = identity.theme?.trim(); + const emoji = identity.emoji?.trim(); + const introParts = [ + `You are ${name}.`, + theme ? `Theme: ${theme}.` : undefined, + emoji ? `Your emoji is ${emoji}.` : undefined, + ].filter(Boolean); + reply.session = { ...reply.session, sessionIntro: introParts.join(" ") }; + } // Bootstrap the workspace (and a starter AGENTS.md) only when we actually run from it. if (reply.mode === "command" && typeof reply.cwd === "string") { diff --git a/src/config/config.ts b/src/config/config.ts index 642db35ca..8b36deea0 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -85,6 +85,11 @@ export type GroupChatConfig = { }; export type ClawdisConfig = { + identity?: { + name?: string; + theme?: string; + emoji?: string; + }; logging?: LoggingConfig; browser?: BrowserConfig; inbound?: { @@ -120,6 +125,7 @@ export type ClawdisConfig = { kind: AgentKind; format?: "text" | "json"; identityPrefix?: string; + provider?: string; model?: string; contextTokens?: number; }; @@ -185,6 +191,7 @@ const ReplySchema = z kind: z.literal("pi"), format: z.union([z.literal("text"), z.literal("json")]).optional(), identityPrefix: z.string().optional(), + provider: z.string().optional(), model: z.string().optional(), contextTokens: z.number().int().positive().optional(), }) @@ -202,6 +209,13 @@ const ReplySchema = z ); 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 @@ -291,6 +305,61 @@ const ClawdisSchema = z.object({ .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 theme = identity.theme?.trim(); + + const inbound = cfg.inbound ?? {}; + const groupChat = inbound.groupChat ?? {}; + const reply = inbound.reply ?? undefined; + const session = reply?.session ?? undefined; + + 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; + } + + if (name && reply && !session?.sessionIntro) { + const introParts = [ + `You are ${name}.`, + theme ? `Theme: ${theme}.` : undefined, + emoji ? `Your emoji is ${emoji}.` : undefined, + ].filter(Boolean); + next.inbound = { + ...(next.inbound ?? inbound), + reply: { + ...reply, + session: { ...(session ?? {}), sessionIntro: introParts.join(" ") }, + }, + }; + mutated = true; + } + + return mutated ? next : cfg; +} + export function loadConfig(): ClawdisConfig { // Read config file (JSON5) if present. const configPath = CONFIG_PATH_CLAWDIS; @@ -307,7 +376,7 @@ export function loadConfig(): ClawdisConfig { } return {}; } - return validated.data as ClawdisConfig; + return applyIdentityDefaults(validated.data as ClawdisConfig); } catch (err) { console.error(`Failed to read config at ${configPath}`, err); return {};