feat: add per-session model selection
This commit is contained in:
@@ -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`.
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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 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.
|
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)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ Tip: treat this folder like Clawd’s “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.
|
||||||
|
|
||||||
|
|||||||
@@ -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 agent’s workspace (`inbound.workspace`)
|
- set the agent’s 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 agent’s identity (`identity`)
|
- set the agent’s 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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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 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`).
|
- **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.
|
- **Documents:** anything else, up to 100 MB, 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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
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(),
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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`),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}`
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 },
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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: (() => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user