diff --git a/CHANGELOG.md b/CHANGELOG.md index 2dd40fbd5..490f6f23d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ Docs: https://docs.clawd.bot - Configure: restrict the model allowlist picker to OAuth-compatible Anthropic models and preselect Opus 4.5. - Configure: seed model fallbacks from the allowlist selection when multiple models are chosen. - Model picker: list the full catalog when no model allowlist is configured. +- Chat: include configured defaults/providers in `/models` output and normalize all-mode paging. (#1398) Thanks @vignesh07. - Discord: honor wildcard channel configs via shared match helpers. (#1334) Thanks @pvoo. - BlueBubbles: resolve short message IDs safely and expose full IDs in templates. (#1387) Thanks @tyler6204. - Infra: preserve fetch helper methods when wrapping abort signals. (#1387) diff --git a/src/auto-reply/model.test.ts b/src/auto-reply/model.test.ts index a2bbf5f92..08b47926d 100644 --- a/src/auto-reply/model.test.ts +++ b/src/auto-reply/model.test.ts @@ -10,11 +10,10 @@ describe("extractModelDirective", () => { expect(result.cleaned).toBe(""); }); - it("extracts /models with argument", () => { + it("does not extract /models with argument", () => { const result = extractModelDirective("/models gpt-5"); - expect(result.hasDirective).toBe(true); - expect(result.rawModel).toBe("gpt-5"); - expect(result.cleaned).toBe(""); + expect(result.hasDirective).toBe(false); + expect(result.cleaned).toBe("/models gpt-5"); }); it("extracts /model with provider/model format", () => { @@ -114,10 +113,11 @@ describe("extractModelDirective", () => { }); describe("edge cases", () => { - it("preserves spacing when /model is followed by a path segment", () => { + it("extracts models with multiple path segments", () => { const result = extractModelDirective("thats not /model gpt-5/tmp/hello"); expect(result.hasDirective).toBe(true); - expect(result.cleaned).toBe("thats not /hello"); + expect(result.rawModel).toBe("gpt-5/tmp/hello"); + expect(result.cleaned).toBe("thats not"); }); it("handles alias with special regex characters", () => { diff --git a/src/auto-reply/model.ts b/src/auto-reply/model.ts index 46ea44db1..450a016b6 100644 --- a/src/auto-reply/model.ts +++ b/src/auto-reply/model.ts @@ -14,7 +14,7 @@ export function extractModelDirective( if (!body) return { cleaned: "", hasDirective: false }; const modelMatch = body.match( - /(?:^|\s)\/models?(?=$|\s|:)\s*:?\s*([A-Za-z0-9_.:@-]+(?:\/[A-Za-z0-9_.:@-]+)?)?/i, + /(?:^|\s)\/model(?=$|\s|:)\s*:?\s*([A-Za-z0-9_.:@-]+(?:\/[A-Za-z0-9_.:@-]+)*)?/i, ); const aliases = (options?.aliases ?? []).map((alias) => alias.trim()).filter(Boolean); diff --git a/src/auto-reply/reply.directive.directive-behavior.lists-allowlisted-models-model-list.test.ts b/src/auto-reply/reply.directive.directive-behavior.lists-allowlisted-models-model-list.test.ts index ffac7deab..47fcf24df 100644 --- a/src/auto-reply/reply.directive.directive-behavior.lists-allowlisted-models-model-list.test.ts +++ b/src/auto-reply/reply.directive.directive-behavior.lists-allowlisted-models-model-list.test.ts @@ -60,13 +60,13 @@ describe("directive behavior", () => { vi.restoreAllMocks(); }); - it("lists allowlisted models on /model list", async () => { + it("lists allowlisted models on /models ", async () => { await withTempHome(async (home) => { vi.mocked(runEmbeddedPiAgent).mockReset(); const storePath = path.join(home, "sessions.json"); const res = await getReplyFromConfig( - { Body: "/model list", From: "+1222", To: "+1222", CommandAuthorized: true }, + { Body: "/models anthropic", From: "+1222", To: "+1222", CommandAuthorized: true }, {}, { agents: { @@ -84,9 +84,29 @@ describe("directive behavior", () => { ); const text = Array.isArray(res) ? res[0]?.text : res?.text; - expect(text).toContain("Pick: /model <#> or /model "); + expect(text).toContain("Models (anthropic)"); expect(text).toContain("anthropic/claude-opus-4-5"); - expect(text).toContain("openai/gpt-4.1-mini"); + + const openaiRes = await getReplyFromConfig( + { Body: "/models openai", From: "+1222", To: "+1222", CommandAuthorized: true }, + {}, + { + agents: { + defaults: { + model: { primary: "anthropic/claude-opus-4-5" }, + workspace: path.join(home, "clawd"), + models: { + "anthropic/claude-opus-4-5": {}, + "openai/gpt-4.1-mini": {}, + }, + }, + }, + session: { store: storePath }, + }, + ); + const openaiText = Array.isArray(openaiRes) ? openaiRes[0]?.text : openaiRes?.text; + expect(openaiText).toContain("Models (openai)"); + expect(openaiText).toContain("openai/gpt-4.1-mini"); expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); }); }); @@ -97,7 +117,7 @@ describe("directive behavior", () => { const storePath = path.join(home, "sessions.json"); const res = await getReplyFromConfig( - { Body: "/model", From: "+1222", To: "+1222", CommandAuthorized: true }, + { Body: "/models anthropic", From: "+1222", To: "+1222", CommandAuthorized: true }, {}, { agents: { @@ -115,9 +135,29 @@ describe("directive behavior", () => { ); const text = Array.isArray(res) ? res[0]?.text : res?.text; - expect(text).toContain("Pick: /model <#> or /model "); + expect(text).toContain("Models (anthropic)"); expect(text).toContain("anthropic/claude-opus-4-5"); - expect(text).toContain("openai/gpt-4.1-mini"); + + const openaiRes = await getReplyFromConfig( + { Body: "/models openai", From: "+1222", To: "+1222", CommandAuthorized: true }, + {}, + { + agents: { + defaults: { + model: { primary: "anthropic/claude-opus-4-5" }, + workspace: path.join(home, "clawd"), + models: { + "anthropic/claude-opus-4-5": {}, + "openai/gpt-4.1-mini": {}, + }, + }, + }, + session: { store: storePath }, + }, + ); + const openaiText = Array.isArray(openaiRes) ? openaiRes[0]?.text : openaiRes?.text; + expect(openaiText).toContain("Models (openai)"); + expect(openaiText).toContain("openai/gpt-4.1-mini"); expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); }); }); @@ -132,7 +172,7 @@ describe("directive behavior", () => { const storePath = path.join(home, "sessions.json"); const res = await getReplyFromConfig( - { Body: "/model list", From: "+1222", To: "+1222", CommandAuthorized: true }, + { Body: "/models xai", From: "+1222", To: "+1222", CommandAuthorized: true }, {}, { agents: { @@ -150,10 +190,29 @@ describe("directive behavior", () => { ); 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).toContain("minimax/MiniMax-M2.1"); + expect(text).toContain("Models (xai)"); expect(text).toContain("xai/grok-4"); + + const minimaxRes = await getReplyFromConfig( + { Body: "/models minimax", From: "+1222", To: "+1222", CommandAuthorized: true }, + {}, + { + agents: { + defaults: { + model: { + primary: "anthropic/claude-opus-4-5", + fallbacks: ["openai/gpt-4.1-mini"], + }, + imageModel: { primary: "minimax/MiniMax-M2.1" }, + workspace: path.join(home, "clawd"), + }, + }, + session: { store: storePath }, + }, + ); + const minimaxText = Array.isArray(minimaxRes) ? minimaxRes[0]?.text : minimaxRes?.text; + expect(minimaxText).toContain("Models (minimax)"); + expect(minimaxText).toContain("minimax/MiniMax-M2.1"); expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); }); }); @@ -173,7 +232,7 @@ describe("directive behavior", () => { const storePath = path.join(home, "sessions.json"); const res = await getReplyFromConfig( - { Body: "/model list", From: "+1222", To: "+1222", CommandAuthorized: true }, + { Body: "/models minimax", From: "+1222", To: "+1222", CommandAuthorized: true }, {}, { agents: { @@ -202,8 +261,7 @@ describe("directive behavior", () => { ); 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).toContain("Models (minimax)"); expect(text).toContain("minimax/MiniMax-M2.1"); expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); }); @@ -231,6 +289,7 @@ describe("directive behavior", () => { ); const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text).toContain("Model listing moved."); expect(text).not.toContain("missing (missing)"); expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); }); diff --git a/src/auto-reply/reply.directive.directive-behavior.prefers-alias-matches-fuzzy-selection-is-ambiguous.test.ts b/src/auto-reply/reply.directive.directive-behavior.prefers-alias-matches-fuzzy-selection-is-ambiguous.test.ts index e42ced2db..220d02939 100644 --- a/src/auto-reply/reply.directive.directive-behavior.prefers-alias-matches-fuzzy-selection-is-ambiguous.test.ts +++ b/src/auto-reply/reply.directive.directive-behavior.prefers-alias-matches-fuzzy-selection-is-ambiguous.test.ts @@ -62,13 +62,13 @@ describe("directive behavior", () => { vi.restoreAllMocks(); }); - it("prefers alias matches when fuzzy selection is ambiguous", async () => { + it("selects exact alias matches even when ambiguous", async () => { await withTempHome(async (home) => { vi.mocked(runEmbeddedPiAgent).mockReset(); const storePath = path.join(home, "sessions.json"); await getReplyFromConfig( - { Body: "/model ki", From: "+1222", To: "+1222", CommandAuthorized: true }, + { Body: "/model Kimi", From: "+1222", To: "+1222", CommandAuthorized: true }, {}, { agents: { diff --git a/src/auto-reply/reply.directive.directive-behavior.supports-fuzzy-model-matches-model-directive.test.ts b/src/auto-reply/reply.directive.directive-behavior.supports-fuzzy-model-matches-model-directive.test.ts index c78919f97..490a1a554 100644 --- a/src/auto-reply/reply.directive.directive-behavior.supports-fuzzy-model-matches-model-directive.test.ts +++ b/src/auto-reply/reply.directive.directive-behavior.supports-fuzzy-model-matches-model-directive.test.ts @@ -65,7 +65,7 @@ describe("directive behavior", () => { vi.mocked(runEmbeddedPiAgent).mockReset(); const storePath = path.join(home, "sessions.json"); - await getReplyFromConfig( + const res = await getReplyFromConfig( { Body: "/model kimi", From: "+1222", To: "+1222", CommandAuthorized: true }, {}, { @@ -94,10 +94,10 @@ describe("directive behavior", () => { }, ); - assertModelSelection(storePath, { - model: "kimi-k2-0905-preview", - provider: "moonshot", - }); + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text).toContain("Did you mean:"); + expect(text).toContain("moonshot/kimi-k2-0905-preview"); + assertModelSelection(storePath); expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); }); }); @@ -106,7 +106,7 @@ describe("directive behavior", () => { vi.mocked(runEmbeddedPiAgent).mockReset(); const storePath = path.join(home, "sessions.json"); - await getReplyFromConfig( + const res = await getReplyFromConfig( { Body: "/model kimi-k2-0905-preview", From: "+1222", @@ -140,10 +140,10 @@ describe("directive behavior", () => { }, ); - assertModelSelection(storePath, { - model: "kimi-k2-0905-preview", - provider: "moonshot", - }); + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text).toContain("Did you mean:"); + expect(text).toContain("moonshot/kimi-k2-0905-preview"); + assertModelSelection(storePath); expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); }); }); @@ -152,7 +152,7 @@ describe("directive behavior", () => { vi.mocked(runEmbeddedPiAgent).mockReset(); const storePath = path.join(home, "sessions.json"); - await getReplyFromConfig( + const res = await getReplyFromConfig( { Body: "/model moonshot/kimi", From: "+1222", To: "+1222", CommandAuthorized: true }, {}, { @@ -181,10 +181,10 @@ describe("directive behavior", () => { }, ); - assertModelSelection(storePath, { - model: "kimi-k2-0905-preview", - provider: "moonshot", - }); + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text).toContain("Did you mean:"); + expect(text).toContain("moonshot/kimi-k2-0905-preview"); + assertModelSelection(storePath); expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); }); }); diff --git a/src/auto-reply/reply.directive.directive-behavior.updates-tool-verbose-during-flight-run-toggle.test.ts b/src/auto-reply/reply.directive.directive-behavior.updates-tool-verbose-during-flight-run-toggle.test.ts index 8ed532b0d..dde11416c 100644 --- a/src/auto-reply/reply.directive.directive-behavior.updates-tool-verbose-during-flight-run-toggle.test.ts +++ b/src/auto-reply/reply.directive.directive-behavior.updates-tool-verbose-during-flight-run-toggle.test.ts @@ -187,7 +187,7 @@ describe("directive behavior", () => { expect(runEmbeddedPiAgent).toHaveBeenCalledOnce(); }); }); - it("lists allowlisted models on /model", async () => { + it("shows a model summary on /model", async () => { await withTempHome(async (home) => { vi.mocked(runEmbeddedPiAgent).mockReset(); const storePath = path.join(home, "sessions.json"); @@ -211,10 +211,9 @@ describe("directive behavior", () => { ); const text = Array.isArray(res) ? res[0]?.text : res?.text; - expect(text).toContain("anthropic/claude-opus-4-5"); - expect(text).toContain("Pick: /model <#> or /model "); - expect(text).toContain("openai/gpt-4.1-mini"); - expect(text).not.toContain("claude-sonnet-4-1"); + expect(text).toContain("Current: anthropic/claude-opus-4-5"); + expect(text).toContain("Browse: /models"); + expect(text).toContain("Switch: /model "); expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); }); }); diff --git a/src/auto-reply/reply.triggers.trigger-handling.shows-quick-model-picker-grouped-by-model.test.ts b/src/auto-reply/reply.triggers.trigger-handling.shows-quick-model-picker-grouped-by-model.test.ts index ae1ddd8bc..bb8052f83 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.shows-quick-model-picker-grouped-by-model.test.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.shows-quick-model-picker-grouped-by-model.test.ts @@ -95,7 +95,7 @@ afterEach(() => { }); describe("trigger handling", () => { - it("shows a quick /model picker listing provider/model pairs", async () => { + it("shows a /model summary with browse hints", async () => { await withTempHome(async (home) => { const cfg = makeCfg(home); const res = await getReplyFromConfig( @@ -115,23 +115,18 @@ describe("trigger handling", () => { const text = Array.isArray(res) ? res[0]?.text : res?.text; const normalized = normalizeTestText(text ?? ""); - expect(normalized).toContain("Pick: /model <#> or /model "); - // Each provider/model combo is listed separately for clear selection - expect(normalized).toContain("anthropic/claude-opus-4-5"); - expect(normalized).toContain("openrouter/anthropic/claude-opus-4-5"); - expect(normalized).toContain("openai/gpt-5.2"); - expect(normalized).toContain("openai-codex/gpt-5.2"); + expect(normalized).toContain("Current: anthropic/claude-opus-4-5"); + expect(normalized).toContain("Browse: /models"); + expect(normalized).toContain("Switch: /model "); expect(normalized).toContain("More: /model status"); - expect(normalized).not.toContain("reasoning"); - expect(normalized).not.toContain("image"); }); }); - it("orders provider/model pairs by provider preference", async () => { + it("lists providers on /models", async () => { await withTempHome(async (home) => { const cfg = makeCfg(home); const res = await getReplyFromConfig( { - Body: "/model", + Body: "/models", From: "telegram:111", To: "telegram:111", ChatType: "direct", @@ -146,54 +141,22 @@ describe("trigger handling", () => { const text = Array.isArray(res) ? res[0]?.text : res?.text; const normalized = normalizeTestText(text ?? ""); - const anthropicIndex = normalized.indexOf("anthropic/claude-opus-4-5"); - const openrouterIndex = normalized.indexOf("openrouter/anthropic/claude-opus-4-5"); - const openaiIndex = normalized.indexOf("openai/gpt-4.1-mini"); - const codexIndex = normalized.indexOf("openai-codex/gpt-5.2"); - expect(anthropicIndex).toBeGreaterThanOrEqual(0); - expect(openrouterIndex).toBeGreaterThanOrEqual(0); - expect(openaiIndex).toBeGreaterThanOrEqual(0); - expect(codexIndex).toBeGreaterThanOrEqual(0); - expect(anthropicIndex).toBeLessThan(openrouterIndex); - expect(openaiIndex).toBeLessThan(codexIndex); + expect(normalized).toContain("Providers:"); + expect(normalized).toContain("anthropic"); + expect(normalized).toContain("openrouter"); + expect(normalized).toContain("openai"); + expect(normalized).toContain("openai-codex"); + expect(normalized).toContain("minimax"); }); }); - it("selects the exact provider/model pair for openrouter by index", async () => { + it("selects the exact provider/model pair for openrouter", async () => { await withTempHome(async (home) => { const cfg = makeCfg(home); const sessionKey = "telegram:slash:111"; - const list = await getReplyFromConfig( - { - Body: "/model", - From: "telegram:111", - To: "telegram:111", - ChatType: "direct", - Provider: "telegram", - Surface: "telegram", - SessionKey: sessionKey, - CommandAuthorized: true, - }, - {}, - cfg, - ); - - const listText = Array.isArray(list) ? list[0]?.text : list?.text; - const lines = normalizeTestText(listText ?? "") - .split("\n") - .map((line) => line.trim()) - .filter(Boolean); - const targetLine = lines.find((line) => - line.includes("openrouter/anthropic/claude-opus-4-5"), - ); - expect(targetLine).toBeDefined(); - const match = targetLine?.match(/^(\d+)\)/); - expect(match?.[1]).toBeDefined(); - const index = Number.parseInt(match?.[1] ?? "", 10); - expect(Number.isFinite(index)).toBe(true); const res = await getReplyFromConfig( { - Body: `/model ${index}`, + Body: "/model openrouter/anthropic/claude-opus-4-5", From: "telegram:111", To: "telegram:111", ChatType: "direct", @@ -238,7 +201,7 @@ describe("trigger handling", () => { const text = Array.isArray(res) ? res[0]?.text : res?.text; expect(normalizeTestText(text ?? "")).toContain( - 'Invalid model selection "99". Use /model to list.', + "Numeric model selection is not supported in chat.", ); const store = loadSessionStore(cfg.session.store); @@ -246,15 +209,14 @@ describe("trigger handling", () => { expect(store[sessionKey]?.modelOverride).toBeUndefined(); }); }); - it("selects exact provider/model combo by index via /model <#>", async () => { + it("selects exact provider/model combo via /model ", async () => { await withTempHome(async (home) => { const cfg = makeCfg(home); const sessionKey = "telegram:slash:111"; - // /model 1 should select the first item (anthropic/claude-opus-4-5) const res = await getReplyFromConfig( { - Body: "/model 1", + Body: "/model anthropic/claude-opus-4-5", From: "telegram:111", To: "telegram:111", ChatType: "direct", @@ -268,8 +230,9 @@ describe("trigger handling", () => { ); const text = Array.isArray(res) ? res[0]?.text : res?.text; - // Selecting the default model shows "reset to default" instead of "set to" - expect(normalizeTestText(text ?? "")).toContain("anthropic/claude-opus-4-5"); + expect(normalizeTestText(text ?? "")).toContain( + "Model reset to default (anthropic/claude-opus-4-5).", + ); const store = loadSessionStore(cfg.session.store); // When selecting the default, overrides are cleared @@ -277,14 +240,14 @@ describe("trigger handling", () => { expect(store[sessionKey]?.modelOverride).toBeUndefined(); }); }); - it("selects a model by index via /model <#>", async () => { + it("selects a model via /model ", async () => { await withTempHome(async (home) => { const cfg = makeCfg(home); const sessionKey = "telegram:slash:111"; const res = await getReplyFromConfig( { - Body: "/model 3", + Body: "/model openai/gpt-5.2", From: "telegram:111", To: "telegram:111", ChatType: "direct", diff --git a/src/auto-reply/reply/commands-models.test.ts b/src/auto-reply/reply/commands-models.test.ts index d225db991..7abed5964 100644 --- a/src/auto-reply/reply/commands-models.test.ts +++ b/src/auto-reply/reply/commands-models.test.ts @@ -80,6 +80,49 @@ describe("/models command", () => { expect(result.reply?.text).toContain("All: /models anthropic all"); }); + it("includes configured providers and defaults", async () => { + const configuredCfg = { + commands: { text: true }, + agents: { + defaults: { + model: { + primary: "synthetic/synth-1", + fallbacks: ["synthetic/synth-2"], + }, + imageModel: { + primary: "synthetic/synth-image", + fallbacks: ["synthetic/synth-image-2"], + }, + }, + }, + models: { + providers: { + synthetic: { + baseUrl: "https://example.com", + models: [ + { + id: "synth-3", + name: "Synth 3", + }, + ], + }, + }, + }, + } as unknown as ClawdbotConfig; + + const providersResult = await handleCommands(buildParams("/models", configuredCfg)); + expect(providersResult.shouldContinue).toBe(false); + expect(providersResult.reply?.text).toContain("synthetic"); + + const modelsResult = await handleCommands(buildParams("/models synthetic", configuredCfg)); + expect(modelsResult.shouldContinue).toBe(false); + expect(modelsResult.reply?.text).toContain("synthetic/synth-1"); + expect(modelsResult.reply?.text).toContain("synthetic/synth-2"); + expect(modelsResult.reply?.text).toContain("synthetic/synth-3"); + expect(modelsResult.reply?.text).toContain("synthetic/synth-image"); + expect(modelsResult.reply?.text).toContain("synthetic/synth-image-2"); + }); + it("errors on out-of-range pages", async () => { const params = buildParams("/models anthropic 4", cfg); const result = await handleCommands(params); diff --git a/src/auto-reply/reply/commands-models.ts b/src/auto-reply/reply/commands-models.ts index 3bb545746..9a3410f89 100644 --- a/src/auto-reply/reply/commands-models.ts +++ b/src/auto-reply/reply/commands-models.ts @@ -1,8 +1,10 @@ import { loadModelCatalog } from "../../agents/model-catalog.js"; import { buildAllowedModelSet, + buildModelAliasIndex, normalizeProviderId, resolveConfiguredModelRef, + resolveModelRefFromString, } from "../../agents/model-selection.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../../agents/defaults.js"; import type { ReplyPayload } from "../types.js"; @@ -15,6 +17,15 @@ function formatProviderLine(params: { provider: string; count: number }): string return `- ${params.provider} (${params.count})`; } +function addModelRef(map: Map>, provider: string, model: string): void { + const normalizedProvider = normalizeProviderId(provider); + const normalizedModel = String(model ?? "").trim(); + if (!normalizedProvider || !normalizedModel) return; + const set = map.get(normalizedProvider) ?? new Set(); + set.add(normalizedModel); + map.set(normalizedProvider, set); +} + function parseModelsArgs(raw: string): { provider?: string; page: number; @@ -90,27 +101,55 @@ export const handleModelsCommand: CommandHandler = async (params, allowTextComma }); const byProvider = new Map>(); - const add = (p: string, m: string) => { - const key = normalizeProviderId(p); - const set = byProvider.get(key) ?? new Set(); - set.add(m); - byProvider.set(key, set); + const aliasIndex = buildModelAliasIndex({ + cfg: params.cfg, + defaultProvider: resolvedDefault.provider, + }); + const addRaw = (raw?: string) => { + const value = String(raw ?? "").trim(); + if (!value) return; + const resolved = resolveModelRefFromString({ + raw: value, + defaultProvider: resolvedDefault.provider, + aliasIndex, + }); + if (!resolved) return; + addModelRef(byProvider, resolved.ref.provider, resolved.ref.model); }; for (const entry of allowed.allowedCatalog) { - add(entry.provider, entry.id); + addModelRef(byProvider, entry.provider, entry.id); + } + + addModelRef(byProvider, resolvedDefault.provider, resolvedDefault.model); + + const modelConfig = params.cfg.agents?.defaults?.model; + const modelFallbacks = + modelConfig && typeof modelConfig === "object" ? (modelConfig.fallbacks ?? []) : []; + for (const fallback of modelFallbacks) { + addRaw(String(fallback ?? "")); + } + + const imageConfig = params.cfg.agents?.defaults?.imageModel; + if (imageConfig) { + if (typeof imageConfig === "string") { + addRaw(imageConfig); + } else { + addRaw(imageConfig.primary); + for (const fallback of imageConfig.fallbacks ?? []) { + addRaw(String(fallback ?? "")); + } + } } - // Include config-only allowlist keys that aren't in the curated catalog. for (const raw of Object.keys(params.cfg.agents?.defaults?.models ?? {})) { - const rawKey = String(raw ?? "").trim(); - if (!rawKey) continue; - const slash = rawKey.indexOf("/"); - if (slash === -1) continue; - const p = normalizeProviderId(rawKey.slice(0, slash)); - const m = rawKey.slice(slash + 1).trim(); - if (!p || !m) continue; - add(p, m); + addRaw(String(raw ?? "")); + } + + for (const [provider, providerConfig] of Object.entries(params.cfg.models?.providers ?? {})) { + for (const modelDef of providerConfig?.models ?? []) { + addModelRef(byProvider, provider, modelDef?.id ?? ""); + } } const providers = [...byProvider.keys()].sort(); diff --git a/src/auto-reply/reply/directive-handling.model.ts b/src/auto-reply/reply/directive-handling.model.ts index 6cfef7828..0f5782a6f 100644 --- a/src/auto-reply/reply/directive-handling.model.ts +++ b/src/auto-reply/reply/directive-handling.model.ts @@ -16,7 +16,6 @@ import { resolveProfileOverride, } from "./directive-handling.auth.js"; import { - buildModelPickerItems, type ModelPickerCatalogEntry, resolveProviderEndpointLabel, } from "./directive-handling.model-picker.js"; diff --git a/src/auto-reply/reply/model-selection.ts b/src/auto-reply/reply/model-selection.ts index fe06c1c06..329661af4 100644 --- a/src/auto-reply/reply/model-selection.ts +++ b/src/auto-reply/reply/model-selection.ts @@ -54,9 +54,8 @@ function boundedLevenshteinDistance(a: string, b: string, maxDistance: number): if (Math.abs(aLen - bLen) > maxDistance) return null; // Standard DP with early exit. O(maxDistance * minLen) in common cases. - const prev = new Array(bLen + 1); - const curr = new Array(bLen + 1); - for (let j = 0; j <= bLen; j++) prev[j] = j; + const prev = Array.from({ length: bLen + 1 }, (_, index) => index); + const curr = Array.from({ length: bLen + 1 }, () => 0); for (let i = 1; i <= aLen; i++) { curr[0] = i; diff --git a/src/config/defaults.ts b/src/config/defaults.ts index c88c1625f..dcad096fb 100644 --- a/src/config/defaults.ts +++ b/src/config/defaults.ts @@ -205,7 +205,7 @@ export function applyContextPruningDefaults(cfg: ClawdbotConfig): ClawdbotConfig if (defaults.contextPruning?.mode === undefined) { nextDefaults.contextPruning = { - ...(defaults.contextPruning ?? {}), + ...defaults.contextPruning, mode: "cache-ttl", ttl: defaults.contextPruning?.ttl ?? "1h", }; @@ -214,7 +214,7 @@ export function applyContextPruningDefaults(cfg: ClawdbotConfig): ClawdbotConfig if (defaults.heartbeat?.every === undefined) { nextDefaults.heartbeat = { - ...(defaults.heartbeat ?? {}), + ...defaults.heartbeat, every: authMode === "oauth" ? "1h" : "30m", }; mutated = true; diff --git a/src/config/legacy-migrate.ts b/src/config/legacy-migrate.ts index 54e9ffd72..4b80f2ddb 100644 --- a/src/config/legacy-migrate.ts +++ b/src/config/legacy-migrate.ts @@ -7,7 +7,11 @@ export function migrateLegacyConfig(raw: unknown): { changes: string[]; } { const { next, changes } = applyLegacyMigrations(raw); - if (!next) return { config: null, changes: [] }; + if (!next) { + const validated = validateConfigObjectWithPlugins(raw); + if (!validated.ok) return { config: null, changes: [] }; + return { config: validated.config, changes: [] }; + } const validated = validateConfigObjectWithPlugins(next); if (!validated.ok) { changes.push("Migration applied, but config still invalid; fix remaining issues manually."); diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index 012fff229..63e58defb 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -89,7 +89,7 @@ function resolveActiveHoursTimezone(cfg: ClawdbotConfig, raw?: string): string { } } -function parseActiveHoursTime(raw?: string, opts: { allow24: boolean }): number | null { +function parseActiveHoursTime(raw: string | undefined, opts: { allow24: boolean }): number | null { if (!raw || !ACTIVE_HOURS_TIME_PATTERN.test(raw)) return null; const [hourStr, minuteStr] = raw.split(":"); const hour = Number(hourStr);