feat: add per-session model selection
This commit is contained in:
@@ -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`.
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -10,13 +10,13 @@ CLAWDIS runs a single embedded agent runtime derived from **p-mono** (internal n
|
||||
|
||||
## Workspace (required)
|
||||
|
||||
You must set an agent home directory via `inbound.workspace`. CLAWDIS uses this as the agent’s **only** working directory (`cwd`) for tools and context.
|
||||
You must set an agent home directory via `agent.workspace`. CLAWDIS uses this as the agent’s **only** working directory (`cwd`) for tools and context.
|
||||
|
||||
Recommended: use `clawdis setup` to create `~/.clawdis/clawdis.json` if missing and initialize the workspace files.
|
||||
|
||||
## Bootstrap files (injected)
|
||||
|
||||
Inside `inbound.workspace`, CLAWDIS expects these user-editable files:
|
||||
Inside `agent.workspace`, CLAWDIS expects these user-editable files:
|
||||
- `AGENTS.md` — operating instructions + “memory”
|
||||
- `SOUL.md` — persona, boundaries, tone
|
||||
- `TOOLS.md` — user-maintained tool notes (e.g. `imsg`, `sag`, conventions)
|
||||
@@ -75,7 +75,7 @@ Incoming user messages are queued while the agent is streaming. The queue is che
|
||||
## Configuration (minimal)
|
||||
|
||||
At minimum, set:
|
||||
- `inbound.workspace`
|
||||
- `agent.workspace`
|
||||
- `inbound.allowFrom` (strongly recommended)
|
||||
|
||||
---
|
||||
|
||||
@@ -94,7 +94,7 @@ Tip: treat this folder like Clawd’s “memory” and make it a git repo (ideal
|
||||
clawdis setup
|
||||
```
|
||||
|
||||
Optional: choose a different workspace with `inbound.workspace` (supports `~`).
|
||||
Optional: choose a different workspace with `agent.workspace` (supports `~`).
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -149,7 +149,7 @@ Example:
|
||||
|
||||
## Heartbeats (proactive mode)
|
||||
|
||||
When `inbound.agent.heartbeatMinutes > 0`, CLAWDIS periodically runs a heartbeat prompt (default: `HEARTBEAT`).
|
||||
When `agent.heartbeatMinutes > 0`, CLAWDIS periodically runs a heartbeat prompt (default: `HEARTBEAT`).
|
||||
|
||||
- If the agent replies with `HEARTBEAT_OK` (exact token), CLAWDIS suppresses outbound delivery for that heartbeat.
|
||||
|
||||
|
||||
@@ -11,18 +11,16 @@ CLAWDIS reads an optional **JSON5** config from `~/.clawdis/clawdis.json` (comme
|
||||
If the file is missing, CLAWDIS uses safe-ish defaults (embedded Pi agent + per-sender sessions + workspace `~/clawd`). You usually only need a config to:
|
||||
- restrict who can trigger the bot (`inbound.allowFrom`)
|
||||
- tune group mention behavior (`inbound.groupChat`)
|
||||
- set the agent’s workspace (`inbound.workspace`)
|
||||
- tune the embedded agent (`inbound.agent`) and session behavior (`inbound.session`)
|
||||
- set the agent’s workspace (`agent.workspace`)
|
||||
- tune the embedded agent (`agent`) and session behavior (`inbound.session`)
|
||||
- set the agent’s identity (`identity`)
|
||||
|
||||
## Minimal config (recommended starting point)
|
||||
|
||||
```json5
|
||||
{
|
||||
inbound: {
|
||||
allowFrom: ["+15555550123"],
|
||||
workspace: "~/clawd"
|
||||
}
|
||||
agent: { workspace: "~/clawd" },
|
||||
inbound: { allowFrom: ["+15555550123"] }
|
||||
}
|
||||
```
|
||||
|
||||
@@ -86,7 +84,7 @@ Group messages default to **require mention** (either metadata mention or regex
|
||||
}
|
||||
```
|
||||
|
||||
### `inbound.workspace`
|
||||
### `agent.workspace`
|
||||
|
||||
Sets the **single global workspace directory** used by the agent for file operations.
|
||||
|
||||
@@ -94,29 +92,33 @@ Default: `~/clawd`.
|
||||
|
||||
```json5
|
||||
{
|
||||
inbound: { workspace: "~/clawd" }
|
||||
agent: { workspace: "~/clawd" }
|
||||
}
|
||||
```
|
||||
|
||||
### `inbound.agent`
|
||||
### `agent`
|
||||
|
||||
Controls the embedded agent runtime (provider/model/thinking/verbose/timeouts).
|
||||
`allowedModels` lets `/model` list/filter and enforce a per-session allowlist
|
||||
(omit to show the full catalog).
|
||||
|
||||
```json5
|
||||
{
|
||||
inbound: {
|
||||
workspace: "~/clawd",
|
||||
agent: {
|
||||
provider: "anthropic",
|
||||
model: "claude-opus-4-5",
|
||||
thinkingDefault: "low",
|
||||
verboseDefault: "off",
|
||||
timeoutSeconds: 600,
|
||||
mediaMaxMb: 5,
|
||||
heartbeatMinutes: 30,
|
||||
contextTokens: 200000
|
||||
}
|
||||
}
|
||||
agent: {
|
||||
provider: "anthropic",
|
||||
model: "claude-opus-4-5",
|
||||
allowedModels: [
|
||||
"anthropic/claude-opus-4-5",
|
||||
"anthropic/claude-sonnet-4-1"
|
||||
],
|
||||
thinkingDefault: "low",
|
||||
verboseDefault: "off",
|
||||
timeoutSeconds: 600,
|
||||
mediaMaxMb: 5,
|
||||
heartbeatMinutes: 30,
|
||||
contextTokens: 200000
|
||||
},
|
||||
inbound: { workspace: "~/clawd" }
|
||||
}
|
||||
```
|
||||
|
||||
@@ -132,7 +134,7 @@ When `models.providers` is present, Clawdis writes/merges a `models.json` into
|
||||
- default behavior: **merge** (keeps existing providers, overrides on name)
|
||||
- set `models.mode: "replace"` to overwrite the file contents
|
||||
|
||||
Select the model via `inbound.agent.provider` + `inbound.agent.model`.
|
||||
Select the model via `agent.provider` + `agent.model`.
|
||||
|
||||
```json5
|
||||
{
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ CLAWDIS is now **web-only** (Baileys). This document captures the current media
|
||||
## Web Provider Behavior
|
||||
- Input: local file path **or** HTTP(S) URL.
|
||||
- Flow: load into a Buffer, detect media kind, and build the correct payload:
|
||||
- **Images:** resize & recompress to JPEG (max side 2048px) targeting `inbound.agent.mediaMaxMb` (default 5 MB), capped at 6 MB.
|
||||
- **Images:** resize & recompress to JPEG (max side 2048px) targeting `agent.mediaMaxMb` (default 5 MB), capped at 6 MB.
|
||||
- **Audio/Voice/Video:** pass-through up to 16 MB; audio is sent as a voice note (`ptt: true`).
|
||||
- **Documents:** anything else, up to 100 MB, with filename preserved when available.
|
||||
- MIME detection prefers magic bytes, then headers, then file extension.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
73
src/agents/model-catalog.ts
Normal file
73
src/agents/model-catalog.ts
Normal 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;
|
||||
}
|
||||
75
src/agents/model-selection.ts
Normal file
75
src/agents/model-selection.ts
Normal 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
19
src/auto-reply/model.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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`),
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 },
|
||||
}),
|
||||
}));
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: (() => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user