@gguf/claw
Version:
Multi-channel AI gateway with extensible messaging integrations
1,354 lines (1,339 loc) • 220 kB
JavaScript
import { t as __exportAll } from "./rolldown-runtime-Cbj13DAv.js";
import { a as resolveCanonicalConfigPath, c as resolveDefaultConfigCandidates, d as resolveIsNixMode, f as resolveLegacyStateDirs, g as resolveStateDir, h as resolveOAuthPath, i as isNixMode, l as resolveGatewayLockDir, m as resolveOAuthDir, n as DEFAULT_GATEWAY_PORT, o as resolveConfigPath, p as resolveNewStateDir, r as STATE_DIR, s as resolveConfigPathCandidate, t as CONFIG_PATH, u as resolveGatewayPort, y as resolveRequiredHomeDir } from "./paths-B4BZAPZh.js";
import { D as isPlainObject$1, d as isRecord, g as resolveConfigDir, y as resolveUserPath } from "./utils-CP9YLh6M.js";
import { l as normalizeAgentId, n as DEFAULT_AGENT_ID } from "./session-key-CZ6OwgSB.js";
import { t as CHANNEL_IDS, u as normalizeChatChannelId } from "./registry-B-j4DRfe.js";
import { c as resolveAgentWorkspaceDir, l as resolveDefaultAgentId } from "./agent-scope-BnZW9Gh2.js";
import { $ as resolveShellEnvFallbackTimeoutMs, Q as loadShellEnvFallback, Ut as DEFAULT_CONTEXT_TOKENS, et as shouldDeferShellEnvFallback, tt as shouldEnableShellEnvFallback, u as parseModelRef } from "./model-selection-CqaTAlhy.js";
import { i as isPathInside } from "./legacy-names-DyDKpCnk.js";
import { d as resolveEnableState, f as resolveMemorySlotDecision, n as loadPluginManifestRegistry, u as normalizePluginsConfig } from "./manifest-registry-4k4vkhPS.js";
import { createRequire } from "node:module";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import JSON5 from "json5";
import { isDeepStrictEqual } from "node:util";
import crypto from "node:crypto";
import dotenv from "dotenv";
import AjvPkg from "ajv";
import { z } from "zod";
//#region src/infra/dotenv.ts
function loadDotEnv(opts) {
const quiet = opts?.quiet ?? true;
dotenv.config({ quiet });
const globalEnvPath = path.join(resolveConfigDir(process.env), ".env");
if (!fs.existsSync(globalEnvPath)) return;
dotenv.config({
quiet,
path: globalEnvPath,
override: false
});
}
//#endregion
//#region src/version.ts
const CORE_PACKAGE_NAME = "openclaw";
const PACKAGE_JSON_CANDIDATES = [
"../package.json",
"../../package.json",
"../../../package.json",
"./package.json"
];
const BUILD_INFO_CANDIDATES = [
"../build-info.json",
"../../build-info.json",
"./build-info.json"
];
function readVersionFromJsonCandidates(moduleUrl, candidates, opts = {}) {
try {
const require = createRequire(moduleUrl);
for (const candidate of candidates) try {
const parsed = require(candidate);
const version = parsed.version?.trim();
if (!version) continue;
if (opts.requirePackageName && parsed.name !== CORE_PACKAGE_NAME) continue;
return version;
} catch {}
return null;
} catch {
return null;
}
}
function firstNonEmpty(...values) {
for (const value of values) {
const trimmed = value?.trim();
if (trimmed) return trimmed;
}
}
function readVersionFromPackageJsonForModuleUrl(moduleUrl) {
return readVersionFromJsonCandidates(moduleUrl, PACKAGE_JSON_CANDIDATES, { requirePackageName: true });
}
function readVersionFromBuildInfoForModuleUrl(moduleUrl) {
return readVersionFromJsonCandidates(moduleUrl, BUILD_INFO_CANDIDATES);
}
function resolveVersionFromModuleUrl(moduleUrl) {
return readVersionFromPackageJsonForModuleUrl(moduleUrl) || readVersionFromBuildInfoForModuleUrl(moduleUrl);
}
function resolveRuntimeServiceVersion(env = process.env, fallback = "dev") {
return firstNonEmpty(env["OPENCLAW_VERSION"], env["OPENCLAW_SERVICE_VERSION"], env["npm_package_version"]) ?? fallback;
}
const VERSION = typeof __OPENCLAW_VERSION__ === "string" && __OPENCLAW_VERSION__ || process.env.OPENCLAW_BUNDLED_VERSION || resolveVersionFromModuleUrl(import.meta.url) || "0.0.0";
//#endregion
//#region src/config/agent-dirs.ts
var DuplicateAgentDirError = class extends Error {
constructor(duplicates) {
super(formatDuplicateAgentDirError(duplicates));
this.name = "DuplicateAgentDirError";
this.duplicates = duplicates;
}
};
function canonicalizeAgentDir(agentDir) {
const resolved = path.resolve(agentDir);
if (process.platform === "darwin" || process.platform === "win32") return resolved.toLowerCase();
return resolved;
}
function collectReferencedAgentIds(cfg) {
const ids = /* @__PURE__ */ new Set();
const agents = Array.isArray(cfg.agents?.list) ? cfg.agents?.list : [];
const defaultAgentId = agents.find((agent) => agent?.default)?.id ?? agents[0]?.id ?? DEFAULT_AGENT_ID;
ids.add(normalizeAgentId(defaultAgentId));
for (const entry of agents) if (entry?.id) ids.add(normalizeAgentId(entry.id));
const bindings = cfg.bindings;
if (Array.isArray(bindings)) for (const binding of bindings) {
const id = binding?.agentId;
if (typeof id === "string" && id.trim()) ids.add(normalizeAgentId(id));
}
return [...ids];
}
function resolveEffectiveAgentDir(cfg, agentId, deps) {
const id = normalizeAgentId(agentId);
const trimmed = (Array.isArray(cfg.agents?.list) ? cfg.agents?.list.find((agent) => normalizeAgentId(agent.id) === id)?.agentDir : void 0)?.trim();
if (trimmed) return resolveUserPath(trimmed);
const env = deps?.env ?? process.env;
const root = resolveStateDir(env, deps?.homedir ?? (() => resolveRequiredHomeDir(env, os.homedir)));
return path.join(root, "agents", id, "agent");
}
function findDuplicateAgentDirs(cfg, deps) {
const byDir = /* @__PURE__ */ new Map();
for (const agentId of collectReferencedAgentIds(cfg)) {
const agentDir = resolveEffectiveAgentDir(cfg, agentId, deps);
const key = canonicalizeAgentDir(agentDir);
const entry = byDir.get(key);
if (entry) entry.agentIds.push(agentId);
else byDir.set(key, {
agentDir,
agentIds: [agentId]
});
}
return [...byDir.values()].filter((v) => v.agentIds.length > 1);
}
function formatDuplicateAgentDirError(dups) {
return [
"Duplicate agentDir detected (multi-agent config).",
"Each agent must have a unique agentDir; sharing it causes auth/session state collisions and token invalidation.",
"",
"Conflicts:",
...dups.map((d) => `- ${d.agentDir}: ${d.agentIds.map((id) => `"${id}"`).join(", ")}`),
"",
"Fix: remove the shared agents.list[].agentDir override (or give each agent its own directory).",
"If you want to share credentials, copy auth-profiles.json instead of sharing the entire agentDir."
].join("\n");
}
//#endregion
//#region src/config/backup-rotation.ts
const CONFIG_BACKUP_COUNT = 5;
async function rotateConfigBackups(configPath, ioFs) {
if (CONFIG_BACKUP_COUNT <= 1) return;
const backupBase = `${configPath}.bak`;
const maxIndex = CONFIG_BACKUP_COUNT - 1;
await ioFs.unlink(`${backupBase}.${maxIndex}`).catch(() => {});
for (let index = maxIndex - 1; index >= 1; index -= 1) await ioFs.rename(`${backupBase}.${index}`, `${backupBase}.${index + 1}`).catch(() => {});
await ioFs.rename(backupBase, `${backupBase}.1`).catch(() => {});
}
//#endregion
//#region src/config/agent-limits.ts
const DEFAULT_AGENT_MAX_CONCURRENT = 4;
const DEFAULT_SUBAGENT_MAX_CONCURRENT = 8;
function resolveAgentMaxConcurrent(cfg) {
const raw = cfg?.agents?.defaults?.maxConcurrent;
if (typeof raw === "number" && Number.isFinite(raw)) return Math.max(1, Math.floor(raw));
return DEFAULT_AGENT_MAX_CONCURRENT;
}
function resolveSubagentMaxConcurrent(cfg) {
const raw = cfg?.agents?.defaults?.subagents?.maxConcurrent;
if (typeof raw === "number" && Number.isFinite(raw)) return Math.max(1, Math.floor(raw));
return DEFAULT_SUBAGENT_MAX_CONCURRENT;
}
//#endregion
//#region src/config/talk.ts
function readTalkApiKeyFromProfile(deps = {}) {
const fsImpl = deps.fs ?? fs;
const osImpl = deps.os ?? os;
const pathImpl = deps.path ?? path;
const home = osImpl.homedir();
const candidates = [
".profile",
".zprofile",
".zshrc",
".bashrc"
].map((name) => pathImpl.join(home, name));
for (const candidate of candidates) {
if (!fsImpl.existsSync(candidate)) continue;
try {
const value = fsImpl.readFileSync(candidate, "utf-8").match(/(?:^|\n)\s*(?:export\s+)?ELEVENLABS_API_KEY\s*=\s*["']?([^\n"']+)["']?/)?.[1]?.trim();
if (value) return value;
} catch {}
}
return null;
}
function resolveTalkApiKey(env = process.env, deps = {}) {
const envValue = (env.ELEVENLABS_API_KEY ?? "").trim();
if (envValue) return envValue;
return readTalkApiKeyFromProfile(deps);
}
//#endregion
//#region src/config/defaults.ts
let defaultWarnState = { warned: false };
const DEFAULT_MODEL_ALIASES = {
opus: "anthropic/claude-opus-4-6",
sonnet: "anthropic/claude-sonnet-4-6",
gpt: "openai/gpt-5.2",
"gpt-mini": "openai/gpt-5-mini",
gemini: "google/gemini-3-pro-preview",
"gemini-flash": "google/gemini-3-flash-preview"
};
const DEFAULT_MODEL_COST = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0
};
const DEFAULT_MODEL_INPUT = ["text"];
const DEFAULT_MODEL_MAX_TOKENS = 8192;
function isPositiveNumber(value) {
return typeof value === "number" && Number.isFinite(value) && value > 0;
}
function resolveModelCost(raw) {
return {
input: typeof raw?.input === "number" ? raw.input : DEFAULT_MODEL_COST.input,
output: typeof raw?.output === "number" ? raw.output : DEFAULT_MODEL_COST.output,
cacheRead: typeof raw?.cacheRead === "number" ? raw.cacheRead : DEFAULT_MODEL_COST.cacheRead,
cacheWrite: typeof raw?.cacheWrite === "number" ? raw.cacheWrite : DEFAULT_MODEL_COST.cacheWrite
};
}
function resolveAnthropicDefaultAuthMode(cfg) {
const profiles = cfg.auth?.profiles ?? {};
const anthropicProfiles = Object.entries(profiles).filter(([, profile]) => profile?.provider === "anthropic");
const order = cfg.auth?.order?.anthropic ?? [];
for (const profileId of order) {
const entry = profiles[profileId];
if (!entry || entry.provider !== "anthropic") continue;
if (entry.mode === "api_key") return "api_key";
if (entry.mode === "oauth" || entry.mode === "token") return "oauth";
}
const hasApiKey = anthropicProfiles.some(([, profile]) => profile?.mode === "api_key");
const hasOauth = anthropicProfiles.some(([, profile]) => profile?.mode === "oauth" || profile?.mode === "token");
if (hasApiKey && !hasOauth) return "api_key";
if (hasOauth && !hasApiKey) return "oauth";
if (process.env.ANTHROPIC_OAUTH_TOKEN?.trim()) return "oauth";
if (process.env.ANTHROPIC_API_KEY?.trim()) return "api_key";
return null;
}
function resolvePrimaryModelRef(raw) {
if (!raw || typeof raw !== "string") return null;
const trimmed = raw.trim();
if (!trimmed) return null;
return DEFAULT_MODEL_ALIASES[trimmed.toLowerCase()] ?? trimmed;
}
function applyMessageDefaults(cfg) {
const messages = cfg.messages;
if (messages?.ackReactionScope !== void 0) return cfg;
const nextMessages = messages ? { ...messages } : {};
nextMessages.ackReactionScope = "group-mentions";
return {
...cfg,
messages: nextMessages
};
}
function applySessionDefaults(cfg, options = {}) {
const session = cfg.session;
if (!session || session.mainKey === void 0) return cfg;
const trimmed = session.mainKey.trim();
const warn = options.warn ?? console.warn;
const warnState = options.warnState ?? defaultWarnState;
const next = {
...cfg,
session: {
...session,
mainKey: "main"
}
};
if (trimmed && trimmed !== "main" && !warnState.warned) {
warnState.warned = true;
warn("session.mainKey is ignored; main session is always \"main\".");
}
return next;
}
function applyTalkApiKey(config) {
const resolved = resolveTalkApiKey();
if (!resolved) return config;
if (config.talk?.apiKey?.trim()) return config;
return {
...config,
talk: {
...config.talk,
apiKey: resolved
}
};
}
function applyModelDefaults(cfg) {
let mutated = false;
let nextCfg = cfg;
const providerConfig = nextCfg.models?.providers;
if (providerConfig) {
const nextProviders = { ...providerConfig };
for (const [providerId, provider] of Object.entries(providerConfig)) {
const models = provider.models;
if (!Array.isArray(models) || models.length === 0) continue;
let providerMutated = false;
const nextModels = models.map((model) => {
const raw = model;
let modelMutated = false;
const reasoning = typeof raw.reasoning === "boolean" ? raw.reasoning : false;
if (raw.reasoning !== reasoning) modelMutated = true;
const input = raw.input ?? [...DEFAULT_MODEL_INPUT];
if (raw.input === void 0) modelMutated = true;
const cost = resolveModelCost(raw.cost);
if (!raw.cost || raw.cost.input !== cost.input || raw.cost.output !== cost.output || raw.cost.cacheRead !== cost.cacheRead || raw.cost.cacheWrite !== cost.cacheWrite) modelMutated = true;
const contextWindow = isPositiveNumber(raw.contextWindow) ? raw.contextWindow : DEFAULT_CONTEXT_TOKENS;
if (raw.contextWindow !== contextWindow) modelMutated = true;
const defaultMaxTokens = Math.min(DEFAULT_MODEL_MAX_TOKENS, contextWindow);
const rawMaxTokens = isPositiveNumber(raw.maxTokens) ? raw.maxTokens : defaultMaxTokens;
const maxTokens = Math.min(rawMaxTokens, contextWindow);
if (raw.maxTokens !== maxTokens) modelMutated = true;
if (!modelMutated) return model;
providerMutated = true;
return {
...raw,
reasoning,
input,
cost,
contextWindow,
maxTokens
};
});
if (!providerMutated) continue;
nextProviders[providerId] = {
...provider,
models: nextModels
};
mutated = true;
}
if (mutated) nextCfg = {
...nextCfg,
models: {
...nextCfg.models,
providers: nextProviders
}
};
}
const existingAgent = nextCfg.agents?.defaults;
if (!existingAgent) return mutated ? nextCfg : cfg;
const existingModels = existingAgent.models ?? {};
if (Object.keys(existingModels).length === 0) return mutated ? nextCfg : cfg;
const nextModels = { ...existingModels };
for (const [alias, target] of Object.entries(DEFAULT_MODEL_ALIASES)) {
const entry = nextModels[target];
if (!entry) continue;
if (entry.alias !== void 0) continue;
nextModels[target] = {
...entry,
alias
};
mutated = true;
}
if (!mutated) return cfg;
return {
...nextCfg,
agents: {
...nextCfg.agents,
defaults: {
...existingAgent,
models: nextModels
}
}
};
}
function applyAgentDefaults(cfg) {
const agents = cfg.agents;
const defaults = agents?.defaults;
const hasMax = typeof defaults?.maxConcurrent === "number" && Number.isFinite(defaults.maxConcurrent);
const hasSubMax = typeof defaults?.subagents?.maxConcurrent === "number" && Number.isFinite(defaults.subagents.maxConcurrent);
if (hasMax && hasSubMax) return cfg;
let mutated = false;
const nextDefaults = defaults ? { ...defaults } : {};
if (!hasMax) {
nextDefaults.maxConcurrent = DEFAULT_AGENT_MAX_CONCURRENT;
mutated = true;
}
const nextSubagents = defaults?.subagents ? { ...defaults.subagents } : {};
if (!hasSubMax) {
nextSubagents.maxConcurrent = DEFAULT_SUBAGENT_MAX_CONCURRENT;
mutated = true;
}
if (!mutated) return cfg;
return {
...cfg,
agents: {
...agents,
defaults: {
...nextDefaults,
subagents: nextSubagents
}
}
};
}
function applyLoggingDefaults(cfg) {
const logging = cfg.logging;
if (!logging) return cfg;
if (logging.redactSensitive) return cfg;
return {
...cfg,
logging: {
...logging,
redactSensitive: "tools"
}
};
}
function applyContextPruningDefaults(cfg) {
const defaults = cfg.agents?.defaults;
if (!defaults) return cfg;
const authMode = resolveAnthropicDefaultAuthMode(cfg);
if (!authMode) return cfg;
let mutated = false;
const nextDefaults = { ...defaults };
const contextPruning = defaults.contextPruning ?? {};
const heartbeat = defaults.heartbeat ?? {};
if (defaults.contextPruning?.mode === void 0) {
nextDefaults.contextPruning = {
...contextPruning,
mode: "cache-ttl",
ttl: defaults.contextPruning?.ttl ?? "1h"
};
mutated = true;
}
if (defaults.heartbeat?.every === void 0) {
nextDefaults.heartbeat = {
...heartbeat,
every: authMode === "oauth" ? "1h" : "30m"
};
mutated = true;
}
if (authMode === "api_key") {
const nextModels = defaults.models ? { ...defaults.models } : {};
let modelsMutated = false;
for (const [key, entry] of Object.entries(nextModels)) {
const parsed = parseModelRef(key, "anthropic");
if (!parsed || parsed.provider !== "anthropic") continue;
const current = entry ?? {};
const params = current.params ?? {};
if (typeof params.cacheRetention === "string") continue;
nextModels[key] = {
...current,
params: {
...params,
cacheRetention: "short"
}
};
modelsMutated = true;
}
const primary = resolvePrimaryModelRef(defaults.model?.primary ?? void 0);
if (primary) {
const parsedPrimary = parseModelRef(primary, "anthropic");
if (parsedPrimary?.provider === "anthropic") {
const key = `${parsedPrimary.provider}/${parsedPrimary.model}`;
const current = nextModels[key] ?? {};
const params = current.params ?? {};
if (typeof params.cacheRetention !== "string") {
nextModels[key] = {
...current,
params: {
...params,
cacheRetention: "short"
}
};
modelsMutated = true;
}
}
}
if (modelsMutated) {
nextDefaults.models = nextModels;
mutated = true;
}
}
if (!mutated) return cfg;
return {
...cfg,
agents: {
...cfg.agents,
defaults: nextDefaults
}
};
}
function applyCompactionDefaults(cfg) {
const defaults = cfg.agents?.defaults;
if (!defaults) return cfg;
const compaction = defaults?.compaction;
if (compaction?.mode) return cfg;
return {
...cfg,
agents: {
...cfg.agents,
defaults: {
...defaults,
compaction: {
...compaction,
mode: "safeguard"
}
}
}
};
}
//#endregion
//#region src/config/env-preserve.ts
/**
* Preserves `${VAR}` environment variable references during config write-back.
*
* When config is read, `${VAR}` references are resolved to their values.
* When writing back, callers pass the resolved config. This module detects
* values that match what a `${VAR}` reference would resolve to and restores
* the original reference, so env var references survive config round-trips.
*
* A value is restored only if:
* 1. The pre-substitution value contained a `${VAR}` pattern
* 2. Resolving that pattern with current env vars produces the incoming value
*
* If a caller intentionally set a new value (different from what the env var
* resolves to), the new value is kept as-is.
*/
const ENV_VAR_PATTERN = /\$\{[A-Z_][A-Z0-9_]*\}/;
/**
* Check if a string contains any `${VAR}` env var references.
*/
function hasEnvVarRef(value) {
return ENV_VAR_PATTERN.test(value);
}
/**
* Resolve `${VAR}` references in a single string using the given env.
* Returns null if any referenced var is missing (instead of throwing).
*
* Mirrors the substitution semantics of `substituteString` in env-substitution.ts:
* - `${VAR}` → env value (returns null if missing)
* - `$${VAR}` → literal `${VAR}` (escape sequence)
*/
function tryResolveString(template, env) {
const ENV_VAR_NAME = /^[A-Z_][A-Z0-9_]*$/;
const chunks = [];
for (let i = 0; i < template.length; i++) {
if (template[i] === "$") {
if (template[i + 1] === "$" && template[i + 2] === "{") {
const start = i + 3;
const end = template.indexOf("}", start);
if (end !== -1) {
const name = template.slice(start, end);
if (ENV_VAR_NAME.test(name)) {
chunks.push(`\${${name}}`);
i = end;
continue;
}
}
}
if (template[i + 1] === "{") {
const start = i + 2;
const end = template.indexOf("}", start);
if (end !== -1) {
const name = template.slice(start, end);
if (ENV_VAR_NAME.test(name)) {
const val = env[name];
if (val === void 0 || val === "") return null;
chunks.push(val);
i = end;
continue;
}
}
}
}
chunks.push(template[i]);
}
return chunks.join("");
}
/**
* Deep-walk the incoming config and restore `${VAR}` references from the
* pre-substitution parsed config wherever the resolved value matches.
*
* @param incoming - The resolved config about to be written
* @param parsed - The pre-substitution parsed config (from the current file on disk)
* @param env - Environment variables for verification
* @returns A new config object with env var references restored where appropriate
*/
function restoreEnvVarRefs(incoming, parsed, env = process.env) {
if (parsed === null || parsed === void 0) return incoming;
if (typeof incoming === "string" && typeof parsed === "string") {
if (hasEnvVarRef(parsed)) {
if (tryResolveString(parsed, env) === incoming) return parsed;
}
return incoming;
}
if (Array.isArray(incoming) && Array.isArray(parsed)) return incoming.map((item, i) => i < parsed.length ? restoreEnvVarRefs(item, parsed[i], env) : item);
if (isPlainObject$1(incoming) && isPlainObject$1(parsed)) {
const result = {};
for (const [key, value] of Object.entries(incoming)) if (key in parsed) result[key] = restoreEnvVarRefs(value, parsed[key], env);
else result[key] = value;
return result;
}
return incoming;
}
//#endregion
//#region src/config/env-substitution.ts
/**
* Environment variable substitution for config values.
*
* Supports `${VAR_NAME}` syntax in string values, substituted at config load time.
* - Only uppercase env vars are matched: `[A-Z_][A-Z0-9_]*`
* - Escape with `$${}` to output literal `${}`
* - Missing env vars throw `MissingEnvVarError` with context
*
* @example
* ```json5
* {
* models: {
* providers: {
* "vercel-gateway": {
* apiKey: "${VERCEL_GATEWAY_API_KEY}"
* }
* }
* }
* }
* ```
*/
const ENV_VAR_NAME_PATTERN = /^[A-Z_][A-Z0-9_]*$/;
var MissingEnvVarError = class extends Error {
constructor(varName, configPath) {
super(`Missing env var "${varName}" referenced at config path: ${configPath}`);
this.varName = varName;
this.configPath = configPath;
this.name = "MissingEnvVarError";
}
};
function parseEnvTokenAt(value, index) {
if (value[index] !== "$") return null;
const next = value[index + 1];
const afterNext = value[index + 2];
if (next === "$" && afterNext === "{") {
const start = index + 3;
const end = value.indexOf("}", start);
if (end !== -1) {
const name = value.slice(start, end);
if (ENV_VAR_NAME_PATTERN.test(name)) return {
kind: "escaped",
name,
end
};
}
}
if (next === "{") {
const start = index + 2;
const end = value.indexOf("}", start);
if (end !== -1) {
const name = value.slice(start, end);
if (ENV_VAR_NAME_PATTERN.test(name)) return {
kind: "substitution",
name,
end
};
}
}
return null;
}
function substituteString(value, env, configPath) {
if (!value.includes("$")) return value;
const chunks = [];
for (let i = 0; i < value.length; i += 1) {
const char = value[i];
if (char !== "$") {
chunks.push(char);
continue;
}
const token = parseEnvTokenAt(value, i);
if (token?.kind === "escaped") {
chunks.push(`\${${token.name}}`);
i = token.end;
continue;
}
if (token?.kind === "substitution") {
const envValue = env[token.name];
if (envValue === void 0 || envValue === "") throw new MissingEnvVarError(token.name, configPath);
chunks.push(envValue);
i = token.end;
continue;
}
chunks.push(char);
}
return chunks.join("");
}
function containsEnvVarReference(value) {
if (!value.includes("$")) return false;
for (let i = 0; i < value.length; i += 1) {
if (value[i] !== "$") continue;
const token = parseEnvTokenAt(value, i);
if (token?.kind === "escaped") {
i = token.end;
continue;
}
if (token?.kind === "substitution") return true;
}
return false;
}
function substituteAny(value, env, path) {
if (typeof value === "string") return substituteString(value, env, path);
if (Array.isArray(value)) return value.map((item, index) => substituteAny(item, env, `${path}[${index}]`));
if (isPlainObject$1(value)) {
const result = {};
for (const [key, val] of Object.entries(value)) result[key] = substituteAny(val, env, path ? `${path}.${key}` : key);
return result;
}
return value;
}
/**
* Resolves `${VAR_NAME}` environment variable references in config values.
*
* @param obj - The parsed config object (after JSON5 parse and $include resolution)
* @param env - Environment variables to use for substitution (defaults to process.env)
* @returns The config object with env vars substituted
* @throws {MissingEnvVarError} If a referenced env var is not set or empty
*/
function resolveConfigEnvVars(obj, env = process.env) {
return substituteAny(obj, env, "");
}
//#endregion
//#region src/config/env-vars.ts
function collectConfigEnvVars(cfg) {
const envConfig = cfg?.env;
if (!envConfig) return {};
const entries = {};
if (envConfig.vars) for (const [key, value] of Object.entries(envConfig.vars)) {
if (!value) continue;
entries[key] = value;
}
for (const [key, value] of Object.entries(envConfig)) {
if (key === "shellEnv" || key === "vars") continue;
if (typeof value !== "string" || !value.trim()) continue;
entries[key] = value;
}
return entries;
}
function applyConfigEnvVars(cfg, env = process.env) {
const entries = collectConfigEnvVars(cfg);
for (const [key, value] of Object.entries(entries)) {
if (env[key]?.trim()) continue;
env[key] = value;
}
}
//#endregion
//#region src/config/includes.ts
/**
* Config includes: $include directive for modular configs
*
* @example
* ```json5
* {
* "$include": "./base.json5", // single file
* "$include": ["./a.json5", "./b.json5"] // merge multiple
* }
* ```
*/
const INCLUDE_KEY = "$include";
const MAX_INCLUDE_DEPTH = 10;
var ConfigIncludeError = class extends Error {
constructor(message, includePath, cause) {
super(message);
this.includePath = includePath;
this.cause = cause;
this.name = "ConfigIncludeError";
}
};
var CircularIncludeError = class extends ConfigIncludeError {
constructor(chain) {
super(`Circular include detected: ${chain.join(" -> ")}`, chain[chain.length - 1]);
this.chain = chain;
this.name = "CircularIncludeError";
}
};
const BLOCKED_MERGE_KEYS = new Set([
"__proto__",
"prototype",
"constructor"
]);
/** Deep merge: arrays concatenate, objects merge recursively, primitives: source wins */
function deepMerge(target, source) {
if (Array.isArray(target) && Array.isArray(source)) return [...target, ...source];
if (isPlainObject$1(target) && isPlainObject$1(source)) {
const result = { ...target };
for (const key of Object.keys(source)) {
if (BLOCKED_MERGE_KEYS.has(key)) continue;
result[key] = key in result ? deepMerge(result[key], source[key]) : source[key];
}
return result;
}
return source;
}
var IncludeProcessor = class IncludeProcessor {
constructor(basePath, resolver, rootDir) {
this.basePath = basePath;
this.resolver = resolver;
this.visited = /* @__PURE__ */ new Set();
this.depth = 0;
this.visited.add(path.normalize(basePath));
this.rootDir = path.normalize(rootDir ?? path.dirname(basePath));
this.rootRealDir = path.normalize(safeRealpath(this.rootDir));
}
process(obj) {
if (Array.isArray(obj)) return obj.map((item) => this.process(item));
if (!isPlainObject$1(obj)) return obj;
if (!(INCLUDE_KEY in obj)) return this.processObject(obj);
return this.processInclude(obj);
}
processObject(obj) {
const result = {};
for (const [key, value] of Object.entries(obj)) result[key] = this.process(value);
return result;
}
processInclude(obj) {
const includeValue = obj[INCLUDE_KEY];
const otherKeys = Object.keys(obj).filter((k) => k !== INCLUDE_KEY);
const included = this.resolveInclude(includeValue);
if (otherKeys.length === 0) return included;
if (!isPlainObject$1(included)) throw new ConfigIncludeError("Sibling keys require included content to be an object", typeof includeValue === "string" ? includeValue : INCLUDE_KEY);
const rest = {};
for (const key of otherKeys) rest[key] = this.process(obj[key]);
return deepMerge(included, rest);
}
resolveInclude(value) {
if (typeof value === "string") return this.loadFile(value);
if (Array.isArray(value)) return value.reduce((merged, item) => {
if (typeof item !== "string") throw new ConfigIncludeError(`Invalid $include array item: expected string, got ${typeof item}`, String(item));
return deepMerge(merged, this.loadFile(item));
}, {});
throw new ConfigIncludeError(`Invalid $include value: expected string or array of strings, got ${typeof value}`, String(value));
}
loadFile(includePath) {
const resolvedPath = this.resolvePath(includePath);
this.checkCircular(resolvedPath);
this.checkDepth(includePath);
const raw = this.readFile(includePath, resolvedPath);
const parsed = this.parseFile(includePath, resolvedPath, raw);
return this.processNested(resolvedPath, parsed);
}
resolvePath(includePath) {
const configDir = path.dirname(this.basePath);
const resolved = path.isAbsolute(includePath) ? includePath : path.resolve(configDir, includePath);
const normalized = path.normalize(resolved);
if (!isPathInside(this.rootDir, normalized)) throw new ConfigIncludeError(`Include path escapes config directory: ${includePath} (root: ${this.rootDir})`, includePath);
try {
const real = fs.realpathSync(normalized);
if (!isPathInside(this.rootRealDir, real)) throw new ConfigIncludeError(`Include path resolves outside config directory (symlink): ${includePath} (root: ${this.rootDir})`, includePath);
} catch (err) {
if (err instanceof ConfigIncludeError) throw err;
}
return normalized;
}
checkCircular(resolvedPath) {
if (this.visited.has(resolvedPath)) throw new CircularIncludeError([...this.visited, resolvedPath]);
}
checkDepth(includePath) {
if (this.depth >= MAX_INCLUDE_DEPTH) throw new ConfigIncludeError(`Maximum include depth (${MAX_INCLUDE_DEPTH}) exceeded at: ${includePath}`, includePath);
}
readFile(includePath, resolvedPath) {
try {
return this.resolver.readFile(resolvedPath);
} catch (err) {
throw new ConfigIncludeError(`Failed to read include file: ${includePath} (resolved: ${resolvedPath})`, includePath, err instanceof Error ? err : void 0);
}
}
parseFile(includePath, resolvedPath, raw) {
try {
return this.resolver.parseJson(raw);
} catch (err) {
throw new ConfigIncludeError(`Failed to parse include file: ${includePath} (resolved: ${resolvedPath})`, includePath, err instanceof Error ? err : void 0);
}
}
processNested(resolvedPath, parsed) {
const nested = new IncludeProcessor(resolvedPath, this.resolver, this.rootDir);
nested.visited = new Set([...this.visited, resolvedPath]);
nested.depth = this.depth + 1;
return nested.process(parsed);
}
};
function safeRealpath(target) {
try {
return fs.realpathSync(target);
} catch {
return target;
}
}
const defaultResolver = {
readFile: (p) => fs.readFileSync(p, "utf-8"),
parseJson: (raw) => JSON5.parse(raw)
};
/**
* Resolves all $include directives in a parsed config object.
*/
function resolveConfigIncludes(obj, configPath, resolver = defaultResolver) {
return new IncludeProcessor(configPath, resolver).process(obj);
}
//#endregion
//#region src/infra/exec-safety.ts
const SHELL_METACHARS = /[;&|`$<>]/;
const CONTROL_CHARS = /[\r\n]/;
const QUOTE_CHARS = /["']/;
const BARE_NAME_PATTERN = /^[A-Za-z0-9._+-]+$/;
function isLikelyPath(value) {
if (value.startsWith(".") || value.startsWith("~")) return true;
if (value.includes("/") || value.includes("\\")) return true;
return /^[A-Za-z]:[\\/]/.test(value);
}
function isSafeExecutableValue(value) {
if (!value) return false;
const trimmed = value.trim();
if (!trimmed) return false;
if (trimmed.includes("\0")) return false;
if (CONTROL_CHARS.test(trimmed)) return false;
if (SHELL_METACHARS.test(trimmed)) return false;
if (QUOTE_CHARS.test(trimmed)) return false;
if (isLikelyPath(trimmed)) return true;
if (trimmed.startsWith("-")) return false;
return BARE_NAME_PATTERN.test(trimmed);
}
//#endregion
//#region src/config/legacy.shared.ts
const getRecord = (value) => isRecord(value) ? value : null;
const ensureRecord = (root, key) => {
const existing = root[key];
if (isRecord(existing)) return existing;
const next = {};
root[key] = next;
return next;
};
const mergeMissing = (target, source) => {
for (const [key, value] of Object.entries(source)) {
if (value === void 0) continue;
const existing = target[key];
if (existing === void 0) {
target[key] = value;
continue;
}
if (isRecord(existing) && isRecord(value)) mergeMissing(existing, value);
}
};
const mapLegacyAudioTranscription = (value) => {
const transcriber = getRecord(value);
const command = Array.isArray(transcriber?.command) ? transcriber?.command : null;
if (!command || command.length === 0) return null;
if (typeof command[0] !== "string") return null;
if (!command.every((part) => typeof part === "string")) return null;
const rawExecutable = command[0].trim();
if (!rawExecutable) return null;
if (!isSafeExecutableValue(rawExecutable)) return null;
const args = command.slice(1);
const timeoutSeconds = typeof transcriber?.timeoutSeconds === "number" ? transcriber?.timeoutSeconds : void 0;
const result = {
command: rawExecutable,
type: "cli"
};
if (args.length > 0) result.args = args;
if (timeoutSeconds !== void 0) result.timeoutSeconds = timeoutSeconds;
return result;
};
const getAgentsList = (agents) => {
const list = agents?.list;
return Array.isArray(list) ? list : [];
};
const resolveDefaultAgentIdFromRaw = (raw) => {
const list = getAgentsList(getRecord(raw.agents));
const defaultEntry = list.find((entry) => isRecord(entry) && entry.default === true && typeof entry.id === "string" && entry.id.trim() !== "");
if (defaultEntry) return defaultEntry.id.trim();
const routing = getRecord(raw.routing);
const routingDefault = typeof routing?.defaultAgentId === "string" ? routing.defaultAgentId.trim() : "";
if (routingDefault) return routingDefault;
const firstEntry = list.find((entry) => isRecord(entry) && typeof entry.id === "string" && entry.id.trim() !== "");
if (firstEntry) return firstEntry.id.trim();
return "main";
};
const ensureAgentEntry = (list, id) => {
const normalized = id.trim();
const existing = list.find((entry) => isRecord(entry) && typeof entry.id === "string" && entry.id.trim() === normalized);
if (existing) return existing;
const created = { id: normalized };
list.push(created);
return created;
};
//#endregion
//#region src/config/legacy.migrations.part-1.ts
function migrateBindings(raw, changes, changeNote, mutator) {
const bindings = Array.isArray(raw.bindings) ? raw.bindings : null;
if (!bindings) return;
let touched = false;
for (const entry of bindings) {
if (!isRecord(entry)) continue;
const match = getRecord(entry.match);
if (!match) continue;
if (!mutator(match)) continue;
entry.match = match;
touched = true;
}
if (touched) {
raw.bindings = bindings;
changes.push(changeNote);
}
}
function ensureDefaultGroupEntry(section) {
const groups = isRecord(section.groups) ? section.groups : {};
const defaultKey = "*";
return {
groups,
entry: isRecord(groups[defaultKey]) ? groups[defaultKey] : {}
};
}
const LEGACY_CONFIG_MIGRATIONS_PART_1 = [
{
id: "bindings.match.provider->bindings.match.channel",
describe: "Move bindings[].match.provider to bindings[].match.channel",
apply: (raw, changes) => {
migrateBindings(raw, changes, "Moved bindings[].match.provider → bindings[].match.channel.", (match) => {
if (typeof match.channel === "string" && match.channel.trim()) return false;
const provider = typeof match.provider === "string" ? match.provider.trim() : "";
if (!provider) return false;
match.channel = provider;
delete match.provider;
return true;
});
}
},
{
id: "bindings.match.accountID->bindings.match.accountId",
describe: "Move bindings[].match.accountID to bindings[].match.accountId",
apply: (raw, changes) => {
migrateBindings(raw, changes, "Moved bindings[].match.accountID → bindings[].match.accountId.", (match) => {
if (match.accountId !== void 0) return false;
const accountID = typeof match.accountID === "string" ? match.accountID.trim() : match.accountID;
if (!accountID) return false;
match.accountId = accountID;
delete match.accountID;
return true;
});
}
},
{
id: "session.sendPolicy.rules.match.provider->match.channel",
describe: "Move session.sendPolicy.rules[].match.provider to match.channel",
apply: (raw, changes) => {
const session = getRecord(raw.session);
if (!session) return;
const sendPolicy = getRecord(session.sendPolicy);
if (!sendPolicy) return;
const rules = Array.isArray(sendPolicy.rules) ? sendPolicy.rules : null;
if (!rules) return;
let touched = false;
for (const rule of rules) {
if (!isRecord(rule)) continue;
const match = getRecord(rule.match);
if (!match) continue;
if (typeof match.channel === "string" && match.channel.trim()) continue;
const provider = typeof match.provider === "string" ? match.provider.trim() : "";
if (!provider) continue;
match.channel = provider;
delete match.provider;
rule.match = match;
touched = true;
}
if (touched) {
sendPolicy.rules = rules;
session.sendPolicy = sendPolicy;
raw.session = session;
changes.push("Moved session.sendPolicy.rules[].match.provider → match.channel.");
}
}
},
{
id: "messages.queue.byProvider->byChannel",
describe: "Move messages.queue.byProvider to messages.queue.byChannel",
apply: (raw, changes) => {
const messages = getRecord(raw.messages);
if (!messages) return;
const queue = getRecord(messages.queue);
if (!queue) return;
if (queue.byProvider === void 0) return;
if (queue.byChannel === void 0) {
queue.byChannel = queue.byProvider;
changes.push("Moved messages.queue.byProvider → messages.queue.byChannel.");
} else changes.push("Removed messages.queue.byProvider (messages.queue.byChannel already set).");
delete queue.byProvider;
messages.queue = queue;
raw.messages = messages;
}
},
{
id: "providers->channels",
describe: "Move provider config sections to channels.*",
apply: (raw, changes) => {
const legacyEntries = [
"whatsapp",
"telegram",
"discord",
"slack",
"signal",
"imessage",
"msteams"
].filter((key) => isRecord(raw[key]));
if (legacyEntries.length === 0) return;
const channels = ensureRecord(raw, "channels");
for (const key of legacyEntries) {
const legacy = getRecord(raw[key]);
if (!legacy) continue;
const channelEntry = ensureRecord(channels, key);
const hadEntries = Object.keys(channelEntry).length > 0;
mergeMissing(channelEntry, legacy);
channels[key] = channelEntry;
delete raw[key];
changes.push(hadEntries ? `Merged ${key} → channels.${key}.` : `Moved ${key} → channels.${key}.`);
}
raw.channels = channels;
}
},
{
id: "routing.allowFrom->channels.whatsapp.allowFrom",
describe: "Move routing.allowFrom to channels.whatsapp.allowFrom",
apply: (raw, changes) => {
const routing = raw.routing;
if (!routing || typeof routing !== "object") return;
const allowFrom = routing.allowFrom;
if (allowFrom === void 0) return;
const channels = getRecord(raw.channels);
const whatsapp = channels ? getRecord(channels.whatsapp) : null;
if (!whatsapp) {
delete routing.allowFrom;
if (Object.keys(routing).length === 0) delete raw.routing;
changes.push("Removed routing.allowFrom (channels.whatsapp not configured).");
return;
}
if (whatsapp.allowFrom === void 0) {
whatsapp.allowFrom = allowFrom;
changes.push("Moved routing.allowFrom → channels.whatsapp.allowFrom.");
} else changes.push("Removed routing.allowFrom (channels.whatsapp.allowFrom already set).");
delete routing.allowFrom;
if (Object.keys(routing).length === 0) delete raw.routing;
channels.whatsapp = whatsapp;
raw.channels = channels;
}
},
{
id: "routing.groupChat.requireMention->groups.*.requireMention",
describe: "Move routing.groupChat.requireMention to channels.whatsapp/telegram/imessage groups",
apply: (raw, changes) => {
const routing = raw.routing;
if (!routing || typeof routing !== "object") return;
const groupChat = routing.groupChat && typeof routing.groupChat === "object" ? routing.groupChat : null;
if (!groupChat) return;
const requireMention = groupChat.requireMention;
if (requireMention === void 0) return;
const channels = ensureRecord(raw, "channels");
const applyTo = (key, options) => {
if (options?.requireExisting && !isRecord(channels[key])) return;
const section = channels[key] && typeof channels[key] === "object" ? channels[key] : {};
const { groups, entry } = ensureDefaultGroupEntry(section);
const defaultKey = "*";
if (entry.requireMention === void 0) {
entry.requireMention = requireMention;
groups[defaultKey] = entry;
section.groups = groups;
channels[key] = section;
changes.push(`Moved routing.groupChat.requireMention → channels.${key}.groups."*".requireMention.`);
} else changes.push(`Removed routing.groupChat.requireMention (channels.${key}.groups."*" already set).`);
};
applyTo("whatsapp", { requireExisting: true });
applyTo("telegram");
applyTo("imessage");
delete groupChat.requireMention;
if (Object.keys(groupChat).length === 0) delete routing.groupChat;
if (Object.keys(routing).length === 0) delete raw.routing;
raw.channels = channels;
}
},
{
id: "gateway.token->gateway.auth.token",
describe: "Move gateway.token to gateway.auth.token",
apply: (raw, changes) => {
const gateway = raw.gateway;
if (!gateway || typeof gateway !== "object") return;
const token = gateway.token;
if (token === void 0) return;
const gatewayObj = gateway;
const auth = gatewayObj.auth && typeof gatewayObj.auth === "object" ? gatewayObj.auth : {};
if (auth.token === void 0) {
auth.token = token;
if (!auth.mode) auth.mode = "token";
changes.push("Moved gateway.token → gateway.auth.token.");
} else changes.push("Removed gateway.token (gateway.auth.token already set).");
delete gatewayObj.token;
if (Object.keys(auth).length > 0) gatewayObj.auth = auth;
raw.gateway = gatewayObj;
}
},
{
id: "telegram.requireMention->channels.telegram.groups.*.requireMention",
describe: "Move telegram.requireMention to channels.telegram.groups.*.requireMention",
apply: (raw, changes) => {
const channels = ensureRecord(raw, "channels");
const telegram = channels.telegram;
if (!telegram || typeof telegram !== "object") return;
const requireMention = telegram.requireMention;
if (requireMention === void 0) return;
const { groups, entry } = ensureDefaultGroupEntry(telegram);
const defaultKey = "*";
if (entry.requireMention === void 0) {
entry.requireMention = requireMention;
groups[defaultKey] = entry;
telegram.groups = groups;
changes.push("Moved telegram.requireMention → channels.telegram.groups.\"*\".requireMention.");
} else changes.push("Removed telegram.requireMention (channels.telegram.groups.\"*\" already set).");
delete telegram.requireMention;
channels.telegram = telegram;
raw.channels = channels;
}
}
];
//#endregion
//#region src/config/legacy.migrations.part-2.ts
function applyLegacyAudioTranscriptionModel(params) {
const mapped = mapLegacyAudioTranscription(params.source);
if (!mapped) {
params.changes.push(params.invalidMessage);
return;
}
const mediaAudio = ensureRecord(ensureRecord(ensureRecord(params.raw, "tools"), "media"), "audio");
if ((Array.isArray(mediaAudio.models) ? mediaAudio.models : []).length === 0) {
mediaAudio.enabled = true;
mediaAudio.models = [mapped];
params.changes.push(params.movedMessage);
return;
}
params.changes.push(params.alreadySetMessage);
}
const LEGACY_CONFIG_MIGRATIONS_PART_2 = [
{
id: "agent.model-config-v2",
describe: "Migrate legacy agent.model/allowedModels/modelAliases/modelFallbacks/imageModelFallbacks to agent.models + model lists",
apply: (raw, changes) => {
const agentRoot = getRecord(raw.agent);
const defaults = getRecord(getRecord(raw.agents)?.defaults);
const agent = agentRoot ?? defaults;
if (!agent) return;
const label = agentRoot ? "agent" : "agents.defaults";
const legacyModel = typeof agent.model === "string" ? String(agent.model) : void 0;
const legacyImageModel = typeof agent.imageModel === "string" ? String(agent.imageModel) : void 0;
const legacyAllowed = Array.isArray(agent.allowedModels) ? agent.allowedModels.map(String) : [];
const legacyModelFallbacks = Array.isArray(agent.modelFallbacks) ? agent.modelFallbacks.map(String) : [];
const legacyImageModelFallbacks = Array.isArray(agent.imageModelFallbacks) ? agent.imageModelFallbacks.map(String) : [];
const legacyAliases = agent.modelAliases && typeof agent.modelAliases === "object" ? agent.modelAliases : {};
if (!(legacyModel || legacyImageModel || legacyAllowed.length > 0 || legacyModelFallbacks.length > 0 || legacyImageModelFallbacks.length > 0 || Object.keys(legacyAliases).length > 0)) return;
const models = agent.models && typeof agent.models === "object" ? agent.models : {};
const ensureModel = (rawKey) => {
if (typeof rawKey !== "string") return;
const key = rawKey.trim();
if (!key) return;
if (!models[key]) models[key] = {};
};
ensureModel(legacyModel);
ensureModel(legacyImageModel);
for (const key of legacyAllowed) ensureModel(key);
for (const key of legacyModelFallbacks) ensureModel(key);
for (const key of legacyImageModelFallbacks) ensureModel(key);
for (const target of Object.values(legacyAliases)) {
if (typeof target !== "string") continue;
ensureModel(target);
}
for (const [alias, targetRaw] of Object.entries(legacyAliases)) {
if (typeof targetRaw !== "string") continue;
const target = targetRaw.trim();
if (!target) continue;
const entry = models[target] && typeof models[target] === "object" ? models[target] : {};
if (!("alias" in entry)) {
entry.alias = alias;
models[target] = entry;
}
}
const currentModel = agent.model && typeof agent.model === "object" ? agent.model : null;
if (currentModel) {
if (!currentModel.primary && legacyModel) currentModel.primary = legacyModel;
if (legacyModelFallbacks.length > 0 && (!Array.isArray(currentModel.fallbacks) || currentModel.fallbacks.length === 0)) currentModel.fallbacks = legacyModelFallbacks;
agent.model = currentModel;
} else if (legacyModel || legacyModelFallbacks.length > 0) agent.model = {
primary: legacyModel,
fallbacks: legacyModelFallbacks.length ? legacyModelFallbacks : []
};
const currentImageModel = agent.imageModel && typeof agent.imageModel === "object" ? agent.imageModel : null;
if (currentImageModel) {
if (!currentImageModel.primary && legacyImageModel) currentImageModel.primary = legacyImageModel;
if (legacyImageModelFallbacks.length > 0 && (!Array.isArray(currentImageModel.fallbacks) || currentImageModel.fallbacks.length === 0)) currentImageModel.fallbacks = legacyImageModelFallbacks;
agent.imageModel = currentImageModel;
} else if (legacyImageModel || legacyImageModelFallbacks.length > 0) agent.imageModel = {
primary: legacyImageModel,
fallbacks: legacyImageModelFallbacks.length ? legacyImageModelFallbacks : []
};
agent.models = models;
if (legacyModel !== void 0) changes.push(`Migrated ${label}.model string → ${label}.model.primary.`);
if (legacyModelFallbacks.length > 0) changes.push(`Migrated ${label}.modelFallbacks → ${label}.model.fallbacks.`);
if (legacyImageModel !== void 0) changes.push(`Migrated ${label}.imageModel string → ${label}.imageModel.primary.`);
if (legacyImageModelFallbacks.length > 0) changes.push(`Migrated ${label}.imageModelFallbacks → ${label}.imageModel.fallbacks.`);
if (legacyAllowed.length > 0) changes.push(`Migrated ${label}.allowedModels → ${label}.models.`);
if (Object.keys(legacyAliases).length > 0) changes.push(`Migrated ${label}.modelAliases → ${label}.models.*.alias.`);
delete agent.allowedModels;
delete agent.modelAliases;
delete agent.modelFallbacks;
delete agent.imageModelFallbacks;
}
},
{
id: "routing.agents-v2",
describe: "Move routing.agents/defaultAgentId to agents.list",
apply: (raw, changes) => {
const routing = getRecord(raw.routing);
if (!routing) return;
const routingAgents = getRecord(routing.agents);
const agents = ensureRecord(raw, "agents");
const list = getAgentsList(agents);
if (routingAgents) {
for (const [rawId, entryRaw] of Object.entries(routingAgents)) {
const agentId = String(rawId ?? "").trim();
const entry = getRecord(entryRaw);
if (!agentId || !entry) continue;
const target = ensureAgentEntry(list, agentId);
const entryCopy = { ...entry };
if ("mentionPatterns" in entryCopy) {
const mentionPatterns = entryCopy.mentionPatterns;
const groupChat = ensureRecord(target, "groupChat");
if (groupChat.mentionPatterns === void 0) {
groupChat.mentionPatterns = mentionPatterns;
changes.push(`Moved routing.agents.${agentId}.mentionPatterns → agents.list (id "${agentId}").groupChat.mentionPatterns.`);
} else changes.push(`Removed routing.agents.${agentId}.mentionPatterns (agents.list groupChat mentionPatterns already set).`);
delete entryCopy.mentionPatterns;
}
const legacyGroupChat = getRecord(entryCopy.groupChat);
if (legacyGroupChat) {
mergeMissing(ensureRecord(target, "groupChat"), legacyGroupChat);
delete entryCopy.groupChat;
}
const legacySandbox = getRecord(entryCopy.sandbox);
if (legacySandbox) {
const sandboxTools = getRecord(legacySandbox.tools);
if (sandboxTools) {
mergeMissing(ensureRecord(ensureRecord(ensureRecord(target, "tools"), "s