feat: add per-session model selection

This commit is contained in:
Peter Steinberger
2025-12-23 23:45:20 +00:00
parent b6bfd8e34f
commit 364a6a9444
34 changed files with 729 additions and 300 deletions

View File

@@ -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>/SKILL.md`.

View File

@@ -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"
}
}

View File

@@ -12,7 +12,7 @@ read_when:
- Session selection:
- If `--session-id` is given, reuse it.
- Else if `--to <e164>` 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 <off|minimal|low|medium|high>` and `--verbose <on|off>` persist into the session store.
- Output:

View File

@@ -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 agents **only** working directory (`cwd`) for tools and context.
You must set an agent home directory via `agent.workspace`. CLAWDIS uses this as the agents **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)
---

View File

@@ -94,7 +94,7 @@ Tip: treat this folder like Clawds “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.

View File

@@ -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 agents workspace (`inbound.workspace`)
- tune the embedded agent (`inbound.agent`) and session behavior (`inbound.session`)
- set the agents workspace (`agent.workspace`)
- tune the embedded agent (`agent`) and session behavior (`inbound.session`)
- set the agents 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
{

View File

@@ -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.

View File

@@ -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 5MB), capped at 6MB.
- **Images:** resize & recompress to JPEG (max side 2048px) targeting `agent.mediaMaxMb` (default 5MB), capped at 6MB.
- **Audio/Voice/Video:** pass-through up to 16MB; audio is sent as a voice note (`ptt: true`).
- **Documents:** anything else, up to 100MB, with filename preserved when available.
- MIME detection prefers magic bytes, then headers, then file extension.

View File

@@ -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.

View File

@@ -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

View File

@@ -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)

View File

@@ -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<ModelCatalogEntry[]> | null = null;
export function resetModelCatalogCacheForTest() {
modelCatalogPromise = null;
}
export async function loadModelCatalog(params?: {
config?: ClawdisConfig;
useCache?: boolean;
}): Promise<ModelCatalogEntry[]> {
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;
}

View File

@@ -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<string>;
} {
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<string>();
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 };
}

19
src/auto-reply/model.ts Normal file
View File

@@ -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,
};
}

View File

@@ -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<T>(fn: (home: string) => Promise<T>): Promise<T> {
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");
});
});
});

View File

@@ -34,10 +34,13 @@ async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
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`),
},

View File

@@ -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<string, boolean>();
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<ReplyPayload | ReplyPayload[] | undefined> {
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<string>();
let allowedModelCatalog: Awaited<ReturnType<typeof loadModelCatalog>> = [];
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,

View File

@@ -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 <level>, /verbose on|off)`;
const optionsLine = `Options: thinking=${thinkLevel} | verbose=${verboseLevel} (set with /think <level>, /verbose on|off, /model <id>)`;
const modelLabel = args.agent?.provider?.trim()
? `${args.agent.provider}/${args.agent?.model ?? model}`

View File

@@ -102,7 +102,7 @@ export function buildProgram() {
.description("Initialize ~/.clawdis/clawdis.json and the agent workspace")
.option(
"--workspace <dir>",
"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));

View File

@@ -49,9 +49,12 @@ function mockConfig(
inboundOverrides?: Partial<NonNullable<ClawdisConfig["inbound"]>>,
) {
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,
},

View File

@@ -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<string>();
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();

View File

@@ -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 },
}),
}));

View File

@@ -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);

View File

@@ -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}`);

View File

@@ -60,9 +60,9 @@ export async function getStatusSummary(): Promise<StatusSummary> {
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;

View File

@@ -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();
});
});

View File

@@ -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

View File

@@ -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,

View File

@@ -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;

View File

@@ -145,10 +145,10 @@ export async function runCronIsolatedAgentTurn(params: {
sessionKey: string;
lane?: string;
}): Promise<RunCronAgentTurnResult> {
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,

View File

@@ -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: (() => {

View File

@@ -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<void> {
await mod.startBrowserControlServerFromConfig();
}
type GatewayModelChoice = {
id: string;
name: string;
provider: string;
contextWindow?: number;
};
let modelCatalogPromise: Promise<GatewayModelChoice[]> | 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<GatewayModelChoice[]> {
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<void>) | null = null;

View File

@@ -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();

View File

@@ -192,7 +192,7 @@ export function resolveReplyHeartbeatMinutes(
cfg: ReturnType<typeof loadConfig>,
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