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 ## 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`. - Injected prompt files: `AGENTS.md`, `SOUL.md`, `TOOLS.md`.
- Skills: `~/clawd/skills/<skill>/SKILL.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 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 ```json5
{ {
inbound: { agent: {
workspace: "~/clawd" workspace: "~/clawd"
} }
} }

View File

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

View File

@@ -10,13 +10,13 @@ CLAWDIS runs a single embedded agent runtime derived from **p-mono** (internal n
## Workspace (required) ## 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. Recommended: use `clawdis setup` to create `~/.clawdis/clawdis.json` if missing and initialize the workspace files.
## Bootstrap files (injected) ## 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” - `AGENTS.md` — operating instructions + “memory”
- `SOUL.md` — persona, boundaries, tone - `SOUL.md` — persona, boundaries, tone
- `TOOLS.md` — user-maintained tool notes (e.g. `imsg`, `sag`, conventions) - `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) ## Configuration (minimal)
At minimum, set: At minimum, set:
- `inbound.workspace` - `agent.workspace`
- `inbound.allowFrom` (strongly recommended) - `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 clawdis setup
``` ```
Optional: choose a different workspace with `inbound.workspace` (supports `~`). Optional: choose a different workspace with `agent.workspace` (supports `~`).
```json5 ```json5
{ {
@@ -149,7 +149,7 @@ Example:
## Heartbeats (proactive mode) ## 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. - 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: 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`) - restrict who can trigger the bot (`inbound.allowFrom`)
- tune group mention behavior (`inbound.groupChat`) - tune group mention behavior (`inbound.groupChat`)
- set the agents workspace (`inbound.workspace`) - set the agents workspace (`agent.workspace`)
- tune the embedded agent (`inbound.agent`) and session behavior (`inbound.session`) - tune the embedded agent (`agent`) and session behavior (`inbound.session`)
- set the agents identity (`identity`) - set the agents identity (`identity`)
## Minimal config (recommended starting point) ## Minimal config (recommended starting point)
```json5 ```json5
{ {
inbound: { agent: { workspace: "~/clawd" },
allowFrom: ["+15555550123"], inbound: { allowFrom: ["+15555550123"] }
workspace: "~/clawd"
}
} }
``` ```
@@ -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. Sets the **single global workspace directory** used by the agent for file operations.
@@ -94,29 +92,33 @@ Default: `~/clawd`.
```json5 ```json5
{ {
inbound: { workspace: "~/clawd" } agent: { workspace: "~/clawd" }
} }
``` ```
### `inbound.agent` ### `agent`
Controls the embedded agent runtime (provider/model/thinking/verbose/timeouts). 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 ```json5
{ {
inbound: { agent: {
workspace: "~/clawd", provider: "anthropic",
agent: { model: "claude-opus-4-5",
provider: "anthropic", allowedModels: [
model: "claude-opus-4-5", "anthropic/claude-opus-4-5",
thinkingDefault: "low", "anthropic/claude-sonnet-4-1"
verboseDefault: "off", ],
timeoutSeconds: 600, thinkingDefault: "low",
mediaMaxMb: 5, verboseDefault: "off",
heartbeatMinutes: 30, timeoutSeconds: 600,
contextTokens: 200000 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) - default behavior: **merge** (keeps existing providers, overrides on name)
- set `models.mode: "replace"` to overwrite the file contents - 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 ```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. - Keep existing WhatsApp length guidance; forbid burying the sentinel inside alerts.
## Config & defaults ## 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. - 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. - 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 ## Web Provider Behavior
- Input: local file path **or** HTTP(S) URL. - Input: local file path **or** HTTP(S) URL.
- Flow: load into a Buffer, detect media kind, and build the correct payload: - 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`). - **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. - **Documents:** anything else, up to 100MB, with filename preserved when available.
- MIME detection prefers magic bytes, then headers, then file extension. - MIME detection prefers magic bytes, then headers, then file extension.

View File

@@ -8,7 +8,7 @@ read_when:
# Workspace Memory v2 (offline): proposal + research # 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. 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? ### Why integrate into Clawdis?
- Clawdis already knows: - Clawdis already knows:
- the workspace path (`inbound.workspace`) - the workspace path (`agent.workspace`)
- the session model + heartbeats - the session model + heartbeats
- logging + troubleshooting patterns - logging + troubleshooting patterns
- You want the agent itself to call the tools: - 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. - 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. - 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. - SuCo: arXiv 2411.14754 (2024): “Subspace Collision” approximate nearest neighbor retrieval.

View File

@@ -17,7 +17,7 @@ read_when:
## Resolution order ## Resolution order
1. Inline directive on the message (applies only to that message). 1. Inline directive on the message (applies only to that message).
2. Session override (set by sending a directive-only 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. 4. Fallback: off.
## Setting a session default ## Setting a session default

View File

@@ -80,13 +80,13 @@ Status: WhatsApp Web via Baileys only. Gateway owns the single session.
## Media limits + optimization ## Media limits + optimization
- Default cap: 5 MB (per media item). - 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). - Images are auto-optimized to JPEG under cap (resize + quality sweep).
- Oversize media => error; media reply falls back to text warning. - Oversize media => error; media reply falls back to text warning.
## Heartbeats ## Heartbeats
- **Gateway heartbeat** logs connection health (`web.heartbeatSeconds`, default 60s). - **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. - Uses `HEARTBEAT` prompt + `HEARTBEAT_TOKEN` skip behavior.
- Skips if queue busy or last inbound was a group. - Skips if queue busy or last inbound was a group.
- Falls back to last direct recipient if needed. - 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.groupChat.historyLimit`
- `inbound.messagePrefix` (inbound prefix) - `inbound.messagePrefix` (inbound prefix)
- `inbound.responsePrefix` (outbound prefix) - `inbound.responsePrefix` (outbound prefix)
- `inbound.agent.mediaMaxMb` - `agent.mediaMaxMb`
- `inbound.agent.heartbeatMinutes` - `agent.heartbeatMinutes`
- `inbound.session.*` (scope, idle, store, mainKey) - `inbound.session.*` (scope, idle, store, mainKey)
- `web.heartbeatSeconds` - `web.heartbeatSeconds`
- `web.reconnect.*` - `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/auto-reply.test.ts` (mention gating, history injection, reply flow)
- `src/web/monitor-inbox.test.ts` (inbound parsing + reply context) - `src/web/monitor-inbox.test.ts` (inbound parsing + reply context)
- `src/web/outbound.test.ts` (send mapping + media) - `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(), runEmbeddedPiAgent: vi.fn(),
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false), queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
})); }));
vi.mock("../agents/model-catalog.js", () => ({
loadModelCatalog: vi.fn(),
}));
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
import { loadModelCatalog } from "../agents/model-catalog.js";
import { import {
loadSessionStore, loadSessionStore,
resolveSessionKey, resolveSessionKey,
@@ -36,6 +40,11 @@ async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
describe("directive parsing", () => { describe("directive parsing", () => {
beforeEach(() => { beforeEach(() => {
vi.mocked(runEmbeddedPiAgent).mockReset(); 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(() => { afterEach(() => {
@@ -92,10 +101,13 @@ describe("directive parsing", () => {
}, },
{}, {},
{ {
agent: {
provider: "anthropic",
model: "claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
inbound: { inbound: {
allowFrom: ["*"], allowFrom: ["*"],
workspace: path.join(home, "clawd"),
agent: { provider: "anthropic", model: "claude-opus-4-5" },
session: { store: path.join(home, "sessions.json") }, session: { store: path.join(home, "sessions.json") },
}, },
}, },
@@ -117,9 +129,12 @@ describe("directive parsing", () => {
{ Body: "/verbose on", From: "+1222", To: "+1222" }, { Body: "/verbose on", From: "+1222", To: "+1222" },
{}, {},
{ {
inbound: { agent: {
provider: "anthropic",
model: "claude-opus-4-5",
workspace: path.join(home, "clawd"), workspace: path.join(home, "clawd"),
agent: { provider: "anthropic", model: "claude-opus-4-5" }, },
inbound: {
session: { store: path.join(home, "sessions.json") }, session: { store: path.join(home, "sessions.json") },
}, },
}, },
@@ -169,10 +184,13 @@ describe("directive parsing", () => {
ctx, ctx,
{}, {},
{ {
agent: {
provider: "anthropic",
model: "claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
inbound: { inbound: {
allowFrom: ["*"], allowFrom: ["*"],
workspace: path.join(home, "clawd"),
agent: { provider: "anthropic", model: "claude-opus-4-5" },
session: { store: storePath }, session: { store: storePath },
}, },
}, },
@@ -228,10 +246,13 @@ describe("directive parsing", () => {
ctx, ctx,
{}, {},
{ {
agent: {
provider: "anthropic",
model: "claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
inbound: { inbound: {
allowFrom: ["*"], allowFrom: ["*"],
workspace: path.join(home, "clawd"),
agent: { provider: "anthropic", model: "claude-opus-4-5" },
session: { store: storePath }, session: { store: storePath },
}, },
}, },
@@ -244,4 +265,110 @@ describe("directive parsing", () => {
expect(runEmbeddedPiAgent).toHaveBeenCalledOnce(); 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) { function makeCfg(home: string) {
return { return {
agent: {
provider: "anthropic",
model: "claude-opus-4-5",
workspace: join(home, "clawd"),
},
inbound: { inbound: {
allowFrom: ["*"], allowFrom: ["*"],
workspace: join(home, "clawd"),
agent: { provider: "anthropic", model: "claude-opus-4-5" },
session: { store: join(home, "sessions.json") }, 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: { inbound: {
allowFrom: ["*"], allowFrom: ["*"],
workspace: join(home, "clawd"),
agent: { provider: "anthropic", model: "claude-opus-4-5" },
session: { store: join(home, "sessions.json") }, session: { store: join(home, "sessions.json") },
groupChat: { requireMention: false }, groupChat: { requireMention: false },
}, },
@@ -203,10 +209,13 @@ describe("trigger handling", () => {
}, },
{}, {},
{ {
agent: {
provider: "anthropic",
model: "claude-opus-4-5",
workspace: join(home, "clawd"),
},
inbound: { inbound: {
allowFrom: ["*"], allowFrom: ["*"],
workspace: join(home, "clawd"),
agent: { provider: "anthropic", model: "claude-opus-4-5" },
session: { session: {
store: join(tmpdir(), `clawdis-session-test-${Date.now()}.json`), 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: { inbound: {
allowFrom: ["*"], allowFrom: ["*"],
workspace: join(home, "clawd"),
agent: { provider: "anthropic", model: "claude-opus-4-5" },
session: { session: {
store: join(tmpdir(), `clawdis-session-test-${Date.now()}.json`), store: join(tmpdir(), `clawdis-session-test-${Date.now()}.json`),
}, },

View File

@@ -6,6 +6,12 @@ import {
DEFAULT_MODEL, DEFAULT_MODEL,
DEFAULT_PROVIDER, DEFAULT_PROVIDER,
} from "../agents/defaults.js"; } from "../agents/defaults.js";
import { loadModelCatalog } from "../agents/model-catalog.js";
import {
buildAllowedModelSet,
modelKey,
parseModelRef,
} from "../agents/model-selection.js";
import { import {
queueEmbeddedPiMessage, queueEmbeddedPiMessage,
runEmbeddedPiAgent, runEmbeddedPiAgent,
@@ -46,6 +52,7 @@ import {
type ThinkLevel, type ThinkLevel,
type VerboseLevel, type VerboseLevel,
} from "./thinking.js"; } from "./thinking.js";
import { extractModelDirective } from "./model.js";
import { SILENT_REPLY_TOKEN } from "./tokens.js"; import { SILENT_REPLY_TOKEN } from "./tokens.js";
import { isAudio, transcribeInboundAudio } from "./transcription.js"; import { isAudio, transcribeInboundAudio } from "./transcription.js";
import type { GetReplyOptions, ReplyPayload } from "./types.js"; import type { GetReplyOptions, ReplyPayload } from "./types.js";
@@ -57,7 +64,7 @@ const ABORT_MEMORY = new Map<string, boolean>();
const SYSTEM_MARK = "⚙️"; const SYSTEM_MARK = "⚙️";
const BARE_SESSION_RESET_PROMPT = 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): { export function extractThinkDirective(body?: string): {
cleaned: string; cleaned: string;
@@ -157,13 +164,15 @@ export async function getReplyFromConfig(
configOverride?: ClawdisConfig, configOverride?: ClawdisConfig,
): Promise<ReplyPayload | ReplyPayload[] | undefined> { ): Promise<ReplyPayload | ReplyPayload[] | undefined> {
const cfg = configOverride ?? loadConfig(); const cfg = configOverride ?? loadConfig();
const workspaceDirRaw = cfg.inbound?.workspace ?? DEFAULT_AGENT_WORKSPACE_DIR; const workspaceDirRaw = cfg.agent?.workspace ?? DEFAULT_AGENT_WORKSPACE_DIR;
const agentCfg = cfg.inbound?.agent; const agentCfg = cfg.agent;
const sessionCfg = cfg.inbound?.session; const sessionCfg = cfg.inbound?.session;
const provider = agentCfg?.provider?.trim() || DEFAULT_PROVIDER; const defaultProvider = agentCfg?.provider?.trim() || DEFAULT_PROVIDER;
const model = agentCfg?.model?.trim() || DEFAULT_MODEL; const defaultModel = agentCfg?.model?.trim() || DEFAULT_MODEL;
const contextTokens = let provider = defaultProvider;
let model = defaultModel;
let contextTokens =
agentCfg?.contextTokens ?? agentCfg?.contextTokens ??
lookupContextTokens(model) ?? lookupContextTokens(model) ??
DEFAULT_CONTEXT_TOKENS; DEFAULT_CONTEXT_TOKENS;
@@ -251,6 +260,8 @@ export async function getReplyFromConfig(
let persistedThinking: string | undefined; let persistedThinking: string | undefined;
let persistedVerbose: string | undefined; let persistedVerbose: string | undefined;
let persistedModelOverride: string | undefined;
let persistedProviderOverride: string | undefined;
const isGroup = const isGroup =
typeof ctx.From === "string" && typeof ctx.From === "string" &&
@@ -297,6 +308,8 @@ export async function getReplyFromConfig(
abortedLastRun = entry.abortedLastRun ?? false; abortedLastRun = entry.abortedLastRun ?? false;
persistedThinking = entry.thinkingLevel; persistedThinking = entry.thinkingLevel;
persistedVerbose = entry.verboseLevel; persistedVerbose = entry.verboseLevel;
persistedModelOverride = entry.modelOverride;
persistedProviderOverride = entry.providerOverride;
} else { } else {
sessionId = crypto.randomUUID(); sessionId = crypto.randomUUID();
isNewSession = true; isNewSession = true;
@@ -314,6 +327,8 @@ export async function getReplyFromConfig(
// Persist previously stored thinking/verbose levels when present. // Persist previously stored thinking/verbose levels when present.
thinkingLevel: persistedThinking ?? baseEntry?.thinkingLevel, thinkingLevel: persistedThinking ?? baseEntry?.thinkingLevel,
verboseLevel: persistedVerbose ?? baseEntry?.verboseLevel, verboseLevel: persistedVerbose ?? baseEntry?.verboseLevel,
modelOverride: persistedModelOverride ?? baseEntry?.modelOverride,
providerOverride: persistedProviderOverride ?? baseEntry?.providerOverride,
}; };
sessionStore[sessionKey] = sessionEntry; sessionStore[sessionKey] = sessionEntry;
await saveSessionStore(storePath, sessionStore); await saveSessionStore(storePath, sessionStore);
@@ -337,8 +352,13 @@ export async function getReplyFromConfig(
rawLevel: rawVerboseLevel, rawLevel: rawVerboseLevel,
hasDirective: hasVerboseDirective, hasDirective: hasVerboseDirective,
} = extractVerboseDirective(thinkCleaned); } = extractVerboseDirective(thinkCleaned);
sessionCtx.Body = verboseCleaned; const {
sessionCtx.BodyStripped = verboseCleaned; cleaned: modelCleaned,
rawModel: rawModelDirective,
hasDirective: hasModelDirective,
} = extractModelDirective(verboseCleaned);
sessionCtx.Body = modelCleaned;
sessionCtx.BodyStripped = modelCleaned;
const defaultGroupActivation = () => { const defaultGroupActivation = () => {
const requireMention = cfg.inbound?.groupChat?.requireMention; const requireMention = cfg.inbound?.groupChat?.requireMention;
@@ -369,117 +389,178 @@ export async function getReplyFromConfig(
return resolvedVerboseLevel === "on"; return resolvedVerboseLevel === "on";
}; };
const combinedDirectiveOnly = const hasAllowlist = (agentCfg?.allowedModels?.length ?? 0) > 0;
hasThinkDirective && const hasStoredOverride = Boolean(
hasVerboseDirective && sessionEntry?.modelOverride || sessionEntry?.providerOverride,
(() => { );
const stripped = stripStructuralPrefixes(verboseCleaned ?? ""); const needsModelCatalog = hasModelDirective || hasAllowlist || hasStoredOverride;
const noMentions = isGroup ? stripMentions(stripped, ctx, cfg) : stripped; let allowedModelKeys = new Set<string>();
return noMentions.length === 0; 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 = (() => { const directiveOnly = (() => {
if (!hasThinkDirective) return false; if (!hasThinkDirective && !hasVerboseDirective && !hasModelDirective)
if (!thinkCleaned) return true; return false;
// Check after stripping both think and verbose so combined directives count. const stripped = stripStructuralPrefixes(modelCleaned ?? "");
const stripped = stripStructuralPrefixes(verboseCleaned);
const noMentions = isGroup ? stripMentions(stripped, ctx, cfg) : stripped; const noMentions = isGroup ? stripMentions(stripped, ctx, cfg) : stripped;
return noMentions.length === 0; return noMentions.length === 0;
})(); })();
// Directive-only message => persist session thinking level and return ack if (directiveOnly) {
if (directiveOnly || combinedDirectiveOnly) { if (hasModelDirective && !rawModelDirective) {
if (!inlineThink) { 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(); cleanupTyping();
return { return {
text: `Unrecognized thinking level "${rawThinkLevel ?? ""}". Valid levels: off, minimal, low, medium, high.`, text: `Unrecognized thinking level "${rawThinkLevel ?? ""}". Valid levels: off, minimal, low, medium, high.`,
}; };
} }
if (sessionEntry && sessionStore && sessionKey) { if (hasVerboseDirective && !inlineVerbose) {
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) {
cleanupTyping(); cleanupTyping();
return { return {
text: `Unrecognized verbose level "${rawVerboseLevel ?? ""}". Valid levels: off, on.`, 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 (sessionEntry && sessionStore && sessionKey) {
if (inlineVerbose === "off") { if (hasThinkDirective && inlineThink) {
delete sessionEntry.verboseLevel; if (inlineThink === "off") delete sessionEntry.thinkingLevel;
} else { else sessionEntry.thinkingLevel = inlineThink;
sessionEntry.verboseLevel = inlineVerbose; }
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(); sessionEntry.updatedAt = Date.now();
sessionStore[sessionKey] = sessionEntry; sessionStore[sessionKey] = sessionEntry;
await saveSessionStore(storePath, sessionStore); await saveSessionStore(storePath, sessionStore);
} }
const ack =
inlineVerbose === "off" const parts: string[] = [];
? `${SYSTEM_MARK} Verbose logging disabled.` if (hasThinkDirective && inlineThink) {
: `${SYSTEM_MARK} Verbose logging enabled.`; 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(); 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) { if (sessionEntry && sessionStore && sessionKey) {
let updated = false; let updated = false;
if (hasThinkDirective && inlineThink) { if (hasThinkDirective && inlineThink) {
@@ -498,6 +579,30 @@ export async function getReplyFromConfig(
} }
updated = true; 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) { if (updated) {
sessionEntry.updatedAt = Date.now(); sessionEntry.updatedAt = Date.now();
sessionStore[sessionKey] = sessionEntry; sessionStore[sessionKey] = sessionEntry;
@@ -889,6 +994,8 @@ export async function getReplyFromConfig(
prompt: commandBody, prompt: commandBody,
extraSystemPrompt: groupIntro || undefined, extraSystemPrompt: groupIntro || undefined,
ownerNumbers: ownerList.length > 0 ? ownerList : undefined, ownerNumbers: ownerList.length > 0 ? ownerList : undefined,
enforceFinalTag:
provider === "lmstudio" || provider === "ollama" ? true : undefined,
provider, provider,
model, model,
thinkLevel: resolvedThinkLevel, thinkLevel: resolvedThinkLevel,

View File

@@ -190,7 +190,7 @@ export function buildStatusMessage(args: StatusArgs): string {
contextTokens ?? null, contextTokens ?? null,
)}${entry?.abortedLastRun ? " • last run aborted" : ""}`; )}${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() const modelLabel = args.agent?.provider?.trim()
? `${args.agent.provider}/${args.agent?.model ?? model}` ? `${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") .description("Initialize ~/.clawdis/clawdis.json and the agent workspace")
.option( .option(
"--workspace <dir>", "--workspace <dir>",
"Agent workspace directory (default: ~/clawd; stored as inbound.workspace)", "Agent workspace directory (default: ~/clawd; stored as agent.workspace)",
) )
.action(async (opts) => { .action(async (opts) => {
try { try {
@@ -338,7 +338,7 @@ Examples:
clawdis sessions --json # machine-readable output clawdis sessions --json # machine-readable output
clawdis sessions --store ./tmp/sessions.json 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) => { .action(async (opts) => {
setVerbose(Boolean(opts.verbose)); setVerbose(Boolean(opts.verbose));

View File

@@ -49,9 +49,12 @@ function mockConfig(
inboundOverrides?: Partial<NonNullable<ClawdisConfig["inbound"]>>, inboundOverrides?: Partial<NonNullable<ClawdisConfig["inbound"]>>,
) { ) {
configSpy.mockReturnValue({ configSpy.mockReturnValue({
inbound: { agent: {
provider: "anthropic",
model: "claude-opus-4-5",
workspace: path.join(home, "clawd"), workspace: path.join(home, "clawd"),
agent: { provider: "anthropic", model: "claude-opus-4-5" }, },
inbound: {
session: { store: storePath, mainKey: "main" }, session: { store: storePath, mainKey: "main" },
...inboundOverrides, ...inboundOverrides,
}, },

View File

@@ -5,6 +5,8 @@ import {
DEFAULT_MODEL, DEFAULT_MODEL,
DEFAULT_PROVIDER, DEFAULT_PROVIDER,
} from "../agents/defaults.js"; } 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 { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
import { buildWorkspaceSkillSnapshot } from "../agents/skills.js"; import { buildWorkspaceSkillSnapshot } from "../agents/skills.js";
import { import {
@@ -140,8 +142,8 @@ export async function agentCommand(
} }
const cfg = loadConfig(); const cfg = loadConfig();
const agentCfg = cfg.inbound?.agent; const agentCfg = cfg.agent;
const workspaceDirRaw = cfg.inbound?.workspace ?? DEFAULT_AGENT_WORKSPACE_DIR; const workspaceDirRaw = cfg.agent?.workspace ?? DEFAULT_AGENT_WORKSPACE_DIR;
const workspace = await ensureAgentWorkspace({ const workspace = await ensureAgentWorkspace({
dir: workspaceDirRaw, dir: workspaceDirRaw,
ensureBootstrapFiles: true, ensureBootstrapFiles: true,
@@ -245,8 +247,53 @@ export async function agentCommand(
await saveSessionStore(storePath, sessionStore); await saveSessionStore(storePath, sessionStore);
} }
const provider = agentCfg?.provider?.trim() || DEFAULT_PROVIDER; const defaultProvider = agentCfg?.provider?.trim() || DEFAULT_PROVIDER;
const model = agentCfg?.model?.trim() || DEFAULT_MODEL; 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 sessionFile = resolveSessionTranscriptPath(sessionId);
const startedAt = Date.now(); const startedAt = Date.now();

View File

@@ -9,9 +9,7 @@ process.env.FORCE_COLOR = "0";
vi.mock("../config/config.js", () => ({ vi.mock("../config/config.js", () => ({
loadConfig: () => ({ 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 cfg = loadConfig();
const configContextTokens = const configContextTokens =
cfg.inbound?.agent?.contextTokens ?? cfg.agent?.contextTokens ??
lookupContextTokens(cfg.inbound?.agent?.model) ?? lookupContextTokens(cfg.agent?.model) ??
DEFAULT_CONTEXT_TOKENS; 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 storePath = resolveStorePath(opts.store ?? cfg.inbound?.session?.store);
const store = loadSessionStore(storePath); const store = loadSessionStore(storePath);

View File

@@ -46,24 +46,25 @@ export async function setupCommand(
const existingRaw = await readConfigFileRaw(); const existingRaw = await readConfigFileRaw();
const cfg = existingRaw.parsed; const cfg = existingRaw.parsed;
const inbound = cfg.inbound ?? {}; const inbound = cfg.inbound ?? {};
const agent = cfg.agent ?? {};
const workspace = const workspace =
desiredWorkspace ?? inbound.workspace ?? DEFAULT_AGENT_WORKSPACE_DIR; desiredWorkspace ?? agent.workspace ?? DEFAULT_AGENT_WORKSPACE_DIR;
const next: ClawdisConfig = { const next: ClawdisConfig = {
...cfg, ...cfg,
inbound: { agent: {
...inbound, ...agent,
workspace, workspace,
}, },
}; };
if (!existingRaw.exists || inbound.workspace !== workspace) { if (!existingRaw.exists || agent.workspace !== workspace) {
await writeConfigFile(next); await writeConfigFile(next);
runtime.log( runtime.log(
!existingRaw.exists !existingRaw.exists
? `Wrote ${CONFIG_PATH_CLAWDIS}` ? `Wrote ${CONFIG_PATH_CLAWDIS}`
: `Updated ${CONFIG_PATH_CLAWDIS} (set inbound.workspace)`, : `Updated ${CONFIG_PATH_CLAWDIS} (set agent.workspace)`,
); );
} else { } else {
runtime.log(`Config OK: ${CONFIG_PATH_CLAWDIS}`); 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 providerSummary = await buildProviderSummary(cfg);
const queuedSystemEvents = peekSystemEvents(); const queuedSystemEvents = peekSystemEvents();
const configModel = cfg.inbound?.agent?.model ?? DEFAULT_MODEL; const configModel = cfg.agent?.model ?? DEFAULT_MODEL;
const configContextTokens = const configContextTokens =
cfg.inbound?.agent?.contextTokens ?? cfg.agent?.contextTokens ??
lookupContextTokens(configModel) ?? lookupContextTokens(configModel) ??
DEFAULT_CONTEXT_TOKENS; 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) => { await withTempHome(async (home) => {
const configDir = path.join(home, ".clawdis"); const configDir = path.join(home, ".clawdis");
await fs.mkdir(configDir, { recursive: true }); await fs.mkdir(configDir, { recursive: true });
@@ -113,7 +113,7 @@ describe("config identity defaults", () => {
expect(cfg.inbound?.groupChat?.mentionPatterns).toEqual([ expect(cfg.inbound?.groupChat?.mentionPatterns).toEqual([
"\\b@?Samantha\\b", "\\b@?Samantha\\b",
]); ]);
expect(cfg.inbound?.agent).toBeUndefined(); expect(cfg.agent).toBeUndefined();
expect(cfg.inbound?.session).toBeUndefined(); expect(cfg.inbound?.session).toBeUndefined();
}); });
}); });

View File

@@ -227,10 +227,30 @@ export type ClawdisConfig = {
skillsLoad?: SkillsLoadConfig; skillsLoad?: SkillsLoadConfig;
skillsInstall?: SkillsInstallConfig; skillsInstall?: SkillsInstallConfig;
models?: ModelsConfig; models?: ModelsConfig;
inbound?: { agent?: {
allowFrom?: string[]; // E.164 numbers allowed to trigger auto-reply (without whatsapp:) /** 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. */ /** Agent working directory (preferred). Used as the default cwd for agent runs. */
workspace?: string; 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 "") 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., "🦞") responsePrefix?: string; // Prefix auto-added to all outbound replies (e.g., "🦞")
timestampPrefix?: boolean | string; // true/false or IANA timezone string (default: true with UTC) timestampPrefix?: boolean | string; // true/false or IANA timezone string (default: true with UTC)
@@ -240,24 +260,6 @@ export type ClawdisConfig = {
timeoutSeconds?: number; timeoutSeconds?: number;
}; };
groupChat?: GroupChatConfig; 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; session?: SessionConfig;
}; };
web?: WebConfig; web?: WebConfig;
@@ -377,10 +379,32 @@ const ClawdisSchema = z.object({
}) })
.optional(), .optional(),
models: ModelsConfigSchema, 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 inbound: z
.object({ .object({
allowFrom: z.array(z.string()).optional(), allowFrom: z.array(z.string()).optional(),
workspace: z.string().optional(),
messagePrefix: z.string().optional(), messagePrefix: z.string().optional(),
responsePrefix: z.string().optional(), responsePrefix: z.string().optional(),
timestampPrefix: z.union([z.boolean(), 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(), timeoutSeconds: z.number().int().positive().optional(),
}) })
.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 session: z
.object({ .object({
scope: z scope: z

View File

@@ -17,6 +17,8 @@ export type SessionEntry = {
abortedLastRun?: boolean; abortedLastRun?: boolean;
thinkingLevel?: string; thinkingLevel?: string;
verboseLevel?: string; verboseLevel?: string;
providerOverride?: string;
modelOverride?: string;
groupActivation?: "mention" | "always"; groupActivation?: "mention" | "always";
groupActivationNeedsSystemIntro?: boolean; groupActivationNeedsSystemIntro?: boolean;
inputTokens?: number; inputTokens?: number;
@@ -128,6 +130,8 @@ export async function updateLastRoute(params: {
abortedLastRun: existing?.abortedLastRun, abortedLastRun: existing?.abortedLastRun,
thinkingLevel: existing?.thinkingLevel, thinkingLevel: existing?.thinkingLevel,
verboseLevel: existing?.verboseLevel, verboseLevel: existing?.verboseLevel,
providerOverride: existing?.providerOverride,
modelOverride: existing?.modelOverride,
inputTokens: existing?.inputTokens, inputTokens: existing?.inputTokens,
outputTokens: existing?.outputTokens, outputTokens: existing?.outputTokens,
totalTokens: existing?.totalTokens, totalTokens: existing?.totalTokens,

View File

@@ -52,9 +52,12 @@ async function writeSessionStore(home: string) {
function makeCfg(home: string, storePath: string): ClawdisConfig { function makeCfg(home: string, storePath: string): ClawdisConfig {
return { return {
inbound: { agent: {
provider: "anthropic",
model: "claude-opus-4-5",
workspace: path.join(home, "clawd"), workspace: path.join(home, "clawd"),
agent: { provider: "anthropic", model: "claude-opus-4-5" }, },
inbound: {
session: { store: storePath, mainKey: "main" }, session: { store: storePath, mainKey: "main" },
}, },
} as ClawdisConfig; } as ClawdisConfig;

View File

@@ -145,10 +145,10 @@ export async function runCronIsolatedAgentTurn(params: {
sessionKey: string; sessionKey: string;
lane?: string; lane?: string;
}): Promise<RunCronAgentTurnResult> { }): Promise<RunCronAgentTurnResult> {
const agentCfg = params.cfg.inbound?.agent; const agentCfg = params.cfg.agent;
void params.lane; void params.lane;
const workspaceDirRaw = const workspaceDirRaw =
params.cfg.inbound?.workspace ?? DEFAULT_AGENT_WORKSPACE_DIR; params.cfg.agent?.workspace ?? DEFAULT_AGENT_WORKSPACE_DIR;
const workspace = await ensureAgentWorkspace({ const workspace = await ensureAgentWorkspace({
dir: workspaceDirRaw, dir: workspaceDirRaw,
ensureBootstrapFiles: true, ensureBootstrapFiles: true,

View File

@@ -182,10 +182,13 @@ vi.mock("../config/config.js", () => {
return { return {
CONFIG_PATH_CLAWDIS: resolveConfigPath(), CONFIG_PATH_CLAWDIS: resolveConfigPath(),
loadConfig: () => ({ loadConfig: () => ({
agent: {
provider: "anthropic",
model: "claude-opus-4-5",
workspace: path.join(os.tmpdir(), "clawd-gateway-test"),
},
inbound: { inbound: {
allowFrom: testAllowFrom, allowFrom: testAllowFrom,
workspace: path.join(os.tmpdir(), "clawd-gateway-test"),
agent: { provider: "anthropic", model: "claude-opus-4-5" },
session: { mainKey: "main", store: testSessionStorePath }, session: { mainKey: "main", store: testSessionStorePath },
}, },
gateway: (() => { gateway: (() => {

View File

@@ -9,10 +9,17 @@ import os from "node:os";
import path from "node:path"; import path from "node:path";
import chalk from "chalk"; import chalk from "chalk";
import { type WebSocket, WebSocketServer } from "ws"; import { type WebSocket, WebSocketServer } from "ws";
import { resolveClawdisAgentDir } from "../agents/agent-paths.js";
import { lookupContextTokens } from "../agents/context.js"; import { lookupContextTokens } from "../agents/context.js";
import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL } from "../agents/defaults.js"; import {
import { ensureClawdisModelsJson } from "../agents/models-config.js"; 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 { installSkill } from "../agents/skills-install.js";
import { buildWorkspaceSkillStatus } from "../agents/skills-status.js"; import { buildWorkspaceSkillStatus } from "../agents/skills-status.js";
import { DEFAULT_AGENT_WORKSPACE_DIR } from "../agents/workspace.js"; import { DEFAULT_AGENT_WORKSPACE_DIR } from "../agents/workspace.js";
@@ -200,71 +207,17 @@ async function startBrowserControlServerIfEnabled(): Promise<void> {
await mod.startBrowserControlServerFromConfig(); await mod.startBrowserControlServerFromConfig();
} }
type GatewayModelChoice = { type GatewayModelChoice = ModelCatalogEntry;
id: string;
name: string;
provider: string;
contextWindow?: number;
};
let modelCatalogPromise: Promise<GatewayModelChoice[]> | null = null;
// Test-only escape hatch: model catalog is cached at module scope for the // 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 // process lifetime, which is fine for the real gateway daemon, but makes
// isolated unit tests harder. Keep this intentionally obscure. // isolated unit tests harder. Keep this intentionally obscure.
export function __resetModelCatalogCacheForTest() { export function __resetModelCatalogCacheForTest() {
modelCatalogPromise = null; resetModelCatalogCacheForTest();
} }
async function loadGatewayModelCatalog(): Promise<GatewayModelChoice[]> { async function loadGatewayModelCatalog(): Promise<GatewayModelChoice[]> {
if (modelCatalogPromise) return modelCatalogPromise; return await loadModelCatalog({ config: loadConfig() });
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;
} }
import { import {
@@ -796,9 +749,9 @@ function classifySessionKey(key: string): GatewaySessionRow["kind"] {
} }
function getSessionDefaults(cfg: ClawdisConfig): GatewaySessionsDefaults { function getSessionDefaults(cfg: ClawdisConfig): GatewaySessionsDefaults {
const model = cfg.inbound?.agent?.model ?? DEFAULT_MODEL; const model = cfg.agent?.model ?? DEFAULT_MODEL;
const contextTokens = const contextTokens =
cfg.inbound?.agent?.contextTokens ?? cfg.agent?.contextTokens ??
lookupContextTokens(model) ?? lookupContextTokens(model) ??
DEFAULT_CONTEXT_TOKENS; DEFAULT_CONTEXT_TOKENS;
return { model: model ?? null, contextTokens: contextTokens ?? null }; return { model: model ?? null, contextTokens: contextTokens ?? null };
@@ -2277,7 +2230,7 @@ export async function startGatewayServer(
).items; ).items;
const thinkingLevel = const thinkingLevel =
entry?.thinkingLevel ?? entry?.thinkingLevel ??
loadConfig().inbound?.agent?.thinkingDefault ?? loadConfig().agent?.thinkingDefault ??
"off"; "off";
return { return {
ok: true, ok: true,
@@ -3486,7 +3439,7 @@ export async function startGatewayServer(
).items; ).items;
const thinkingLevel = const thinkingLevel =
entry?.thinkingLevel ?? entry?.thinkingLevel ??
loadConfig().inbound?.agent?.thinkingDefault ?? loadConfig().agent?.thinkingDefault ??
"off"; "off";
respond(true, { respond(true, {
sessionKey, sessionKey,
@@ -4119,7 +4072,7 @@ export async function startGatewayServer(
} }
const cfg = loadConfig(); const cfg = loadConfig();
const workspaceDirRaw = const workspaceDirRaw =
cfg.inbound?.workspace ?? DEFAULT_AGENT_WORKSPACE_DIR; cfg.agent?.workspace ?? DEFAULT_AGENT_WORKSPACE_DIR;
const workspaceDir = resolveUserPath(workspaceDirRaw); const workspaceDir = resolveUserPath(workspaceDirRaw);
const report = buildWorkspaceSkillStatus(workspaceDir, { const report = buildWorkspaceSkillStatus(workspaceDir, {
config: cfg, config: cfg,
@@ -4147,7 +4100,7 @@ export async function startGatewayServer(
}; };
const cfg = loadConfig(); const cfg = loadConfig();
const workspaceDirRaw = const workspaceDirRaw =
cfg.inbound?.workspace ?? DEFAULT_AGENT_WORKSPACE_DIR; cfg.agent?.workspace ?? DEFAULT_AGENT_WORKSPACE_DIR;
const result = await installSkill({ const result = await installSkill({
workspaceDir: workspaceDirRaw, workspaceDir: workspaceDirRaw,
skillName: p.name, 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(`listening on ws://${bindHost}:${port} (PID ${process.pid})`);
log.info(`log file: ${getResolvedLoggerSettings().file}`); log.info(`log file: ${getResolvedLoggerSettings().file}`);
let tailscaleCleanup: (() => Promise<void>) | null = null; let tailscaleCleanup: (() => Promise<void>) | null = null;

View File

@@ -164,12 +164,12 @@ describe("heartbeat helpers", () => {
expect(resolveReplyHeartbeatMinutes(cfgBase)).toBe(30); expect(resolveReplyHeartbeatMinutes(cfgBase)).toBe(30);
expect( expect(
resolveReplyHeartbeatMinutes({ resolveReplyHeartbeatMinutes({
inbound: { agent: { heartbeatMinutes: 5 } }, agent: { heartbeatMinutes: 5 },
}), }),
).toBe(5); ).toBe(5);
expect( expect(
resolveReplyHeartbeatMinutes({ resolveReplyHeartbeatMinutes({
inbound: { agent: { heartbeatMinutes: 0 } }, agent: { heartbeatMinutes: 0 },
}), }),
).toBeNull(); ).toBeNull();
expect(resolveReplyHeartbeatMinutes(cfgBase, 7)).toBe(7); expect(resolveReplyHeartbeatMinutes(cfgBase, 7)).toBe(7);
@@ -508,9 +508,9 @@ describe("runWebHeartbeatOnce", () => {
); );
setLoadConfigMock(() => ({ setLoadConfigMock(() => ({
agent: { heartbeatMinutes: 0.001 },
inbound: { inbound: {
allowFrom: ["+4367"], allowFrom: ["+4367"],
agent: { heartbeatMinutes: 0.001 },
session: { store: storePath, idleMinutes: 60 }, session: { store: storePath, idleMinutes: 60 },
}, },
})); }));
@@ -1198,7 +1198,7 @@ describe("web auto-reply", () => {
for (const fmt of formats) { for (const fmt of formats) {
// Force a small cap to ensure compression is exercised for every format. // 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 sendMedia = vi.fn();
const reply = vi.fn().mockResolvedValue(undefined); const reply = vi.fn().mockResolvedValue(undefined);
const sendComposing = vi.fn(); const sendComposing = vi.fn();
@@ -1263,7 +1263,7 @@ describe("web auto-reply", () => {
); );
it("honors mediaMaxMb from config", async () => { it("honors mediaMaxMb from config", async () => {
setLoadConfigMock(() => ({ inbound: { agent: { mediaMaxMb: 1 } } })); setLoadConfigMock(() => ({ agent: { mediaMaxMb: 1 } }));
const sendMedia = vi.fn(); const sendMedia = vi.fn();
const reply = vi.fn().mockResolvedValue(undefined); const reply = vi.fn().mockResolvedValue(undefined);
const sendComposing = vi.fn(); const sendComposing = vi.fn();

View File

@@ -192,7 +192,7 @@ export function resolveReplyHeartbeatMinutes(
cfg: ReturnType<typeof loadConfig>, cfg: ReturnType<typeof loadConfig>,
overrideMinutes?: number, overrideMinutes?: number,
) { ) {
const raw = overrideMinutes ?? cfg.inbound?.agent?.heartbeatMinutes; const raw = overrideMinutes ?? cfg.agent?.heartbeatMinutes;
if (raw === 0) return null; if (raw === 0) return null;
if (typeof raw === "number" && raw > 0) return raw; if (typeof raw === "number" && raw > 0) return raw;
return DEFAULT_REPLY_HEARTBEAT_MINUTES; return DEFAULT_REPLY_HEARTBEAT_MINUTES;
@@ -758,7 +758,7 @@ export async function monitorWebProvider(
}; };
emitStatus(); emitStatus();
const cfg = loadConfig(); const cfg = loadConfig();
const configuredMaxMb = cfg.inbound?.agent?.mediaMaxMb; const configuredMaxMb = cfg.agent?.mediaMaxMb;
const maxMediaBytes = const maxMediaBytes =
typeof configuredMaxMb === "number" && configuredMaxMb > 0 typeof configuredMaxMb === "number" && configuredMaxMb > 0
? configuredMaxMb * 1024 * 1024 ? configuredMaxMb * 1024 * 1024