Compare commits

..
26 changed files with 441 additions and 130 deletions

No files matched your search

+3
View File
@@ -66,3 +66,6 @@ apps/ios/*.mobileprovision
IDENTITY.md
USER.md
.tgz
# local tooling
.serena/
-1
View File
@@ -1 +0,0 @@
/cache
Binary file not shown.
Binary file not shown.
-87
View File
@@ -1,87 +0,0 @@
# list of languages for which language servers are started; choose from:
# al bash clojure cpp csharp csharp_omnisharp
# dart elixir elm erlang fortran fsharp
# go groovy haskell java julia kotlin
# lua markdown nix pascal perl php
# powershell python python_jedi r rego ruby
# ruby_solargraph rust scala swift terraform toml
# typescript typescript_vts yaml zig
# Note:
# - For C, use cpp
# - For JavaScript, use typescript
# - For Free Pascal / Lazarus, use pascal
# Special requirements:
# - csharp: Requires the presence of a .sln file in the project folder.
# - pascal: Requires Free Pascal Compiler (fpc) and optionally Lazarus.
# When using multiple languages, the first language server that supports a given file will be used for that file.
# The first language is the default language and the respective language server will be used as a fallback.
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
languages:
- typescript
# the encoding used by text files in the project
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
encoding: "utf-8"
# whether to use the project's gitignore file to ignore files
# Added on 2025-04-07
ignore_all_files_in_gitignore: true
# list of additional paths to ignore
# same syntax as gitignore, so you can use * and **
# Was previously called `ignored_dirs`, please update your config if you are using that.
# Added (renamed) on 2025-04-07
ignored_paths: []
# whether the project is in read-only mode
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
# Added on 2025-04-18
read_only: false
# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
# Below is the complete list of tools for convenience.
# To make sure you have the latest list of tools, and to view their descriptions,
# execute `uv run scripts/print_tool_overview.py`.
#
# * `activate_project`: Activates a project by name.
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
# * `create_text_file`: Creates/overwrites a file in the project directory.
# * `delete_lines`: Deletes a range of lines within a file.
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
# * `execute_shell_command`: Executes a shell command.
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
# * `initial_instructions`: Gets the initial instructions for the current project.
# Should only be used in settings where the system prompt cannot be set,
# e.g. in clients you have no control over, like Claude Desktop.
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
# * `insert_at_line`: Inserts content at a given line in a file.
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
# * `list_memories`: Lists memories in Serena's project-specific memory store.
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
# * `read_file`: Reads a file within the project directory.
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
# * `remove_project`: Removes a project from the Serena configuration.
# * `replace_lines`: Replaces a range of lines within a file with new content.
# * `replace_symbol_body`: Replaces the full definition of a symbol.
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
# * `search_for_pattern`: Performs a search for a pattern in the project.
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
# * `switch_modes`: Activates modes by providing a list of their names
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
excluded_tools: []
# initial prompt for the project. It will always be given to the LLM upon activating the project
# (contrary to the memories, which are loaded on demand).
initial_prompt: ""
project_name: "clawdbot"
included_optional_tools: []
+1
View File
@@ -16,6 +16,7 @@ Docs: https://docs.clawd.bot
- Zalouser: add channel dock metadata, config schema, setup wiring, probe, and status issues. (#1219) — thanks @suminhthanh.
### Fixes
- Web search: infer Perplexity base URL from API key source (direct vs OpenRouter).
- Session: preserve command argument casing while matching reset triggers. (#1308) — thanks @dougvk.
- TUI: keep thinking blocks ordered before content during streaming and isolate per-run assembly. (#1202) — thanks @aaronveklabs.
- TUI: align custom editor initialization with the latest pi-tui API. (#1298) — thanks @sibbl.
- CLI: avoid duplicating --profile/--dev flags when formatting commands.
+1 -1
View File
@@ -60,7 +60,7 @@ the workspace is writable. See [Memory](/concepts/memory) and
- Idle reset (optional): `idleMinutes` adds a sliding idle window. When both daily and idle resets are configured, **whichever expires first** forces a new session.
- Legacy idle-only: if you set `session.idleMinutes` without any `session.reset`/`resetByType` config, Clawdbot stays in idle-only mode for backward compatibility.
- Per-type overrides (optional): `resetByType` lets you override the policy for `dm`, `group`, and `thread` sessions (thread = Slack/Discord threads, Telegram topics, Matrix threads when provided by the connector).
- Reset triggers: exact `/new` or `/reset` (plus any extras in `resetTriggers`) start a fresh session id and pass the remainder of the message through. If `/new` or `/reset` is sent alone, Clawdbot runs a short “hello” greeting turn to confirm the reset.
- Reset triggers: exact `/new` or `/reset` (plus any extras in `resetTriggers`) start a fresh session id and pass the remainder of the message through. `/new <model>` accepts a model alias, `provider/model`, or provider name (fuzzy match) to set the new session model. If `/new` or `/reset` is sent alone, Clawdbot runs a short “hello” greeting turn to confirm the reset.
- Manual reset: delete specific keys from the store or remove the JSONL transcript; the next message recreates them.
- Isolated cron jobs always mint a fresh `sessionId` per run (no idle reuse).
+2 -1
View File
@@ -73,7 +73,7 @@ Text + native (when enabled):
- `/dock-slack` (alias: `/dock_slack`) (switch replies to Slack)
- `/activation mention|always` (groups only)
- `/send on|off|inherit` (owner-only)
- `/reset` or `/new`
- `/reset` or `/new [model]` (optional model hint; remainder is passed through)
- `/think <off|minimal|low|medium|high|xhigh>` (dynamic choices by model/provider; aliases: `/thinking`, `/t`)
- `/verbose on|full|off` (alias: `/v`)
- `/reasoning on|off|stream` (alias: `/reason`; when on, sends a separate message prefixed `Reasoning:`; `stream` = Telegram draft only)
@@ -91,6 +91,7 @@ Text-only:
Notes:
- Commands accept an optional `:` between the command and args (e.g. `/think: high`, `/send: on`, `/help:`).
- `/new <model>` accepts a model alias, `provider/model`, or a provider name (fuzzy match); if no match, the text is treated as the message body.
- For full provider usage breakdown, use `clawdbot status --usage`.
- `/usage` controls the per-response usage footer; `/usage cost` prints a local cost summary from Clawdbot session logs.
- `/restart` is disabled by default; set `commands.restart: true` to enable it.
+7
View File
@@ -11,6 +11,7 @@ import type { CliBackendConfig } from "../../config/types.js";
import { runExec } from "../../process/exec.js";
import type { EmbeddedContextFile } from "../pi-embedded-helpers.js";
import { buildSystemPromptParams } from "../system-prompt-params.js";
import { resolveDefaultModelForAgent } from "../model-selection.js";
import { buildAgentSystemPrompt } from "../system-prompt.js";
const CLI_RUN_QUEUE = new Map<string, Promise<unknown>>();
@@ -174,6 +175,11 @@ export function buildSystemPrompt(params: {
modelDisplay: string;
agentId?: string;
}) {
const defaultModelRef = resolveDefaultModelForAgent({
cfg: params.config ?? {},
agentId: params.agentId,
});
const defaultModelLabel = `${defaultModelRef.provider}/${defaultModelRef.model}`;
const { runtimeInfo, userTimezone, userTime, userTimeFormat } = buildSystemPromptParams({
config: params.config,
agentId: params.agentId,
@@ -183,6 +189,7 @@ export function buildSystemPrompt(params: {
arch: os.arch(),
node: process.version,
model: params.modelDisplay,
defaultModel: defaultModelLabel,
},
});
return buildAgentSystemPrompt({
+1 -5
View File
@@ -1,13 +1,9 @@
import type { Api, Model } from "@mariozechner/pi-ai";
function isOpenAICompletionsModel(model: Model<Api>): model is Model<"openai-completions"> {
return model.api === "openai-completions";
}
export function normalizeModelCompat(model: Model<Api>): Model<Api> {
const baseUrl = model.baseUrl ?? "";
const isZai = model.provider === "zai" || baseUrl.includes("api.z.ai");
if (!isZai || !isOpenAICompletionsModel(model)) return model;
if (!isZai || model.api !== "openai-completions") return model;
const compat = model.compat ?? {};
if (compat.supportsDeveloperRole === false) return model;
+34
View File
@@ -1,6 +1,8 @@
import type { ClawdbotConfig } from "../config/config.js";
import type { ModelCatalogEntry } from "./model-catalog.js";
import { normalizeGoogleModelId } from "./models-config.providers.js";
import { resolveAgentModelPrimary } from "./agent-scope.js";
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js";
export type ModelRef = {
provider: string;
@@ -141,6 +143,38 @@ export function resolveConfiguredModelRef(params: {
return { provider: params.defaultProvider, model: params.defaultModel };
}
export function resolveDefaultModelForAgent(params: {
cfg: ClawdbotConfig;
agentId?: string;
}): ModelRef {
const agentModelOverride = params.agentId
? resolveAgentModelPrimary(params.cfg, params.agentId)
: undefined;
const cfg =
agentModelOverride && agentModelOverride.length > 0
? {
...params.cfg,
agents: {
...params.cfg.agents,
defaults: {
...params.cfg.agents?.defaults,
model: {
...(typeof params.cfg.agents?.defaults?.model === "object"
? params.cfg.agents.defaults.model
: undefined),
primary: agentModelOverride,
},
},
},
}
: params.cfg;
return resolveConfiguredModelRef({
cfg,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
});
}
export function buildAllowedModelSet(params: {
cfg: ClawdbotConfig;
catalog: ModelCatalogEntry[];
@@ -43,6 +43,7 @@ import {
resolveSkillsPromptForRun,
} from "../../skills.js";
import { buildSystemPromptReport } from "../../system-prompt-report.js";
import { resolveDefaultModelForAgent } from "../../model-selection.js";
import { isAbortError } from "../abort.js";
import { buildEmbeddedExtensionPaths } from "../extensions.js";
@@ -212,6 +213,11 @@ export async function runEmbeddedAttempt(
})
: undefined;
const defaultModelRef = resolveDefaultModelForAgent({
cfg: params.config ?? {},
agentId: sessionAgentId,
});
const defaultModelLabel = `${defaultModelRef.provider}/${defaultModelRef.model}`;
const { runtimeInfo, userTimezone, userTime, userTimeFormat } = buildSystemPromptParams({
config: params.config,
agentId: sessionAgentId,
@@ -221,6 +227,7 @@ export async function runEmbeddedAttempt(
arch: os.arch(),
node: process.version,
model: `${params.provider}/${params.modelId}`,
defaultModel: defaultModelLabel,
channel: runtimeChannel,
capabilities: runtimeCapabilities,
channelActions,
+1
View File
@@ -13,6 +13,7 @@ export type RuntimeInfoInput = {
arch: string;
node: string;
model: string;
defaultModel?: string;
channel?: string;
capabilities?: string[];
/** Supported message actions for the current channel (e.g., react, edit, unsend) */
+2
View File
@@ -288,6 +288,7 @@ describe("buildAgentSystemPrompt", () => {
arch: "arm64",
node: "v20",
model: "anthropic/claude",
defaultModel: "anthropic/claude-opus-4-5",
},
"telegram",
["inlineButtons"],
@@ -299,6 +300,7 @@ describe("buildAgentSystemPrompt", () => {
expect(line).toContain("os=macOS (arm64)");
expect(line).toContain("node=v20");
expect(line).toContain("model=anthropic/claude");
expect(line).toContain("default_model=anthropic/claude-opus-4-5");
expect(line).toContain("channel=telegram");
expect(line).toContain("capabilities=inlineButtons");
expect(line).toContain("thinking=low");
+2
View File
@@ -582,6 +582,7 @@ export function buildRuntimeLine(
arch?: string;
node?: string;
model?: string;
defaultModel?: string;
},
runtimeChannel?: string,
runtimeCapabilities: string[] = [],
@@ -597,6 +598,7 @@ export function buildRuntimeLine(
: "",
runtimeInfo?.node ? `node=${runtimeInfo.node}` : "",
runtimeInfo?.model ? `model=${runtimeInfo.model}` : "",
runtimeInfo?.defaultModel ? `default_model=${runtimeInfo.defaultModel}` : "",
runtimeChannel ? `channel=${runtimeChannel}` : "",
runtimeChannel
? `capabilities=${runtimeCapabilities.length > 0 ? runtimeCapabilities.join(",") : "none"}`
+2
View File
@@ -312,12 +312,14 @@ function buildChatCommands(): ChatCommandDefinition[] {
nativeName: "reset",
description: "Reset the current session.",
textAlias: "/reset",
acceptsArgs: true,
}),
defineChatCommand({
key: "new",
nativeName: "new",
description: "Start a new session.",
textAlias: "/new",
acceptsArgs: true,
}),
defineChatCommand({
key: "compact",
@@ -7,6 +7,7 @@ import { getSkillsSnapshotVersion } from "../../agents/skills/refresh.js";
import { buildAgentSystemPrompt } from "../../agents/system-prompt.js";
import { buildSystemPromptReport } from "../../agents/system-prompt-report.js";
import { buildSystemPromptParams } from "../../agents/system-prompt-params.js";
import { resolveDefaultModelForAgent } from "../../agents/model-selection.js";
import { buildToolSummaryMap } from "../../agents/tool-summaries.js";
import { resolveBootstrapContextForRun } from "../../agents/bootstrap-files.js";
import type { SessionSystemPromptReport } from "../../config/sessions/types.js";
@@ -93,6 +94,11 @@ async function resolveContextReport(
sessionKey: params.sessionKey,
config: params.cfg,
});
const defaultModelRef = resolveDefaultModelForAgent({
cfg: params.cfg,
agentId: sessionAgentId,
});
const defaultModelLabel = `${defaultModelRef.provider}/${defaultModelRef.model}`;
const { runtimeInfo, userTimezone, userTime, userTimeFormat } = buildSystemPromptParams({
config: params.cfg,
agentId: sessionAgentId,
@@ -102,6 +108,7 @@ async function resolveContextReport(
arch: "unknown",
node: process.version,
model: `${params.provider}/${params.model}`,
defaultModel: defaultModelLabel,
},
});
const sandboxInfo = sandboxRuntime.sandboxed
@@ -1,16 +1,15 @@
import {
resolveAgentDir,
resolveAgentModelPrimary,
resolveDefaultAgentId,
resolveSessionAgentId,
} from "../../agents/agent-scope.js";
import { lookupContextTokens } from "../../agents/context.js";
import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../../agents/defaults.js";
import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js";
import {
buildModelAliasIndex,
type ModelAliasIndex,
modelKey,
resolveConfiguredModelRef,
resolveDefaultModelForAgent,
resolveModelRefFromString,
} from "../../agents/model-selection.js";
import type { ClawdbotConfig } from "../../config/config.js";
@@ -239,36 +238,14 @@ export function resolveDefaultModel(params: { cfg: ClawdbotConfig; agentId?: str
defaultModel: string;
aliasIndex: ModelAliasIndex;
} {
const agentModelOverride = params.agentId
? resolveAgentModelPrimary(params.cfg, params.agentId)
: undefined;
const cfg =
agentModelOverride && agentModelOverride.length > 0
? {
...params.cfg,
agents: {
...params.cfg.agents,
defaults: {
...params.cfg.agents?.defaults,
model: {
...(typeof params.cfg.agents?.defaults?.model === "object"
? params.cfg.agents.defaults.model
: undefined),
primary: agentModelOverride,
},
},
},
}
: params.cfg;
const mainModel = resolveConfiguredModelRef({
cfg,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
const mainModel = resolveDefaultModelForAgent({
cfg: params.cfg,
agentId: params.agentId,
});
const defaultProvider = mainModel.provider;
const defaultModel = mainModel.model;
const aliasIndex = buildModelAliasIndex({
cfg,
cfg: params.cfg,
defaultProvider,
});
return { defaultProvider, defaultModel, aliasIndex };
+27 -1
View File
@@ -38,6 +38,7 @@ import { SILENT_REPLY_TOKEN } from "../tokens.js";
import type { GetReplyOptions, ReplyPayload } from "../types.js";
import { runReplyAgent } from "./agent-runner.js";
import { applySessionHints } from "./body.js";
import { routeReply } from "./route-reply.js";
import type { buildCommandContext } from "./commands.js";
import type { InlineDirectives } from "./directive-handling.js";
import { buildGroupIntro } from "./groups.js";
@@ -51,7 +52,7 @@ type AgentDefaults = NonNullable<ClawdbotConfig["agents"]>["defaults"];
type ExecOverrides = Pick<ExecToolDefaults, "host" | "security" | "ask" | "node">;
const BARE_SESSION_RESET_PROMPT =
"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.";
"A new session was started via /new or /reset. Say hi briefly (1-2 sentences) and ask what the user wants to do next. If the runtime model differs from default_model in the system prompt, mention the default model in the greeting. Do not mention internal steps, files, tools, or reasoning.";
type RunPreparedReplyParams = {
ctx: MsgContext;
@@ -92,9 +93,11 @@ type RunPreparedReplyParams = {
};
typing: TypingController;
opts?: GetReplyOptions;
defaultProvider: string;
defaultModel: string;
timeoutMs: number;
isNewSession: boolean;
resetTriggered: boolean;
systemSent: boolean;
sessionEntry?: SessionEntry;
sessionStore?: Record<string, SessionEntry>;
@@ -220,9 +223,11 @@ export async function runPreparedReply(
perMessageQueueOptions,
typing,
opts,
defaultProvider,
defaultModel,
timeoutMs,
isNewSession,
resetTriggered,
systemSent,
sessionKey,
sessionId,
@@ -369,6 +374,27 @@ export async function runPreparedReply(
}
}
}
if (resetTriggered && command.isAuthorizedSender) {
const channel = ctx.OriginatingChannel || (command.channel as any);
const to = ctx.OriginatingTo || command.from || command.to;
if (channel && to) {
const modelLabel = `${provider}/${model}`;
const defaultLabel = `${defaultProvider}/${defaultModel}`;
const text =
modelLabel === defaultLabel
? `✅ New session started · model: ${modelLabel}`
: `✅ New session started · model: ${modelLabel} (default: ${defaultLabel})`;
await routeReply({
payload: { text },
channel,
to,
sessionKey,
accountId: ctx.AccountId,
threadId: ctx.MessageThreadId,
cfg,
});
}
}
const sessionIdFinal = sessionId ?? crypto.randomUUID();
const sessionFile = resolveSessionFilePath(sessionIdFinal, sessionEntry);
const queueBodyBase = [threadStarterNote, baseBodyFinal].filter(Boolean).join("\n\n");
+20
View File
@@ -19,6 +19,7 @@ import { handleInlineActions } from "./get-reply-inline-actions.js";
import { runPreparedReply } from "./get-reply-run.js";
import { finalizeInboundContext } from "./inbound-context.js";
import { initSessionState } from "./session.js";
import { applyResetModelOverride } from "./session-reset-model.js";
import { stageSandboxMedia } from "./stage-sandbox-media.js";
import { createTypingController } from "./typing.js";
@@ -103,6 +104,7 @@ export async function getReplyFromConfig(
sessionKey,
sessionId,
isNewSession,
resetTriggered,
systemSent,
abortedLastRun,
storePath,
@@ -110,8 +112,24 @@ export async function getReplyFromConfig(
groupResolution,
isGroup,
triggerBodyNormalized,
bodyStripped,
} = sessionState;
await applyResetModelOverride({
cfg,
resetTriggered,
bodyStripped,
sessionCtx,
ctx: finalized,
sessionEntry,
sessionStore,
sessionKey,
storePath,
defaultProvider,
defaultModel,
aliasIndex,
});
const directiveResult = await resolveReplyDirectives({
ctx: finalized,
cfg,
@@ -256,9 +274,11 @@ export async function getReplyFromConfig(
perMessageQueueOptions,
typing,
opts,
defaultProvider,
defaultModel,
timeoutMs,
isNewSession,
resetTriggered,
systemSent,
sessionEntry,
sessionStore,
@@ -0,0 +1,74 @@
import { describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../../config/config.js";
import { buildModelAliasIndex } from "../../agents/model-selection.js";
import { applyResetModelOverride } from "./session-reset-model.js";
vi.mock("../../agents/model-catalog.js", () => ({
loadModelCatalog: vi.fn(async () => [
{ provider: "minimax", id: "m2.1", name: "M2.1" },
{ provider: "openai", id: "gpt-4o-mini", name: "GPT-4o mini" },
]),
}));
describe("applyResetModelOverride", () => {
it("selects a model hint and strips it from the body", async () => {
const cfg = {} as ClawdbotConfig;
const aliasIndex = buildModelAliasIndex({ cfg, defaultProvider: "openai" });
const sessionEntry = {
sessionId: "s1",
updatedAt: Date.now(),
};
const sessionStore = { "agent:main:dm:1": sessionEntry };
const sessionCtx = { BodyStripped: "minimax summarize" };
const ctx = { ChatType: "direct" };
await applyResetModelOverride({
cfg,
resetTriggered: true,
bodyStripped: "minimax summarize",
sessionCtx,
ctx,
sessionEntry,
sessionStore,
sessionKey: "agent:main:dm:1",
defaultProvider: "openai",
defaultModel: "gpt-4o-mini",
aliasIndex,
});
expect(sessionEntry.providerOverride).toBe("minimax");
expect(sessionEntry.modelOverride).toBe("m2.1");
expect(sessionCtx.BodyStripped).toBe("summarize");
});
it("skips when resetTriggered is false", async () => {
const cfg = {} as ClawdbotConfig;
const aliasIndex = buildModelAliasIndex({ cfg, defaultProvider: "openai" });
const sessionEntry = {
sessionId: "s1",
updatedAt: Date.now(),
};
const sessionStore = { "agent:main:dm:1": sessionEntry };
const sessionCtx = { BodyStripped: "minimax summarize" };
const ctx = { ChatType: "direct" };
await applyResetModelOverride({
cfg,
resetTriggered: false,
bodyStripped: "minimax summarize",
sessionCtx,
ctx,
sessionEntry,
sessionStore,
sessionKey: "agent:main:dm:1",
defaultProvider: "openai",
defaultModel: "gpt-4o-mini",
aliasIndex,
});
expect(sessionEntry.providerOverride).toBeUndefined();
expect(sessionEntry.modelOverride).toBeUndefined();
expect(sessionCtx.BodyStripped).toBe("minimax summarize");
});
});
+191
View File
@@ -0,0 +1,191 @@
import { loadModelCatalog } from "../../agents/model-catalog.js";
import {
buildAllowedModelSet,
modelKey,
normalizeProviderId,
resolveModelRefFromString,
type ModelAliasIndex,
} from "../../agents/model-selection.js";
import type { ClawdbotConfig } from "../../config/config.js";
import type { SessionEntry } from "../../config/sessions.js";
import { updateSessionStore } from "../../config/sessions.js";
import type { MsgContext, TemplateContext } from "../templating.js";
import { formatInboundBodyWithSenderMeta } from "./inbound-sender-meta.js";
import { resolveModelDirectiveSelection, type ModelDirectiveSelection } from "./model-selection.js";
type ResetModelResult = {
selection?: ModelDirectiveSelection;
cleanedBody?: string;
};
function splitBody(body: string) {
const tokens = body.split(/\s+/).filter(Boolean);
return {
tokens,
first: tokens[0],
second: tokens[1],
rest: tokens.slice(2),
};
}
function buildSelectionFromExplicit(params: {
raw: string;
defaultProvider: string;
defaultModel: string;
aliasIndex: ModelAliasIndex;
allowedModelKeys: Set<string>;
}): ModelDirectiveSelection | undefined {
const resolved = resolveModelRefFromString({
raw: params.raw,
defaultProvider: params.defaultProvider,
aliasIndex: params.aliasIndex,
});
if (!resolved) return undefined;
const key = modelKey(resolved.ref.provider, resolved.ref.model);
if (params.allowedModelKeys.size > 0 && !params.allowedModelKeys.has(key)) return undefined;
const isDefault =
resolved.ref.provider === params.defaultProvider && resolved.ref.model === params.defaultModel;
return {
provider: resolved.ref.provider,
model: resolved.ref.model,
isDefault,
...(resolved.alias ? { alias: resolved.alias } : undefined),
};
}
function applySelectionToSession(params: {
selection: ModelDirectiveSelection;
sessionEntry?: SessionEntry;
sessionStore?: Record<string, SessionEntry>;
sessionKey?: string;
storePath?: string;
}) {
const { selection, sessionEntry, sessionStore, sessionKey, storePath } = params;
if (!sessionEntry || !sessionStore || !sessionKey) return;
let updated = false;
if (selection.isDefault) {
if (sessionEntry.providerOverride || sessionEntry.modelOverride) {
delete sessionEntry.providerOverride;
delete sessionEntry.modelOverride;
updated = true;
}
} else {
if (sessionEntry.providerOverride !== selection.provider) {
sessionEntry.providerOverride = selection.provider;
updated = true;
}
if (sessionEntry.modelOverride !== selection.model) {
sessionEntry.modelOverride = selection.model;
updated = true;
}
}
if (!updated) return;
sessionEntry.updatedAt = Date.now();
sessionStore[sessionKey] = sessionEntry;
if (storePath) {
updateSessionStore(storePath, (store) => {
store[sessionKey] = sessionEntry;
}).catch(() => {
// Ignore persistence errors; session still proceeds.
});
}
}
export async function applyResetModelOverride(params: {
cfg: ClawdbotConfig;
resetTriggered: boolean;
bodyStripped?: string;
sessionCtx: TemplateContext;
ctx: MsgContext;
sessionEntry?: SessionEntry;
sessionStore?: Record<string, SessionEntry>;
sessionKey?: string;
storePath?: string;
defaultProvider: string;
defaultModel: string;
aliasIndex: ModelAliasIndex;
}): Promise<ResetModelResult> {
if (!params.resetTriggered) return {};
const rawBody = params.bodyStripped?.trim();
if (!rawBody) return {};
const { tokens, first, second } = splitBody(rawBody);
if (!first) return {};
const catalog = await loadModelCatalog({ config: params.cfg });
const allowed = buildAllowedModelSet({
cfg: params.cfg,
catalog,
defaultProvider: params.defaultProvider,
defaultModel: params.defaultModel,
});
const allowedModelKeys = allowed.allowedKeys;
if (allowedModelKeys.size === 0) return {};
const providers = new Set<string>();
for (const key of allowedModelKeys) {
const slash = key.indexOf("/");
if (slash <= 0) continue;
providers.add(normalizeProviderId(key.slice(0, slash)));
}
const resolveSelection = (raw: string) =>
resolveModelDirectiveSelection({
raw,
defaultProvider: params.defaultProvider,
defaultModel: params.defaultModel,
aliasIndex: params.aliasIndex,
allowedModelKeys,
});
let selection: ModelDirectiveSelection | undefined;
let consumed = 0;
if (providers.has(normalizeProviderId(first)) && second) {
const composite = `${normalizeProviderId(first)}/${second}`;
const resolved = resolveSelection(composite);
if (resolved.selection) {
selection = resolved.selection;
consumed = 2;
}
}
if (!selection) {
selection = buildSelectionFromExplicit({
raw: first,
defaultProvider: params.defaultProvider,
defaultModel: params.defaultModel,
aliasIndex: params.aliasIndex,
allowedModelKeys,
});
if (selection) consumed = 1;
}
if (!selection) {
const resolved = resolveSelection(first);
const allowFuzzy = providers.has(normalizeProviderId(first)) || first.trim().length >= 6;
if (allowFuzzy) {
selection = resolved.selection;
if (selection) consumed = 1;
}
}
if (!selection) return {};
const cleanedBody = tokens.slice(consumed).join(" ").trim();
params.sessionCtx.BodyStripped = formatInboundBodyWithSenderMeta({
ctx: params.ctx,
body: cleanedBody,
});
params.sessionCtx.BodyForCommands = cleanedBody;
applySelectionToSession({
selection,
sessionEntry: params.sessionEntry,
sessionStore: params.sessionStore,
sessionKey: params.sessionKey,
storePath: params.storePath,
});
return { selection, cleanedBody };
}
+28
View File
@@ -151,6 +151,34 @@ describe("initSessionState RawBody", () => {
expect(result.bodyStripped).toBe("");
});
it("preserves argument casing while still matching reset triggers case-insensitively", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-rawbody-reset-case-"));
const storePath = path.join(root, "sessions.json");
const cfg = {
session: {
store: storePath,
resetTriggers: ["/new"],
},
} as ClawdbotConfig;
const ctx = {
RawBody: "/NEW KeepThisCase",
ChatType: "direct",
SessionKey: "agent:main:whatsapp:dm:S1",
};
const result = await initSessionState({
ctx,
cfg,
commandAuthorized: true,
});
expect(result.isNewSession).toBe(true);
expect(result.bodyStripped).toBe("KeepThisCase");
expect(result.triggerBodyNormalized).toBe("/NEW KeepThisCase");
});
it("falls back to Body when RawBody is undefined", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-rawbody-fallback-"));
const storePath = path.join(root, "sessions.json");
+19 -4
View File
@@ -40,6 +40,7 @@ export type SessionInitResult = {
sessionKey: string;
sessionId: string;
isNewSession: boolean;
resetTriggered: boolean;
systemSent: boolean;
abortedLastRun: boolean;
storePath: string;
@@ -135,7 +136,10 @@ export async function initSessionState(params: {
// Prefer CommandBody/RawBody (clean message) for command detection; fall back
// to Body which may contain structural context (history, sender labels).
const commandSource = ctx.BodyForCommands ?? ctx.CommandBody ?? ctx.RawBody ?? ctx.Body ?? "";
const triggerBodyNormalized = stripStructuralPrefixes(commandSource).trim().toLowerCase();
// IMPORTANT: do NOT lowercase the entire command body.
// Users often pass case-sensitive arguments (e.g. filesystem paths on Linux).
// Command parsing downstream lowercases only the command token for matching.
const triggerBodyNormalized = stripStructuralPrefixes(commandSource).trim();
// Use CommandBody/RawBody for reset trigger matching (clean message without structural context).
const rawBody = commandSource;
@@ -151,17 +155,27 @@ export async function initSessionState(params: {
const strippedForReset = isGroup
? stripMentions(triggerBodyNormalized, ctx, cfg, agentId)
: triggerBodyNormalized;
// Reset triggers are configured as lowercased commands (e.g. "/new"), but users may type
// "/NEW" etc. Match case-insensitively while keeping the original casing for any stripped body.
const trimmedBodyLower = trimmedBody.toLowerCase();
const strippedForResetLower = strippedForReset.toLowerCase();
for (const trigger of resetTriggers) {
if (!trigger) continue;
if (!resetAuthorized) break;
if (trimmedBody === trigger || strippedForReset === trigger) {
const triggerLower = trigger.toLowerCase();
if (trimmedBodyLower === triggerLower || strippedForResetLower === triggerLower) {
isNewSession = true;
bodyStripped = "";
resetTriggered = true;
break;
}
const triggerPrefix = `${trigger} `;
if (trimmedBody.startsWith(triggerPrefix) || strippedForReset.startsWith(triggerPrefix)) {
const triggerPrefixLower = `${triggerLower} `;
if (
trimmedBodyLower.startsWith(triggerPrefixLower) ||
strippedForResetLower.startsWith(triggerPrefixLower)
) {
isNewSession = true;
bodyStripped = strippedForReset.slice(trigger.length).trimStart();
resetTriggered = true;
@@ -327,6 +341,7 @@ export async function initSessionState(params: {
sessionKey,
sessionId: sessionId ?? crypto.randomUUID(),
isNewSession,
resetTriggered,
systemSent,
abortedLastRun,
storePath,
+6
View File
@@ -107,6 +107,12 @@ export function createGatewayCloseHandler(params: {
await params.browserControl.stop().catch(() => {});
}
await new Promise<void>((resolve) => params.wss.close(() => resolve()));
const httpServer = params.httpServer as HttpServer & {
closeIdleConnections?: () => void;
};
if (typeof httpServer.closeIdleConnections === "function") {
httpServer.closeIdleConnections();
}
await new Promise<void>((resolve, reject) =>
params.httpServer.close((err) => (err ? reject(err) : resolve())),
);
-1
View File
@@ -15,7 +15,6 @@ export class CustomEditor extends Editor {
constructor(theme: EditorTheme) {
super(theme);
}
handleInput(data: string): void {
if (matchesKey(data, Key.alt("enter")) && this.onAltEnter) {
this.onAltEnter();