feat(agent): enforce provider/model and identity defaults
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -482,6 +482,8 @@ export async function runCommandReply(
|
||||
bodyIndex,
|
||||
isNewSession,
|
||||
sessionId: templatingCtx.SessionId,
|
||||
provider: agentCfg.provider,
|
||||
model: agentCfg.model,
|
||||
sendSystemOnce,
|
||||
systemSent,
|
||||
identityPrefix: agentCfg.identityPrefix,
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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 {};
|
||||
|
||||
Reference in New Issue
Block a user