From 364a6a944480d1f615227e83fe98733a23e83744 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 23 Dec 2025 23:45:20 +0000 Subject: [PATCH] feat: add per-session model selection --- README.md | 2 +- docs/AGENTS.default.md | 4 +- docs/agent-send.md | 2 +- docs/agent.md | 6 +- docs/clawd.md | 4 +- docs/configuration.md | 48 ++-- docs/heartbeat.md | 2 +- docs/images.md | 2 +- docs/research/memory.md | 5 +- docs/thinking.md | 2 +- docs/whatsapp.md | 9 +- src/agents/model-catalog.ts | 73 ++++++ src/agents/model-selection.ts | 75 +++++++ src/auto-reply/model.ts | 19 ++ src/auto-reply/reply.directive.test.ts | 143 +++++++++++- src/auto-reply/reply.triggers.test.ts | 28 ++- src/auto-reply/reply.ts | 295 +++++++++++++++++-------- src/auto-reply/status.ts | 2 +- src/cli/program.ts | 4 +- src/commands/agent.test.ts | 7 +- src/commands/agent.ts | 55 ++++- src/commands/sessions.test.ts | 4 +- src/commands/sessions.ts | 6 +- src/commands/setup.ts | 11 +- src/commands/status.ts | 4 +- src/config/config.test.ts | 4 +- src/config/config.ts | 89 ++++---- src/config/sessions.ts | 4 + src/cron/isolated-agent.test.ts | 7 +- src/cron/isolated-agent.ts | 4 +- src/gateway/server.test.ts | 7 +- src/gateway/server.ts | 88 ++------ src/web/auto-reply.test.ts | 10 +- src/web/auto-reply.ts | 4 +- 34 files changed, 729 insertions(+), 300 deletions(-) create mode 100644 src/agents/model-catalog.ts create mode 100644 src/agents/model-selection.ts create mode 100644 src/auto-reply/model.ts diff --git a/README.md b/README.md index f5e96e0d9..1cf9a9ea0 100644 --- a/README.md +++ b/README.md @@ -133,7 +133,7 @@ Runbook: `docs/ios/connect.md`. ## Agent workspace + skills -- Workspace root: `~/clawd` (configurable via `inbound.workspace`). +- Workspace root: `~/clawd` (configurable via `agent.workspace`). - Injected prompt files: `AGENTS.md`, `SOUL.md`, `TOOLS.md`. - Skills: `~/clawd/skills//SKILL.md`. diff --git a/docs/AGENTS.default.md b/docs/AGENTS.default.md index 5d1b2b11c..62750c68b 100644 --- a/docs/AGENTS.default.md +++ b/docs/AGENTS.default.md @@ -30,11 +30,11 @@ cp docs/templates/TOOLS.md ~/.clawdis/workspace/TOOLS.md cp docs/AGENTS.default.md ~/.clawdis/workspace/AGENTS.md ``` -4) Optional: choose a different workspace by setting `inbound.workspace` (supports `~`): +4) Optional: choose a different workspace by setting `agent.workspace` (supports `~`): ```json5 { - inbound: { + agent: { workspace: "~/clawd" } } diff --git a/docs/agent-send.md b/docs/agent-send.md index 2ab574942..fd98c7543 100644 --- a/docs/agent-send.md +++ b/docs/agent-send.md @@ -12,7 +12,7 @@ read_when: - Session selection: - If `--session-id` is given, reuse it. - Else if `--to ` is given, derive the session key from `inbound.session.scope` (direct chats collapse to `inbound.session.mainKey`). -- Runs the embedded Pi agent (configured via `inbound.agent`). +- Runs the embedded Pi agent (configured via `agent`). - Thinking/verbose: - Flags `--thinking ` and `--verbose ` persist into the session store. - Output: diff --git a/docs/agent.md b/docs/agent.md index 9292ea16b..c707511fa 100644 --- a/docs/agent.md +++ b/docs/agent.md @@ -10,13 +10,13 @@ CLAWDIS runs a single embedded agent runtime derived from **p-mono** (internal n ## Workspace (required) -You must set an agent home directory via `inbound.workspace`. CLAWDIS uses this as the agent’s **only** working directory (`cwd`) for tools and context. +You must set an agent home directory via `agent.workspace`. CLAWDIS uses this as the agent’s **only** working directory (`cwd`) for tools and context. Recommended: use `clawdis setup` to create `~/.clawdis/clawdis.json` if missing and initialize the workspace files. ## Bootstrap files (injected) -Inside `inbound.workspace`, CLAWDIS expects these user-editable files: +Inside `agent.workspace`, CLAWDIS expects these user-editable files: - `AGENTS.md` — operating instructions + “memory” - `SOUL.md` — persona, boundaries, tone - `TOOLS.md` — user-maintained tool notes (e.g. `imsg`, `sag`, conventions) @@ -75,7 +75,7 @@ Incoming user messages are queued while the agent is streaming. The queue is che ## Configuration (minimal) At minimum, set: -- `inbound.workspace` +- `agent.workspace` - `inbound.allowFrom` (strongly recommended) --- diff --git a/docs/clawd.md b/docs/clawd.md index f5881e1b0..cf296c713 100644 --- a/docs/clawd.md +++ b/docs/clawd.md @@ -94,7 +94,7 @@ Tip: treat this folder like Clawd’s “memory” and make it a git repo (ideal clawdis setup ``` -Optional: choose a different workspace with `inbound.workspace` (supports `~`). +Optional: choose a different workspace with `agent.workspace` (supports `~`). ```json5 { @@ -149,7 +149,7 @@ Example: ## Heartbeats (proactive mode) -When `inbound.agent.heartbeatMinutes > 0`, CLAWDIS periodically runs a heartbeat prompt (default: `HEARTBEAT`). +When `agent.heartbeatMinutes > 0`, CLAWDIS periodically runs a heartbeat prompt (default: `HEARTBEAT`). - If the agent replies with `HEARTBEAT_OK` (exact token), CLAWDIS suppresses outbound delivery for that heartbeat. diff --git a/docs/configuration.md b/docs/configuration.md index 19cad5186..dc25dd626 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -11,18 +11,16 @@ CLAWDIS reads an optional **JSON5** config from `~/.clawdis/clawdis.json` (comme If the file is missing, CLAWDIS uses safe-ish defaults (embedded Pi agent + per-sender sessions + workspace `~/clawd`). You usually only need a config to: - restrict who can trigger the bot (`inbound.allowFrom`) - tune group mention behavior (`inbound.groupChat`) -- set the agent’s workspace (`inbound.workspace`) -- tune the embedded agent (`inbound.agent`) and session behavior (`inbound.session`) +- set the agent’s workspace (`agent.workspace`) +- tune the embedded agent (`agent`) and session behavior (`inbound.session`) - set the agent’s identity (`identity`) ## Minimal config (recommended starting point) ```json5 { - inbound: { - allowFrom: ["+15555550123"], - workspace: "~/clawd" - } + agent: { workspace: "~/clawd" }, + inbound: { allowFrom: ["+15555550123"] } } ``` @@ -86,7 +84,7 @@ Group messages default to **require mention** (either metadata mention or regex } ``` -### `inbound.workspace` +### `agent.workspace` Sets the **single global workspace directory** used by the agent for file operations. @@ -94,29 +92,33 @@ Default: `~/clawd`. ```json5 { - inbound: { workspace: "~/clawd" } + agent: { workspace: "~/clawd" } } ``` -### `inbound.agent` +### `agent` Controls the embedded agent runtime (provider/model/thinking/verbose/timeouts). +`allowedModels` lets `/model` list/filter and enforce a per-session allowlist +(omit to show the full catalog). ```json5 { - inbound: { - workspace: "~/clawd", - agent: { - provider: "anthropic", - model: "claude-opus-4-5", - thinkingDefault: "low", - verboseDefault: "off", - timeoutSeconds: 600, - mediaMaxMb: 5, - heartbeatMinutes: 30, - contextTokens: 200000 - } - } + agent: { + provider: "anthropic", + model: "claude-opus-4-5", + allowedModels: [ + "anthropic/claude-opus-4-5", + "anthropic/claude-sonnet-4-1" + ], + thinkingDefault: "low", + verboseDefault: "off", + timeoutSeconds: 600, + mediaMaxMb: 5, + heartbeatMinutes: 30, + contextTokens: 200000 + }, + inbound: { workspace: "~/clawd" } } ``` @@ -132,7 +134,7 @@ When `models.providers` is present, Clawdis writes/merges a `models.json` into - default behavior: **merge** (keeps existing providers, overrides on name) - set `models.mode: "replace"` to overwrite the file contents -Select the model via `inbound.agent.provider` + `inbound.agent.model`. +Select the model via `agent.provider` + `agent.model`. ```json5 { diff --git a/docs/heartbeat.md b/docs/heartbeat.md index 1d37d1346..9ddc4fe70 100644 --- a/docs/heartbeat.md +++ b/docs/heartbeat.md @@ -12,7 +12,7 @@ Goal: add a simple heartbeat poll for the embedded agent that only notifies user - Keep existing WhatsApp length guidance; forbid burying the sentinel inside alerts. ## Config & defaults -- New config key: `inbound.agent.heartbeatMinutes` (number of minutes; `0` disables). +- New config key: `agent.heartbeatMinutes` (number of minutes; `0` disables). - Default: 30 minutes. - New optional idle override for heartbeats: `inbound.session.heartbeatIdleMinutes` (defaults to `idleMinutes`). Heartbeat skips do **not** update the session `updatedAt` so idle expiry still works. diff --git a/docs/images.md b/docs/images.md index 5d37c3fd7..d4e4b159d 100644 --- a/docs/images.md +++ b/docs/images.md @@ -21,7 +21,7 @@ CLAWDIS is now **web-only** (Baileys). This document captures the current media ## Web Provider Behavior - Input: local file path **or** HTTP(S) URL. - Flow: load into a Buffer, detect media kind, and build the correct payload: - - **Images:** resize & recompress to JPEG (max side 2048px) targeting `inbound.agent.mediaMaxMb` (default 5 MB), capped at 6 MB. + - **Images:** resize & recompress to JPEG (max side 2048px) targeting `agent.mediaMaxMb` (default 5 MB), capped at 6 MB. - **Audio/Voice/Video:** pass-through up to 16 MB; audio is sent as a voice note (`ptt: true`). - **Documents:** anything else, up to 100 MB, with filename preserved when available. - MIME detection prefers magic bytes, then headers, then file extension. diff --git a/docs/research/memory.md b/docs/research/memory.md index 5d64aa2ac..ea569f165 100644 --- a/docs/research/memory.md +++ b/docs/research/memory.md @@ -8,7 +8,7 @@ read_when: # Workspace Memory v2 (offline): proposal + research -Target: Clawd-style workspace (`inbound.workspace`, default `~/clawd`) where “memory” is stored as one Markdown file per day (`memory/YYYY-MM-DD.md`) plus a small set of stable files (e.g. `memory.md`, `SOUL.md`). +Target: Clawd-style workspace (`agent.workspace`, default `~/clawd`) where “memory” is stored as one Markdown file per day (`memory/YYYY-MM-DD.md`) plus a small set of stable files (e.g. `memory.md`, `SOUL.md`). This doc proposes an **offline-first** memory architecture that keeps Markdown as the canonical, reviewable source of truth, but adds **structured recall** (search, entity summaries, confidence updates) via a derived index. @@ -159,7 +159,7 @@ Recommendation: **deep integration in Clawdis**, but keep a separable core libra ### Why integrate into Clawdis? - Clawdis already knows: - - the workspace path (`inbound.workspace`) + - the workspace path (`agent.workspace`) - the session model + heartbeats - logging + troubleshooting patterns - You want the agent itself to call the tools: @@ -225,4 +225,3 @@ Open question: - Letta / MemGPT concepts: “core memory blocks” + “archival memory” + tool-driven self-editing memory. - Hindsight Technical Report: “retain / recall / reflect”, four-network memory, narrative fact extraction, opinion confidence evolution. - SuCo: arXiv 2411.14754 (2024): “Subspace Collision” approximate nearest neighbor retrieval. - diff --git a/docs/thinking.md b/docs/thinking.md index d58af351c..5e208d939 100644 --- a/docs/thinking.md +++ b/docs/thinking.md @@ -17,7 +17,7 @@ read_when: ## Resolution order 1. Inline directive on the message (applies only to that message). 2. Session override (set by sending a directive-only message). -3. Global default (`inbound.agent.thinkingDefault` in config). +3. Global default (`agent.thinkingDefault` in config). 4. Fallback: off. ## Setting a session default diff --git a/docs/whatsapp.md b/docs/whatsapp.md index 1304474f3..c7260387e 100644 --- a/docs/whatsapp.md +++ b/docs/whatsapp.md @@ -80,13 +80,13 @@ Status: WhatsApp Web via Baileys only. Gateway owns the single session. ## Media limits + optimization - Default cap: 5 MB (per media item). -- Override: `inbound.agent.mediaMaxMb`. +- Override: `agent.mediaMaxMb`. - Images are auto-optimized to JPEG under cap (resize + quality sweep). - Oversize media => error; media reply falls back to text warning. ## Heartbeats - **Gateway heartbeat** logs connection health (`web.heartbeatSeconds`, default 60s). -- **Reply heartbeat** asks agent on a timer (`inbound.agent.heartbeatMinutes`). +- **Reply heartbeat** asks agent on a timer (`agent.heartbeatMinutes`). - Uses `HEARTBEAT` prompt + `HEARTBEAT_TOKEN` skip behavior. - Skips if queue busy or last inbound was a group. - Falls back to last direct recipient if needed. @@ -103,8 +103,8 @@ Status: WhatsApp Web via Baileys only. Gateway owns the single session. - `inbound.groupChat.historyLimit` - `inbound.messagePrefix` (inbound prefix) - `inbound.responsePrefix` (outbound prefix) -- `inbound.agent.mediaMaxMb` -- `inbound.agent.heartbeatMinutes` +- `agent.mediaMaxMb` +- `agent.heartbeatMinutes` - `inbound.session.*` (scope, idle, store, mainKey) - `web.heartbeatSeconds` - `web.reconnect.*` @@ -118,4 +118,3 @@ Status: WhatsApp Web via Baileys only. Gateway owns the single session. - `src/web/auto-reply.test.ts` (mention gating, history injection, reply flow) - `src/web/monitor-inbox.test.ts` (inbound parsing + reply context) - `src/web/outbound.test.ts` (send mapping + media) - diff --git a/src/agents/model-catalog.ts b/src/agents/model-catalog.ts new file mode 100644 index 000000000..f4bbc7268 --- /dev/null +++ b/src/agents/model-catalog.ts @@ -0,0 +1,73 @@ +import { resolveClawdisAgentDir } from "./agent-paths.js"; +import { type ClawdisConfig, loadConfig } from "../config/config.js"; +import { ensureClawdisModelsJson } from "./models-config.js"; + +export type ModelCatalogEntry = { + id: string; + name: string; + provider: string; + contextWindow?: number; +}; + +let modelCatalogPromise: Promise | null = null; + +export function resetModelCatalogCacheForTest() { + modelCatalogPromise = null; +} + +export async function loadModelCatalog(params?: { + config?: ClawdisConfig; + useCache?: boolean; +}): Promise { + if (params?.useCache === false) { + modelCatalogPromise = null; + } + if (modelCatalogPromise) return modelCatalogPromise; + + modelCatalogPromise = (async () => { + const piSdk = (await import("@mariozechner/pi-coding-agent")) as { + discoverModels: (agentDir?: string) => Array<{ + id: string; + name?: string; + provider: string; + contextWindow?: number; + }>; + }; + + let entries: Array<{ + id: string; + name?: string; + provider: string; + contextWindow?: number; + }> = []; + try { + const cfg = params?.config ?? loadConfig(); + await ensureClawdisModelsJson(cfg); + entries = piSdk.discoverModels(resolveClawdisAgentDir()); + } catch { + entries = []; + } + + const models: ModelCatalogEntry[] = []; + for (const entry of entries) { + const id = String(entry?.id ?? "").trim(); + if (!id) continue; + const provider = String(entry?.provider ?? "").trim(); + if (!provider) continue; + const name = String(entry?.name ?? id).trim() || id; + const contextWindow = + typeof entry?.contextWindow === "number" && entry.contextWindow > 0 + ? entry.contextWindow + : undefined; + models.push({ id, name, provider, contextWindow }); + } + + return models.sort((a, b) => { + const p = a.provider.localeCompare(b.provider); + if (p !== 0) return p; + return a.name.localeCompare(b.name); + }); + })(); + + return modelCatalogPromise; +} diff --git a/src/agents/model-selection.ts b/src/agents/model-selection.ts new file mode 100644 index 000000000..798013e78 --- /dev/null +++ b/src/agents/model-selection.ts @@ -0,0 +1,75 @@ +import type { ClawdisConfig } from "../config/config.js"; +import type { ModelCatalogEntry } from "./model-catalog.js"; + +export type ModelRef = { + provider: string; + model: string; +}; + +export function modelKey(provider: string, model: string) { + return `${provider}/${model}`; +} + +export function parseModelRef( + raw: string, + defaultProvider: string, +): ModelRef | null { + const trimmed = raw.trim(); + if (!trimmed) return null; + const slash = trimmed.indexOf("/"); + if (slash === -1) { + return { provider: defaultProvider, model: trimmed }; + } + const provider = trimmed.slice(0, slash).trim(); + const model = trimmed.slice(slash + 1).trim(); + if (!provider || !model) return null; + return { provider, model }; +} + +export function buildAllowedModelSet(params: { + cfg: ClawdisConfig; + catalog: ModelCatalogEntry[]; + defaultProvider: string; +}): { + allowAny: boolean; + allowedCatalog: ModelCatalogEntry[]; + allowedKeys: Set; +} { + const rawAllowlist = params.cfg.agent?.allowedModels ?? []; + const allowAny = rawAllowlist.length === 0; + const catalogKeys = new Set( + params.catalog.map((entry) => modelKey(entry.provider, entry.id)), + ); + + if (allowAny) { + return { + allowAny: true, + allowedCatalog: params.catalog, + allowedKeys: catalogKeys, + }; + } + + const allowedKeys = new Set(); + for (const raw of rawAllowlist) { + const parsed = parseModelRef(String(raw), params.defaultProvider); + if (!parsed) continue; + const key = modelKey(parsed.provider, parsed.model); + if (catalogKeys.has(key)) { + allowedKeys.add(key); + } + } + + const allowedCatalog = params.catalog.filter((entry) => + allowedKeys.has(modelKey(entry.provider, entry.id)), + ); + + if (allowedCatalog.length === 0) { + return { + allowAny: true, + allowedCatalog: params.catalog, + allowedKeys: catalogKeys, + }; + } + + return { allowAny: false, allowedCatalog, allowedKeys }; +} diff --git a/src/auto-reply/model.ts b/src/auto-reply/model.ts new file mode 100644 index 000000000..834e3daa8 --- /dev/null +++ b/src/auto-reply/model.ts @@ -0,0 +1,19 @@ +export function extractModelDirective(body?: string): { + cleaned: string; + rawModel?: string; + hasDirective: boolean; +} { + if (!body) return { cleaned: "", hasDirective: false }; + const match = body.match( + /(?:^|\s)\/model(?=$|\s|:)\s*:?\s*([A-Za-z0-9_.:-]+(?:\/[A-Za-z0-9_.:-]+)?)?/i, + ); + const rawModel = match?.[1]?.trim(); + const cleaned = match + ? body.replace(match[0], "").replace(/\s+/g, " ").trim() + : body.trim(); + return { + cleaned, + rawModel, + hasDirective: !!match, + }; +} diff --git a/src/auto-reply/reply.directive.test.ts b/src/auto-reply/reply.directive.test.ts index ede14b6df..d826d8f7d 100644 --- a/src/auto-reply/reply.directive.test.ts +++ b/src/auto-reply/reply.directive.test.ts @@ -8,8 +8,12 @@ vi.mock("../agents/pi-embedded.js", () => ({ runEmbeddedPiAgent: vi.fn(), queueEmbeddedPiMessage: vi.fn().mockReturnValue(false), })); +vi.mock("../agents/model-catalog.js", () => ({ + loadModelCatalog: vi.fn(), +})); import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; +import { loadModelCatalog } from "../agents/model-catalog.js"; import { loadSessionStore, resolveSessionKey, @@ -36,6 +40,11 @@ async function withTempHome(fn: (home: string) => Promise): Promise { describe("directive parsing", () => { beforeEach(() => { vi.mocked(runEmbeddedPiAgent).mockReset(); + vi.mocked(loadModelCatalog).mockResolvedValue([ + { id: "claude-opus-4-5", name: "Opus 4.5", provider: "anthropic" }, + { id: "claude-sonnet-4-1", name: "Sonnet 4.1", provider: "anthropic" }, + { id: "gpt-4.1-mini", name: "GPT-4.1 Mini", provider: "openai" }, + ]); }); afterEach(() => { @@ -92,10 +101,13 @@ describe("directive parsing", () => { }, {}, { + agent: { + provider: "anthropic", + model: "claude-opus-4-5", + workspace: path.join(home, "clawd"), + }, inbound: { allowFrom: ["*"], - workspace: path.join(home, "clawd"), - agent: { provider: "anthropic", model: "claude-opus-4-5" }, session: { store: path.join(home, "sessions.json") }, }, }, @@ -117,9 +129,12 @@ describe("directive parsing", () => { { Body: "/verbose on", From: "+1222", To: "+1222" }, {}, { - inbound: { + agent: { + provider: "anthropic", + model: "claude-opus-4-5", workspace: path.join(home, "clawd"), - agent: { provider: "anthropic", model: "claude-opus-4-5" }, + }, + inbound: { session: { store: path.join(home, "sessions.json") }, }, }, @@ -169,10 +184,13 @@ describe("directive parsing", () => { ctx, {}, { + agent: { + provider: "anthropic", + model: "claude-opus-4-5", + workspace: path.join(home, "clawd"), + }, inbound: { allowFrom: ["*"], - workspace: path.join(home, "clawd"), - agent: { provider: "anthropic", model: "claude-opus-4-5" }, session: { store: storePath }, }, }, @@ -228,10 +246,13 @@ describe("directive parsing", () => { ctx, {}, { + agent: { + provider: "anthropic", + model: "claude-opus-4-5", + workspace: path.join(home, "clawd"), + }, inbound: { allowFrom: ["*"], - workspace: path.join(home, "clawd"), - agent: { provider: "anthropic", model: "claude-opus-4-5" }, session: { store: storePath }, }, }, @@ -244,4 +265,110 @@ describe("directive parsing", () => { expect(runEmbeddedPiAgent).toHaveBeenCalledOnce(); }); }); + + it("lists allowlisted models on /model", async () => { + await withTempHome(async (home) => { + vi.mocked(runEmbeddedPiAgent).mockReset(); + const storePath = path.join(home, "sessions.json"); + + const res = await getReplyFromConfig( + { Body: "/model", From: "+1222", To: "+1222" }, + {}, + { + agent: { + provider: "anthropic", + model: "claude-opus-4-5", + workspace: path.join(home, "clawd"), + allowedModels: [ + "anthropic/claude-opus-4-5", + "openai/gpt-4.1-mini", + ], + }, + inbound: { + session: { store: storePath }, + }, + }, + ); + + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text).toContain("anthropic/claude-opus-4-5"); + expect(text).toContain("openai/gpt-4.1-mini"); + expect(text).not.toContain("claude-sonnet-4-1"); + expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + }); + }); + + it("sets model override on /model directive", async () => { + await withTempHome(async (home) => { + vi.mocked(runEmbeddedPiAgent).mockReset(); + const storePath = path.join(home, "sessions.json"); + + const res = await getReplyFromConfig( + { Body: "/model openai/gpt-4.1-mini", From: "+1222", To: "+1222" }, + {}, + { + agent: { + provider: "anthropic", + model: "claude-opus-4-5", + workspace: path.join(home, "clawd"), + allowedModels: ["openai/gpt-4.1-mini"], + }, + inbound: { + session: { store: storePath }, + }, + }, + ); + + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text).toContain("Model set to openai/gpt-4.1-mini"); + const store = loadSessionStore(storePath); + const entry = store["main"]; + expect(entry.modelOverride).toBe("gpt-4.1-mini"); + expect(entry.providerOverride).toBe("openai"); + expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + }); + }); + + it("uses model override for inline /model", async () => { + await withTempHome(async (home) => { + const storePath = path.join(home, "sessions.json"); + vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ + payloads: [{ text: "done" }], + meta: { + durationMs: 5, + agentMeta: { sessionId: "s", provider: "p", model: "m" }, + }, + }); + + const res = await getReplyFromConfig( + { + Body: "please sync /model openai/gpt-4.1-mini now", + From: "+1004", + To: "+2000", + }, + {}, + { + agent: { + provider: "anthropic", + model: "claude-opus-4-5", + workspace: path.join(home, "clawd"), + allowedModels: ["openai/gpt-4.1-mini"], + }, + inbound: { + allowFrom: ["*"], + session: { store: storePath }, + }, + }, + ); + + const texts = (Array.isArray(res) ? res : [res]) + .map((entry) => entry?.text) + .filter(Boolean); + expect(texts).toContain("done"); + expect(runEmbeddedPiAgent).toHaveBeenCalledOnce(); + const call = vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]; + expect(call?.provider).toBe("openai"); + expect(call?.model).toBe("gpt-4.1-mini"); + }); + }); }); diff --git a/src/auto-reply/reply.triggers.test.ts b/src/auto-reply/reply.triggers.test.ts index f805591ce..667491256 100644 --- a/src/auto-reply/reply.triggers.test.ts +++ b/src/auto-reply/reply.triggers.test.ts @@ -34,10 +34,13 @@ async function withTempHome(fn: (home: string) => Promise): Promise { function makeCfg(home: string) { return { + agent: { + provider: "anthropic", + model: "claude-opus-4-5", + workspace: join(home, "clawd"), + }, inbound: { allowFrom: ["*"], - workspace: join(home, "clawd"), - agent: { provider: "anthropic", model: "claude-opus-4-5" }, session: { store: join(home, "sessions.json") }, }, }; @@ -164,10 +167,13 @@ describe("trigger handling", () => { }, {}, { + agent: { + provider: "anthropic", + model: "claude-opus-4-5", + workspace: join(home, "clawd"), + }, inbound: { allowFrom: ["*"], - workspace: join(home, "clawd"), - agent: { provider: "anthropic", model: "claude-opus-4-5" }, session: { store: join(home, "sessions.json") }, groupChat: { requireMention: false }, }, @@ -203,10 +209,13 @@ describe("trigger handling", () => { }, {}, { + agent: { + provider: "anthropic", + model: "claude-opus-4-5", + workspace: join(home, "clawd"), + }, inbound: { allowFrom: ["*"], - workspace: join(home, "clawd"), - agent: { provider: "anthropic", model: "claude-opus-4-5" }, session: { store: join(tmpdir(), `clawdis-session-test-${Date.now()}.json`), }, @@ -240,10 +249,13 @@ describe("trigger handling", () => { }, {}, { + agent: { + provider: "anthropic", + model: "claude-opus-4-5", + workspace: join(home, "clawd"), + }, inbound: { allowFrom: ["*"], - workspace: join(home, "clawd"), - agent: { provider: "anthropic", model: "claude-opus-4-5" }, session: { store: join(tmpdir(), `clawdis-session-test-${Date.now()}.json`), }, diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts index e299ab391..f7a2e3b27 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -6,6 +6,12 @@ import { DEFAULT_MODEL, DEFAULT_PROVIDER, } from "../agents/defaults.js"; +import { loadModelCatalog } from "../agents/model-catalog.js"; +import { + buildAllowedModelSet, + modelKey, + parseModelRef, +} from "../agents/model-selection.js"; import { queueEmbeddedPiMessage, runEmbeddedPiAgent, @@ -46,6 +52,7 @@ import { type ThinkLevel, type VerboseLevel, } from "./thinking.js"; +import { extractModelDirective } from "./model.js"; import { SILENT_REPLY_TOKEN } from "./tokens.js"; import { isAudio, transcribeInboundAudio } from "./transcription.js"; import type { GetReplyOptions, ReplyPayload } from "./types.js"; @@ -57,7 +64,7 @@ const ABORT_MEMORY = new Map(); const SYSTEM_MARK = "⚙️"; const BARE_SESSION_RESET_PROMPT = - "A new session was started via /new or /reset. Say hi briefly and ask what the user wants to do next."; + "A new session was started via /new or /reset. Say hi briefly (1-2 sentences) and ask what the user wants to do next. Do not mention internal steps, files, tools, or reasoning."; export function extractThinkDirective(body?: string): { cleaned: string; @@ -157,13 +164,15 @@ export async function getReplyFromConfig( configOverride?: ClawdisConfig, ): Promise { const cfg = configOverride ?? loadConfig(); - const workspaceDirRaw = cfg.inbound?.workspace ?? DEFAULT_AGENT_WORKSPACE_DIR; - const agentCfg = cfg.inbound?.agent; + const workspaceDirRaw = cfg.agent?.workspace ?? DEFAULT_AGENT_WORKSPACE_DIR; + const agentCfg = cfg.agent; const sessionCfg = cfg.inbound?.session; - const provider = agentCfg?.provider?.trim() || DEFAULT_PROVIDER; - const model = agentCfg?.model?.trim() || DEFAULT_MODEL; - const contextTokens = + const defaultProvider = agentCfg?.provider?.trim() || DEFAULT_PROVIDER; + const defaultModel = agentCfg?.model?.trim() || DEFAULT_MODEL; + let provider = defaultProvider; + let model = defaultModel; + let contextTokens = agentCfg?.contextTokens ?? lookupContextTokens(model) ?? DEFAULT_CONTEXT_TOKENS; @@ -251,6 +260,8 @@ export async function getReplyFromConfig( let persistedThinking: string | undefined; let persistedVerbose: string | undefined; + let persistedModelOverride: string | undefined; + let persistedProviderOverride: string | undefined; const isGroup = typeof ctx.From === "string" && @@ -297,6 +308,8 @@ export async function getReplyFromConfig( abortedLastRun = entry.abortedLastRun ?? false; persistedThinking = entry.thinkingLevel; persistedVerbose = entry.verboseLevel; + persistedModelOverride = entry.modelOverride; + persistedProviderOverride = entry.providerOverride; } else { sessionId = crypto.randomUUID(); isNewSession = true; @@ -314,6 +327,8 @@ export async function getReplyFromConfig( // Persist previously stored thinking/verbose levels when present. thinkingLevel: persistedThinking ?? baseEntry?.thinkingLevel, verboseLevel: persistedVerbose ?? baseEntry?.verboseLevel, + modelOverride: persistedModelOverride ?? baseEntry?.modelOverride, + providerOverride: persistedProviderOverride ?? baseEntry?.providerOverride, }; sessionStore[sessionKey] = sessionEntry; await saveSessionStore(storePath, sessionStore); @@ -337,8 +352,13 @@ export async function getReplyFromConfig( rawLevel: rawVerboseLevel, hasDirective: hasVerboseDirective, } = extractVerboseDirective(thinkCleaned); - sessionCtx.Body = verboseCleaned; - sessionCtx.BodyStripped = verboseCleaned; + const { + cleaned: modelCleaned, + rawModel: rawModelDirective, + hasDirective: hasModelDirective, + } = extractModelDirective(verboseCleaned); + sessionCtx.Body = modelCleaned; + sessionCtx.BodyStripped = modelCleaned; const defaultGroupActivation = () => { const requireMention = cfg.inbound?.groupChat?.requireMention; @@ -369,117 +389,178 @@ export async function getReplyFromConfig( return resolvedVerboseLevel === "on"; }; - const combinedDirectiveOnly = - hasThinkDirective && - hasVerboseDirective && - (() => { - const stripped = stripStructuralPrefixes(verboseCleaned ?? ""); - const noMentions = isGroup ? stripMentions(stripped, ctx, cfg) : stripped; - return noMentions.length === 0; - })(); + const hasAllowlist = (agentCfg?.allowedModels?.length ?? 0) > 0; + const hasStoredOverride = Boolean( + sessionEntry?.modelOverride || sessionEntry?.providerOverride, + ); + const needsModelCatalog = hasModelDirective || hasAllowlist || hasStoredOverride; + let allowedModelKeys = new Set(); + let allowedModelCatalog: Awaited> = []; + let resetModelOverride = false; + + if (needsModelCatalog) { + const catalog = await loadModelCatalog({ config: cfg }); + const allowed = buildAllowedModelSet({ + cfg, + catalog, + defaultProvider, + }); + allowedModelCatalog = allowed.allowedCatalog; + allowedModelKeys = allowed.allowedKeys; + } + + if (sessionEntry && sessionStore && sessionKey && hasStoredOverride) { + const overrideProvider = + sessionEntry.providerOverride?.trim() || defaultProvider; + const overrideModel = sessionEntry.modelOverride?.trim(); + if (overrideModel) { + const key = modelKey(overrideProvider, overrideModel); + if (allowedModelKeys.size > 0 && !allowedModelKeys.has(key)) { + delete sessionEntry.providerOverride; + delete sessionEntry.modelOverride; + sessionEntry.updatedAt = Date.now(); + sessionStore[sessionKey] = sessionEntry; + await saveSessionStore(storePath, sessionStore); + resetModelOverride = true; + } + } + } + + const storedProviderOverride = sessionEntry?.providerOverride?.trim(); + const storedModelOverride = sessionEntry?.modelOverride?.trim(); + if (storedModelOverride) { + const candidateProvider = storedProviderOverride || defaultProvider; + const key = modelKey(candidateProvider, storedModelOverride); + if (allowedModelKeys.size === 0 || allowedModelKeys.has(key)) { + provider = candidateProvider; + model = storedModelOverride; + } + } + contextTokens = + agentCfg?.contextTokens ?? + lookupContextTokens(model) ?? + DEFAULT_CONTEXT_TOKENS; const directiveOnly = (() => { - if (!hasThinkDirective) return false; - if (!thinkCleaned) return true; - // Check after stripping both think and verbose so combined directives count. - const stripped = stripStructuralPrefixes(verboseCleaned); + if (!hasThinkDirective && !hasVerboseDirective && !hasModelDirective) + return false; + const stripped = stripStructuralPrefixes(modelCleaned ?? ""); const noMentions = isGroup ? stripMentions(stripped, ctx, cfg) : stripped; return noMentions.length === 0; })(); - // Directive-only message => persist session thinking level and return ack - if (directiveOnly || combinedDirectiveOnly) { - if (!inlineThink) { + if (directiveOnly) { + if (hasModelDirective && !rawModelDirective) { + if (allowedModelCatalog.length === 0) { + cleanupTyping(); + return { text: "No models available." }; + } + const current = `${provider}/${model}`; + const defaultLabel = `${defaultProvider}/${defaultModel}`; + const header = + current === defaultLabel + ? `Models (current: ${current}):` + : `Models (current: ${current}, default: ${defaultLabel}):`; + const lines = [header]; + if (resetModelOverride) { + lines.push(`(previous selection reset to default)`); + } + for (const entry of allowedModelCatalog) { + const label = `${entry.provider}/${entry.id}`; + const suffix = entry.name && entry.name !== entry.id ? ` — ${entry.name}` : ""; + lines.push(`- ${label}${suffix}`); + } + cleanupTyping(); + return { text: lines.join("\n") }; + } + if (hasThinkDirective && !inlineThink) { cleanupTyping(); return { text: `Unrecognized thinking level "${rawThinkLevel ?? ""}". Valid levels: off, minimal, low, medium, high.`, }; } - if (sessionEntry && sessionStore && sessionKey) { - if (inlineThink === "off") { - delete sessionEntry.thinkingLevel; - } else { - sessionEntry.thinkingLevel = inlineThink; - } - sessionEntry.updatedAt = Date.now(); - sessionStore[sessionKey] = sessionEntry; - await saveSessionStore(storePath, sessionStore); - } - // If verbose directive is also present, persist it too. - if ( - hasVerboseDirective && - inlineVerbose && - sessionEntry && - sessionStore && - sessionKey - ) { - if (inlineVerbose === "off") { - delete sessionEntry.verboseLevel; - } else { - sessionEntry.verboseLevel = inlineVerbose; - } - sessionEntry.updatedAt = Date.now(); - sessionStore[sessionKey] = sessionEntry; - await saveSessionStore(storePath, sessionStore); - } - const parts: string[] = []; - if (inlineThink === "off") { - parts.push("Thinking disabled."); - } else { - parts.push(`Thinking level set to ${inlineThink}.`); - } - if (hasVerboseDirective) { - if (!inlineVerbose) { - parts.push( - `Unrecognized verbose level "${rawVerboseLevel ?? ""}". Valid levels: off, on.`, - ); - } else { - parts.push( - inlineVerbose === "off" - ? "Verbose logging disabled." - : "Verbose logging enabled.", - ); - } - } - const ack = parts.join(" "); - cleanupTyping(); - return { text: ack }; - } - - const verboseDirectiveOnly = (() => { - if (!hasVerboseDirective) return false; - if (!verboseCleaned) return true; - const stripped = stripStructuralPrefixes(verboseCleaned); - const noMentions = isGroup ? stripMentions(stripped, ctx, cfg) : stripped; - return noMentions.length === 0; - })(); - - if (verboseDirectiveOnly) { - if (!inlineVerbose) { + if (hasVerboseDirective && !inlineVerbose) { cleanupTyping(); return { text: `Unrecognized verbose level "${rawVerboseLevel ?? ""}". Valid levels: off, on.`, }; } + + let modelSelection: + | { provider: string; model: string; isDefault: boolean } + | undefined; + if (hasModelDirective && rawModelDirective) { + const parsed = parseModelRef(rawModelDirective, defaultProvider); + if (!parsed) { + cleanupTyping(); + return { + text: `Unrecognized model "${rawModelDirective}". Use /model to list available models.`, + }; + } + const key = modelKey(parsed.provider, parsed.model); + if (allowedModelKeys.size > 0 && !allowedModelKeys.has(key)) { + cleanupTyping(); + return { + text: `Model "${parsed.provider}/${parsed.model}" is not allowed. Use /model to list available models.`, + }; + } + const isDefault = + parsed.provider === defaultProvider && parsed.model === defaultModel; + modelSelection = { ...parsed, isDefault }; + } + if (sessionEntry && sessionStore && sessionKey) { - if (inlineVerbose === "off") { - delete sessionEntry.verboseLevel; - } else { - sessionEntry.verboseLevel = inlineVerbose; + if (hasThinkDirective && inlineThink) { + if (inlineThink === "off") delete sessionEntry.thinkingLevel; + else sessionEntry.thinkingLevel = inlineThink; + } + if (hasVerboseDirective && inlineVerbose) { + if (inlineVerbose === "off") delete sessionEntry.verboseLevel; + else sessionEntry.verboseLevel = inlineVerbose; + } + if (modelSelection) { + if (modelSelection.isDefault) { + delete sessionEntry.providerOverride; + delete sessionEntry.modelOverride; + } else { + sessionEntry.providerOverride = modelSelection.provider; + sessionEntry.modelOverride = modelSelection.model; + } } sessionEntry.updatedAt = Date.now(); sessionStore[sessionKey] = sessionEntry; await saveSessionStore(storePath, sessionStore); } - const ack = - inlineVerbose === "off" - ? `${SYSTEM_MARK} Verbose logging disabled.` - : `${SYSTEM_MARK} Verbose logging enabled.`; + + const parts: string[] = []; + if (hasThinkDirective && inlineThink) { + parts.push( + inlineThink === "off" + ? "Thinking disabled." + : `Thinking level set to ${inlineThink}.`, + ); + } + if (hasVerboseDirective && inlineVerbose) { + parts.push( + inlineVerbose === "off" + ? `${SYSTEM_MARK} Verbose logging disabled.` + : `${SYSTEM_MARK} Verbose logging enabled.`, + ); + } + if (modelSelection) { + const label = `${modelSelection.provider}/${modelSelection.model}`; + parts.push( + modelSelection.isDefault + ? `Model reset to default (${label}).` + : `Model set to ${label}.`, + ); + } + const ack = parts.join(" ").trim(); cleanupTyping(); - return { text: ack }; + return { text: ack || "OK." }; } - // Persist inline think/verbose settings even when additional content follows. + // Persist inline think/verbose/model settings even when additional content follows. if (sessionEntry && sessionStore && sessionKey) { let updated = false; if (hasThinkDirective && inlineThink) { @@ -498,6 +579,30 @@ export async function getReplyFromConfig( } updated = true; } + if (hasModelDirective && rawModelDirective) { + const parsed = parseModelRef(rawModelDirective, defaultProvider); + if (parsed) { + const key = modelKey(parsed.provider, parsed.model); + if (allowedModelKeys.size === 0 || allowedModelKeys.has(key)) { + const isDefault = + parsed.provider === defaultProvider && parsed.model === defaultModel; + if (isDefault) { + delete sessionEntry.providerOverride; + delete sessionEntry.modelOverride; + } else { + sessionEntry.providerOverride = parsed.provider; + sessionEntry.modelOverride = parsed.model; + } + provider = parsed.provider; + model = parsed.model; + contextTokens = + agentCfg?.contextTokens ?? + lookupContextTokens(model) ?? + DEFAULT_CONTEXT_TOKENS; + updated = true; + } + } + } if (updated) { sessionEntry.updatedAt = Date.now(); sessionStore[sessionKey] = sessionEntry; @@ -889,6 +994,8 @@ export async function getReplyFromConfig( prompt: commandBody, extraSystemPrompt: groupIntro || undefined, ownerNumbers: ownerList.length > 0 ? ownerList : undefined, + enforceFinalTag: + provider === "lmstudio" || provider === "ollama" ? true : undefined, provider, model, thinkLevel: resolvedThinkLevel, diff --git a/src/auto-reply/status.ts b/src/auto-reply/status.ts index 774cf0894..989e8acfa 100644 --- a/src/auto-reply/status.ts +++ b/src/auto-reply/status.ts @@ -190,7 +190,7 @@ export function buildStatusMessage(args: StatusArgs): string { contextTokens ?? null, )}${entry?.abortedLastRun ? " • last run aborted" : ""}`; - const optionsLine = `Options: thinking=${thinkLevel} | verbose=${verboseLevel} (set with /think , /verbose on|off)`; + const optionsLine = `Options: thinking=${thinkLevel} | verbose=${verboseLevel} (set with /think , /verbose on|off, /model )`; const modelLabel = args.agent?.provider?.trim() ? `${args.agent.provider}/${args.agent?.model ?? model}` diff --git a/src/cli/program.ts b/src/cli/program.ts index 80b01bba6..ee246ac85 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -102,7 +102,7 @@ export function buildProgram() { .description("Initialize ~/.clawdis/clawdis.json and the agent workspace") .option( "--workspace ", - "Agent workspace directory (default: ~/clawd; stored as inbound.workspace)", + "Agent workspace directory (default: ~/clawd; stored as agent.workspace)", ) .action(async (opts) => { try { @@ -338,7 +338,7 @@ Examples: clawdis sessions --json # machine-readable output clawdis sessions --store ./tmp/sessions.json -Shows token usage per session when the agent reports it; set inbound.agent.contextTokens to see % of your model window.`, +Shows token usage per session when the agent reports it; set agent.contextTokens to see % of your model window.`, ) .action(async (opts) => { setVerbose(Boolean(opts.verbose)); diff --git a/src/commands/agent.test.ts b/src/commands/agent.test.ts index 75ac4fa82..a430dd5bc 100644 --- a/src/commands/agent.test.ts +++ b/src/commands/agent.test.ts @@ -49,9 +49,12 @@ function mockConfig( inboundOverrides?: Partial>, ) { configSpy.mockReturnValue({ - inbound: { + agent: { + provider: "anthropic", + model: "claude-opus-4-5", workspace: path.join(home, "clawd"), - agent: { provider: "anthropic", model: "claude-opus-4-5" }, + }, + inbound: { session: { store: storePath, mainKey: "main" }, ...inboundOverrides, }, diff --git a/src/commands/agent.ts b/src/commands/agent.ts index b973fd707..cff92978a 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -5,6 +5,8 @@ import { DEFAULT_MODEL, DEFAULT_PROVIDER, } from "../agents/defaults.js"; +import { loadModelCatalog } from "../agents/model-catalog.js"; +import { buildAllowedModelSet, modelKey } from "../agents/model-selection.js"; import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; import { buildWorkspaceSkillSnapshot } from "../agents/skills.js"; import { @@ -140,8 +142,8 @@ export async function agentCommand( } const cfg = loadConfig(); - const agentCfg = cfg.inbound?.agent; - const workspaceDirRaw = cfg.inbound?.workspace ?? DEFAULT_AGENT_WORKSPACE_DIR; + const agentCfg = cfg.agent; + const workspaceDirRaw = cfg.agent?.workspace ?? DEFAULT_AGENT_WORKSPACE_DIR; const workspace = await ensureAgentWorkspace({ dir: workspaceDirRaw, ensureBootstrapFiles: true, @@ -245,8 +247,53 @@ export async function agentCommand( await saveSessionStore(storePath, sessionStore); } - const provider = agentCfg?.provider?.trim() || DEFAULT_PROVIDER; - const model = agentCfg?.model?.trim() || DEFAULT_MODEL; + const defaultProvider = agentCfg?.provider?.trim() || DEFAULT_PROVIDER; + const defaultModel = agentCfg?.model?.trim() || DEFAULT_MODEL; + let provider = defaultProvider; + let model = defaultModel; + const hasAllowlist = (agentCfg?.allowedModels?.length ?? 0) > 0; + const hasStoredOverride = Boolean( + sessionEntry?.modelOverride || sessionEntry?.providerOverride, + ); + const needsModelCatalog = hasAllowlist || hasStoredOverride; + let allowedModelKeys = new Set(); + + if (needsModelCatalog) { + const catalog = await loadModelCatalog({ config: cfg }); + const allowed = buildAllowedModelSet({ + cfg, + catalog, + defaultProvider, + }); + allowedModelKeys = allowed.allowedKeys; + } + + if (sessionEntry && sessionStore && sessionKey && hasStoredOverride) { + const overrideProvider = + sessionEntry.providerOverride?.trim() || defaultProvider; + const overrideModel = sessionEntry.modelOverride?.trim(); + if (overrideModel) { + const key = modelKey(overrideProvider, overrideModel); + if (allowedModelKeys.size > 0 && !allowedModelKeys.has(key)) { + delete sessionEntry.providerOverride; + delete sessionEntry.modelOverride; + sessionEntry.updatedAt = Date.now(); + sessionStore[sessionKey] = sessionEntry; + await saveSessionStore(storePath, sessionStore); + } + } + } + + const storedProviderOverride = sessionEntry?.providerOverride?.trim(); + const storedModelOverride = sessionEntry?.modelOverride?.trim(); + if (storedModelOverride) { + const candidateProvider = storedProviderOverride || defaultProvider; + const key = modelKey(candidateProvider, storedModelOverride); + if (allowedModelKeys.size === 0 || allowedModelKeys.has(key)) { + provider = candidateProvider; + model = storedModelOverride; + } + } const sessionFile = resolveSessionTranscriptPath(sessionId); const startedAt = Date.now(); diff --git a/src/commands/sessions.test.ts b/src/commands/sessions.test.ts index 4f6826ec9..3d1fbd273 100644 --- a/src/commands/sessions.test.ts +++ b/src/commands/sessions.test.ts @@ -9,9 +9,7 @@ process.env.FORCE_COLOR = "0"; vi.mock("../config/config.js", () => ({ loadConfig: () => ({ - inbound: { - agent: { model: "pi:opus", contextTokens: 32000 }, - }, + agent: { model: "pi:opus", contextTokens: 32000 }, }), })); diff --git a/src/commands/sessions.ts b/src/commands/sessions.ts index d44aadd16..da0bd18d0 100644 --- a/src/commands/sessions.ts +++ b/src/commands/sessions.ts @@ -152,10 +152,10 @@ export async function sessionsCommand( ) { const cfg = loadConfig(); const configContextTokens = - cfg.inbound?.agent?.contextTokens ?? - lookupContextTokens(cfg.inbound?.agent?.model) ?? + cfg.agent?.contextTokens ?? + lookupContextTokens(cfg.agent?.model) ?? DEFAULT_CONTEXT_TOKENS; - const configModel = cfg.inbound?.agent?.model ?? DEFAULT_MODEL; + const configModel = cfg.agent?.model ?? DEFAULT_MODEL; const storePath = resolveStorePath(opts.store ?? cfg.inbound?.session?.store); const store = loadSessionStore(storePath); diff --git a/src/commands/setup.ts b/src/commands/setup.ts index a820c450b..16c3324c9 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -46,24 +46,25 @@ export async function setupCommand( const existingRaw = await readConfigFileRaw(); const cfg = existingRaw.parsed; const inbound = cfg.inbound ?? {}; + const agent = cfg.agent ?? {}; const workspace = - desiredWorkspace ?? inbound.workspace ?? DEFAULT_AGENT_WORKSPACE_DIR; + desiredWorkspace ?? agent.workspace ?? DEFAULT_AGENT_WORKSPACE_DIR; const next: ClawdisConfig = { ...cfg, - inbound: { - ...inbound, + agent: { + ...agent, workspace, }, }; - if (!existingRaw.exists || inbound.workspace !== workspace) { + if (!existingRaw.exists || agent.workspace !== workspace) { await writeConfigFile(next); runtime.log( !existingRaw.exists ? `Wrote ${CONFIG_PATH_CLAWDIS}` - : `Updated ${CONFIG_PATH_CLAWDIS} (set inbound.workspace)`, + : `Updated ${CONFIG_PATH_CLAWDIS} (set agent.workspace)`, ); } else { runtime.log(`Config OK: ${CONFIG_PATH_CLAWDIS}`); diff --git a/src/commands/status.ts b/src/commands/status.ts index 54de3d097..ea046657a 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -60,9 +60,9 @@ export async function getStatusSummary(): Promise { const providerSummary = await buildProviderSummary(cfg); const queuedSystemEvents = peekSystemEvents(); - const configModel = cfg.inbound?.agent?.model ?? DEFAULT_MODEL; + const configModel = cfg.agent?.model ?? DEFAULT_MODEL; const configContextTokens = - cfg.inbound?.agent?.contextTokens ?? + cfg.agent?.contextTokens ?? lookupContextTokens(configModel) ?? DEFAULT_CONTEXT_TOKENS; diff --git a/src/config/config.test.ts b/src/config/config.test.ts index 2901790b1..268aea890 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -88,7 +88,7 @@ describe("config identity defaults", () => { }); }); - it("does not synthesize inbound.agent/session when absent", async () => { + it("does not synthesize agent/session when absent", async () => { await withTempHome(async (home) => { const configDir = path.join(home, ".clawdis"); await fs.mkdir(configDir, { recursive: true }); @@ -113,7 +113,7 @@ describe("config identity defaults", () => { expect(cfg.inbound?.groupChat?.mentionPatterns).toEqual([ "\\b@?Samantha\\b", ]); - expect(cfg.inbound?.agent).toBeUndefined(); + expect(cfg.agent).toBeUndefined(); expect(cfg.inbound?.session).toBeUndefined(); }); }); diff --git a/src/config/config.ts b/src/config/config.ts index 3ca63477a..617c4788d 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -227,10 +227,30 @@ export type ClawdisConfig = { skillsLoad?: SkillsLoadConfig; skillsInstall?: SkillsInstallConfig; models?: ModelsConfig; - inbound?: { - allowFrom?: string[]; // E.164 numbers allowed to trigger auto-reply (without whatsapp:) + 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; /** Agent working directory (preferred). Used as the default cwd for agent runs. */ workspace?: string; + /** Optional allowlist for /model (provider/model or model-only). */ + allowedModels?: 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; + }; + inbound?: { + allowFrom?: string[]; // E.164 numbers allowed to trigger auto-reply (without whatsapp:) 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) @@ -240,24 +260,6 @@ export type ClawdisConfig = { 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; @@ -377,10 +379,32 @@ const ClawdisSchema = z.object({ }) .optional(), models: ModelsConfigSchema, + agent: z + .object({ + provider: z.string().optional(), + model: z.string().optional(), + workspace: z.string().optional(), + allowedModels: z.array(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(), 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(), @@ -397,29 +421,6 @@ const ClawdisSchema = z.object({ 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 diff --git a/src/config/sessions.ts b/src/config/sessions.ts index c17602668..4616014b4 100644 --- a/src/config/sessions.ts +++ b/src/config/sessions.ts @@ -17,6 +17,8 @@ export type SessionEntry = { abortedLastRun?: boolean; thinkingLevel?: string; verboseLevel?: string; + providerOverride?: string; + modelOverride?: string; groupActivation?: "mention" | "always"; groupActivationNeedsSystemIntro?: boolean; inputTokens?: number; @@ -128,6 +130,8 @@ export async function updateLastRoute(params: { abortedLastRun: existing?.abortedLastRun, thinkingLevel: existing?.thinkingLevel, verboseLevel: existing?.verboseLevel, + providerOverride: existing?.providerOverride, + modelOverride: existing?.modelOverride, inputTokens: existing?.inputTokens, outputTokens: existing?.outputTokens, totalTokens: existing?.totalTokens, diff --git a/src/cron/isolated-agent.test.ts b/src/cron/isolated-agent.test.ts index 11a8624c7..ba3600f51 100644 --- a/src/cron/isolated-agent.test.ts +++ b/src/cron/isolated-agent.test.ts @@ -52,9 +52,12 @@ async function writeSessionStore(home: string) { function makeCfg(home: string, storePath: string): ClawdisConfig { return { - inbound: { + agent: { + provider: "anthropic", + model: "claude-opus-4-5", workspace: path.join(home, "clawd"), - agent: { provider: "anthropic", model: "claude-opus-4-5" }, + }, + inbound: { session: { store: storePath, mainKey: "main" }, }, } as ClawdisConfig; diff --git a/src/cron/isolated-agent.ts b/src/cron/isolated-agent.ts index ec7f90236..48c4d89a5 100644 --- a/src/cron/isolated-agent.ts +++ b/src/cron/isolated-agent.ts @@ -145,10 +145,10 @@ export async function runCronIsolatedAgentTurn(params: { sessionKey: string; lane?: string; }): Promise { - const agentCfg = params.cfg.inbound?.agent; + const agentCfg = params.cfg.agent; void params.lane; const workspaceDirRaw = - params.cfg.inbound?.workspace ?? DEFAULT_AGENT_WORKSPACE_DIR; + params.cfg.agent?.workspace ?? DEFAULT_AGENT_WORKSPACE_DIR; const workspace = await ensureAgentWorkspace({ dir: workspaceDirRaw, ensureBootstrapFiles: true, diff --git a/src/gateway/server.test.ts b/src/gateway/server.test.ts index ab01d92c7..ebe3c82a3 100644 --- a/src/gateway/server.test.ts +++ b/src/gateway/server.test.ts @@ -182,10 +182,13 @@ vi.mock("../config/config.js", () => { return { CONFIG_PATH_CLAWDIS: resolveConfigPath(), loadConfig: () => ({ + agent: { + provider: "anthropic", + model: "claude-opus-4-5", + workspace: path.join(os.tmpdir(), "clawd-gateway-test"), + }, inbound: { allowFrom: testAllowFrom, - workspace: path.join(os.tmpdir(), "clawd-gateway-test"), - agent: { provider: "anthropic", model: "claude-opus-4-5" }, session: { mainKey: "main", store: testSessionStorePath }, }, gateway: (() => { diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 05e62696d..cbd2a71c3 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -9,10 +9,17 @@ import os from "node:os"; import path from "node:path"; import chalk from "chalk"; import { type WebSocket, WebSocketServer } from "ws"; -import { resolveClawdisAgentDir } from "../agents/agent-paths.js"; import { lookupContextTokens } from "../agents/context.js"; -import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL } from "../agents/defaults.js"; -import { ensureClawdisModelsJson } from "../agents/models-config.js"; +import { + DEFAULT_CONTEXT_TOKENS, + DEFAULT_MODEL, + DEFAULT_PROVIDER, +} from "../agents/defaults.js"; +import { + loadModelCatalog, + resetModelCatalogCacheForTest, + type ModelCatalogEntry, +} from "../agents/model-catalog.js"; import { installSkill } from "../agents/skills-install.js"; import { buildWorkspaceSkillStatus } from "../agents/skills-status.js"; import { DEFAULT_AGENT_WORKSPACE_DIR } from "../agents/workspace.js"; @@ -200,71 +207,17 @@ async function startBrowserControlServerIfEnabled(): Promise { await mod.startBrowserControlServerFromConfig(); } -type GatewayModelChoice = { - id: string; - name: string; - provider: string; - contextWindow?: number; -}; - -let modelCatalogPromise: Promise | null = null; +type GatewayModelChoice = ModelCatalogEntry; // Test-only escape hatch: model catalog is cached at module scope for the // process lifetime, which is fine for the real gateway daemon, but makes // isolated unit tests harder. Keep this intentionally obscure. export function __resetModelCatalogCacheForTest() { - modelCatalogPromise = null; + resetModelCatalogCacheForTest(); } async function loadGatewayModelCatalog(): Promise { - if (modelCatalogPromise) return modelCatalogPromise; - - modelCatalogPromise = (async () => { - const piSdk = (await import("@mariozechner/pi-coding-agent")) as { - discoverModels: (agentDir?: string) => Array<{ - id: string; - name?: string; - provider: string; - contextWindow?: number; - }>; - }; - - let entries: Array<{ - id: string; - name?: string; - provider: string; - contextWindow?: number; - }> = []; - try { - const cfg = loadConfig(); - await ensureClawdisModelsJson(cfg); - entries = piSdk.discoverModels(resolveClawdisAgentDir()); - } catch { - entries = []; - } - - const models: GatewayModelChoice[] = []; - for (const entry of entries) { - const id = String(entry?.id ?? "").trim(); - if (!id) continue; - const provider = String(entry?.provider ?? "").trim(); - if (!provider) continue; - const name = String(entry?.name ?? id).trim() || id; - const contextWindow = - typeof entry?.contextWindow === "number" && entry.contextWindow > 0 - ? entry.contextWindow - : undefined; - models.push({ id, name, provider, contextWindow }); - } - - return models.sort((a, b) => { - const p = a.provider.localeCompare(b.provider); - if (p !== 0) return p; - return a.name.localeCompare(b.name); - }); - })(); - - return modelCatalogPromise; + return await loadModelCatalog({ config: loadConfig() }); } import { @@ -796,9 +749,9 @@ function classifySessionKey(key: string): GatewaySessionRow["kind"] { } function getSessionDefaults(cfg: ClawdisConfig): GatewaySessionsDefaults { - const model = cfg.inbound?.agent?.model ?? DEFAULT_MODEL; + const model = cfg.agent?.model ?? DEFAULT_MODEL; const contextTokens = - cfg.inbound?.agent?.contextTokens ?? + cfg.agent?.contextTokens ?? lookupContextTokens(model) ?? DEFAULT_CONTEXT_TOKENS; return { model: model ?? null, contextTokens: contextTokens ?? null }; @@ -2277,7 +2230,7 @@ export async function startGatewayServer( ).items; const thinkingLevel = entry?.thinkingLevel ?? - loadConfig().inbound?.agent?.thinkingDefault ?? + loadConfig().agent?.thinkingDefault ?? "off"; return { ok: true, @@ -3486,7 +3439,7 @@ export async function startGatewayServer( ).items; const thinkingLevel = entry?.thinkingLevel ?? - loadConfig().inbound?.agent?.thinkingDefault ?? + loadConfig().agent?.thinkingDefault ?? "off"; respond(true, { sessionKey, @@ -4119,7 +4072,7 @@ export async function startGatewayServer( } const cfg = loadConfig(); const workspaceDirRaw = - cfg.inbound?.workspace ?? DEFAULT_AGENT_WORKSPACE_DIR; + cfg.agent?.workspace ?? DEFAULT_AGENT_WORKSPACE_DIR; const workspaceDir = resolveUserPath(workspaceDirRaw); const report = buildWorkspaceSkillStatus(workspaceDir, { config: cfg, @@ -4147,7 +4100,7 @@ export async function startGatewayServer( }; const cfg = loadConfig(); const workspaceDirRaw = - cfg.inbound?.workspace ?? DEFAULT_AGENT_WORKSPACE_DIR; + cfg.agent?.workspace ?? DEFAULT_AGENT_WORKSPACE_DIR; const result = await installSkill({ workspaceDir: workspaceDirRaw, skillName: p.name, @@ -5495,6 +5448,9 @@ export async function startGatewayServer( }); }); + const agentProvider = cfgAtStart.agent?.provider?.trim() || DEFAULT_PROVIDER; + const agentModel = cfgAtStart.agent?.model?.trim() || DEFAULT_MODEL; + log.info(`agent model: ${agentProvider}/${agentModel}`); log.info(`listening on ws://${bindHost}:${port} (PID ${process.pid})`); log.info(`log file: ${getResolvedLoggerSettings().file}`); let tailscaleCleanup: (() => Promise) | null = null; diff --git a/src/web/auto-reply.test.ts b/src/web/auto-reply.test.ts index a14feed21..1717dc656 100644 --- a/src/web/auto-reply.test.ts +++ b/src/web/auto-reply.test.ts @@ -164,12 +164,12 @@ describe("heartbeat helpers", () => { expect(resolveReplyHeartbeatMinutes(cfgBase)).toBe(30); expect( resolveReplyHeartbeatMinutes({ - inbound: { agent: { heartbeatMinutes: 5 } }, + agent: { heartbeatMinutes: 5 }, }), ).toBe(5); expect( resolveReplyHeartbeatMinutes({ - inbound: { agent: { heartbeatMinutes: 0 } }, + agent: { heartbeatMinutes: 0 }, }), ).toBeNull(); expect(resolveReplyHeartbeatMinutes(cfgBase, 7)).toBe(7); @@ -508,9 +508,9 @@ describe("runWebHeartbeatOnce", () => { ); setLoadConfigMock(() => ({ + agent: { heartbeatMinutes: 0.001 }, inbound: { allowFrom: ["+4367"], - agent: { heartbeatMinutes: 0.001 }, session: { store: storePath, idleMinutes: 60 }, }, })); @@ -1198,7 +1198,7 @@ describe("web auto-reply", () => { for (const fmt of formats) { // Force a small cap to ensure compression is exercised for every format. - setLoadConfigMock(() => ({ inbound: { agent: { mediaMaxMb: 1 } } })); + setLoadConfigMock(() => ({ agent: { mediaMaxMb: 1 } })); const sendMedia = vi.fn(); const reply = vi.fn().mockResolvedValue(undefined); const sendComposing = vi.fn(); @@ -1263,7 +1263,7 @@ describe("web auto-reply", () => { ); it("honors mediaMaxMb from config", async () => { - setLoadConfigMock(() => ({ inbound: { agent: { mediaMaxMb: 1 } } })); + setLoadConfigMock(() => ({ agent: { mediaMaxMb: 1 } })); const sendMedia = vi.fn(); const reply = vi.fn().mockResolvedValue(undefined); const sendComposing = vi.fn(); diff --git a/src/web/auto-reply.ts b/src/web/auto-reply.ts index 824ef5934..e4049104f 100644 --- a/src/web/auto-reply.ts +++ b/src/web/auto-reply.ts @@ -192,7 +192,7 @@ export function resolveReplyHeartbeatMinutes( cfg: ReturnType, overrideMinutes?: number, ) { - const raw = overrideMinutes ?? cfg.inbound?.agent?.heartbeatMinutes; + const raw = overrideMinutes ?? cfg.agent?.heartbeatMinutes; if (raw === 0) return null; if (typeof raw === "number" && raw > 0) return raw; return DEFAULT_REPLY_HEARTBEAT_MINUTES; @@ -758,7 +758,7 @@ export async function monitorWebProvider( }; emitStatus(); const cfg = loadConfig(); - const configuredMaxMb = cfg.inbound?.agent?.mediaMaxMb; + const configuredMaxMb = cfg.agent?.mediaMaxMb; const maxMediaBytes = typeof configuredMaxMb === "number" && configuredMaxMb > 0 ? configuredMaxMb * 1024 * 1024