pilotdeckConfig.js 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605
  1. import fs from 'fs';
  2. import fsPromises from 'fs/promises';
  3. import os from 'os';
  4. import path from 'path';
  5. import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
  6. // Source of truth: ~/.pilotdeck/pilotdeck.yaml. The disk format and the
  7. // "internal" config object are the same V2 schema — no more adapter layer.
  8. //
  9. // Top-level shape:
  10. // schemaVersion: 1
  11. // agent: { model: "provider/model", params, subagents }
  12. // model: { providers: { [pid]: { protocol, url, apiKey, models, headers, timeoutMs } } }
  13. // memory: { enabled, model, apiType?, reasoningMode, ... }
  14. // webui: { runtime: { host, serverPort, vitePort, proxyPort, ... } }
  15. // router: { enabled, stats: { enabled, modelPricing }, ... }
  16. // gateway: { enabled, home, ... }
  17. // alwaysOn: { enabled, trigger, dormancy, workspace, execution, projects }
  18. // customEnv:{ KEY: VALUE } (UI-only; engine ignores)
  19. //
  20. // Everything not in this list (router/gateway/alwaysOn deep fields, etc.)
  21. // flows through verbatim — the gateway-side PilotConfigStore owns those
  22. // schemas. UI server just round-trips them.
  23. const CONFIG_VERSION = 1;
  24. const PILOT_HOME_DIR = process.env.PILOT_HOME || path.join(os.homedir(), '.pilotdeck');
  25. const DEFAULT_CONFIG_PATH = path.join(PILOT_HOME_DIR, 'pilotdeck.yaml');
  26. const MASK = '********';
  27. const SECRET_KEY_RE = /(api[_-]?key|token|secret|password|auth[_-]?token|access[_-]?token|bot[_-]?token|app[_-]?token|encoding[_-]?aes[_-]?key)$/i;
  28. const SECRET_EXACT_KEYS = new Set(['key', 'apiKey', 'api_key', 'authToken', 'accessToken']);
  29. function clone(value) {
  30. return JSON.parse(JSON.stringify(value));
  31. }
  32. function isRecord(value) {
  33. return value && typeof value === 'object' && !Array.isArray(value);
  34. }
  35. function normalizeString(value) {
  36. return typeof value === 'string' ? value.trim() : '';
  37. }
  38. function deepMerge(base, override) {
  39. if (!isRecord(base)) return clone(override);
  40. const output = clone(base);
  41. if (!isRecord(override)) return output;
  42. for (const [key, value] of Object.entries(override)) {
  43. if (value === undefined) continue;
  44. if (isRecord(value) && isRecord(output[key])) {
  45. output[key] = deepMerge(output[key], value);
  46. } else {
  47. output[key] = value;
  48. }
  49. }
  50. return output;
  51. }
  52. // ─── Defaults ────────────────────────────────────────────────────────────────
  53. export function buildDefaultPilotDeckConfig() {
  54. return {
  55. schemaVersion: CONFIG_VERSION,
  56. agent: {
  57. model: '',
  58. params: {},
  59. subagents: { default: 'inherit', params: {} },
  60. },
  61. model: {
  62. providers: {},
  63. },
  64. memory: {
  65. enabled: true,
  66. reasoningMode: 'answer_first',
  67. autoIndexIntervalMinutes: 30,
  68. autoDreamIntervalMinutes: 60,
  69. captureStrategy: 'last_turn',
  70. includeAssistant: true,
  71. maxMessageChars: 6000,
  72. heartbeatBatchSize: 30,
  73. },
  74. webui: {
  75. runtime: {
  76. host: '0.0.0.0',
  77. serverPort: 3001,
  78. vitePort: 5173,
  79. proxyPort: 18080,
  80. apiTimeoutMs: 120000,
  81. httpsProxy: '',
  82. databasePath: path.join(PILOT_HOME_DIR, 'auth.db'),
  83. workspacesRoot: os.homedir(),
  84. },
  85. },
  86. };
  87. }
  88. // `normalize` here means "fill in missing top-level sections with defaults"
  89. // — it never reshapes. Idempotent.
  90. export function normalizePilotDeckConfig(input) {
  91. return deepMerge(buildDefaultPilotDeckConfig(), isRecord(input) ? input : {});
  92. }
  93. // Strip surrounding whitespace from provider apiKey + url before they
  94. // hit disk. Without this, a copy-paste with a stray space (e.g.
  95. // `apiKey: " sk-..."`) survives the round-trip and produces an
  96. // `Authorization: Bearer sk-...` header that providers reject as
  97. // `invalid_token` / `无效的令牌`. The gateway's parseModelConfig already
  98. // trims as a defence-in-depth, but cleaning here keeps the on-disk
  99. // yaml authoritative + diff-clean for users browsing the file.
  100. export function sanitizeProviderCredentials(config) {
  101. if (!isRecord(config)) return config;
  102. const providers = config?.model?.providers;
  103. if (!isRecord(providers)) return config;
  104. for (const provider of Object.values(providers)) {
  105. if (!isRecord(provider)) continue;
  106. if (typeof provider.apiKey === 'string') {
  107. provider.apiKey = provider.apiKey.trim();
  108. }
  109. if (typeof provider.url === 'string') {
  110. provider.url = provider.url.trim();
  111. }
  112. }
  113. return config;
  114. }
  115. // ─── Model resolution ────────────────────────────────────────────────────────
  116. function splitModelRef(ref) {
  117. const text = normalizeString(ref);
  118. if (!text) return null;
  119. const slash = text.indexOf('/');
  120. if (slash <= 0 || slash === text.length - 1) return null;
  121. return { providerId: text.slice(0, slash), modelId: text.slice(slash + 1) };
  122. }
  123. // Returns { id, providerId, provider, model, def } or null if the
  124. // reference doesn't resolve. `id` is the canonical "provider/model"
  125. // string (after inherit-resolution).
  126. export function resolveModel(config, ref, options = {}) {
  127. const inheritFallback = normalizeString(config?.agent?.model);
  128. const refText = normalizeString(ref);
  129. const effective = (!refText || refText === 'inherit')
  130. ? inheritFallback
  131. : refText;
  132. const parts = splitModelRef(effective);
  133. if (!parts) {
  134. if (options.allowMissing) return null;
  135. throw new Error(`Invalid model reference: ${ref ?? ''}`);
  136. }
  137. const provider = config?.model?.providers?.[parts.providerId];
  138. if (!isRecord(provider)) {
  139. if (options.allowMissing) return null;
  140. throw new Error(`Provider not found for model "${effective}": ${parts.providerId}`);
  141. }
  142. const def = isRecord(provider.models) ? provider.models[parts.modelId] : null;
  143. return {
  144. id: effective,
  145. providerId: parts.providerId,
  146. provider,
  147. model: parts.modelId,
  148. def: isRecord(def) ? def : {},
  149. };
  150. }
  151. // ─── Validation ──────────────────────────────────────────────────────────────
  152. function validateProvider(id, provider, errors) {
  153. if (!isRecord(provider)) {
  154. errors.push(`model.providers.${id} must be an object`);
  155. return;
  156. }
  157. const protocol = normalizeString(provider.protocol).toLowerCase();
  158. if (!protocol) errors.push(`model.providers.${id}.protocol is required`);
  159. else if (protocol !== 'openai' && protocol !== 'anthropic') {
  160. errors.push(`model.providers.${id}.protocol must be "openai" or "anthropic"`);
  161. }
  162. if (!normalizeString(provider.url)) errors.push(`model.providers.${id}.url is required`);
  163. if (!normalizeString(provider.apiKey)) errors.push(`model.providers.${id}.apiKey is required`);
  164. }
  165. function validateModelRef(config, ref, label, errors) {
  166. const modelRef = normalizeString(ref);
  167. if (!modelRef) return;
  168. if (!resolveModel(config, modelRef, { allowMissing: true })) {
  169. errors.push(`${label}="${modelRef}" doesn't resolve to a configured provider/model`);
  170. }
  171. }
  172. function validateRouterModelRefs(config, errors) {
  173. const router = config.router;
  174. if (!isRecord(router)) return;
  175. if (isRecord(router.scenarios)) {
  176. for (const [key, ref] of Object.entries(router.scenarios)) {
  177. validateModelRef(config, ref, `router.scenarios.${key}`, errors);
  178. }
  179. }
  180. if (isRecord(router.fallback)) {
  181. for (const [key, refs] of Object.entries(router.fallback)) {
  182. if (!Array.isArray(refs)) continue;
  183. refs.forEach((ref, index) => validateModelRef(config, ref, `router.fallback.${key}[${index}]`, errors));
  184. }
  185. }
  186. const tokenSaver = router.tokenSaver;
  187. if (!isRecord(tokenSaver)) return;
  188. validateModelRef(config, tokenSaver.judge, 'router.tokenSaver.judge', errors);
  189. if (isRecord(tokenSaver.tiers)) {
  190. for (const [key, tier] of Object.entries(tokenSaver.tiers)) {
  191. if (!isRecord(tier)) continue;
  192. validateModelRef(config, tier.model, `router.tokenSaver.tiers.${key}.model`, errors);
  193. }
  194. }
  195. }
  196. export function validatePilotDeckConfig(config) {
  197. const normalized = normalizePilotDeckConfig(config);
  198. const errors = [];
  199. const warnings = [];
  200. const mainRef = normalizeString(normalized.agent.model);
  201. if (!mainRef) {
  202. warnings.push('agent.model is empty; pick a model from model.providers.');
  203. } else {
  204. const main = resolveModel(normalized, mainRef, { allowMissing: true });
  205. if (!main) {
  206. errors.push(`agent.model="${mainRef}" doesn't resolve to a configured provider/model`);
  207. } else {
  208. validateProvider(main.providerId, main.provider, errors);
  209. }
  210. }
  211. if (normalized.memory?.enabled && normalizeString(normalized.memory.model)) {
  212. const ref = normalizeString(normalized.memory.model);
  213. if (ref !== 'inherit') {
  214. const memory = resolveModel(normalized, ref, { allowMissing: true });
  215. if (!memory) {
  216. errors.push(`memory.model="${ref}" doesn't resolve to a configured provider/model`);
  217. }
  218. }
  219. }
  220. validateRouterModelRefs(normalized, errors);
  221. if (normalized.webui?.runtime?.contextWindow !== undefined) {
  222. warnings.push(
  223. 'webui.runtime.contextWindow is deprecated and ignored. ' +
  224. 'Use agent.maxContextTokens to override the model\'s context window for auto-compaction.',
  225. );
  226. }
  227. return { valid: errors.length === 0, errors, warnings, config: normalized };
  228. }
  229. // ─── Secret masking ──────────────────────────────────────────────────────────
  230. function isSecretKey(key) {
  231. return SECRET_EXACT_KEYS.has(key) || SECRET_KEY_RE.test(key);
  232. }
  233. export function maskSecrets(value) {
  234. if (Array.isArray(value)) return value.map(maskSecrets);
  235. if (!isRecord(value)) return value;
  236. const output = {};
  237. for (const [key, child] of Object.entries(value)) {
  238. if (isSecretKey(key) && typeof child === 'string' && child.trim()) {
  239. output[key] = MASK;
  240. } else {
  241. output[key] = maskSecrets(child);
  242. }
  243. }
  244. return output;
  245. }
  246. export function preserveMaskedSecrets(nextValue, previousValue) {
  247. if (nextValue === MASK && typeof previousValue === 'string') return previousValue;
  248. if (Array.isArray(nextValue)) {
  249. return nextValue.map((item, index) =>
  250. preserveMaskedSecrets(item, Array.isArray(previousValue) ? previousValue[index] : undefined),
  251. );
  252. }
  253. if (isRecord(nextValue)) {
  254. const output = {};
  255. for (const [key, child] of Object.entries(nextValue)) {
  256. output[key] = preserveMaskedSecrets(child, isRecord(previousValue) ? previousValue[key] : undefined);
  257. }
  258. return output;
  259. }
  260. return nextValue;
  261. }
  262. // ─── Runtime env derivation ──────────────────────────────────────────────────
  263. function providerProtocolToMemoryApi(protocol) {
  264. // V2 catalog only uses 'openai' (Chat Completions) and 'anthropic'.
  265. // The /responses style is only relevant when a user manually sets
  266. // memory.apiType, which they can do alongside protocol="openai".
  267. return 'openai-completions';
  268. }
  269. export function buildRuntimeEnv(config) {
  270. const normalized = normalizePilotDeckConfig(config);
  271. const main = resolveModel(normalized, normalized.agent.model, { allowMissing: true });
  272. const runtime = normalized.webui?.runtime ?? {};
  273. const proxyPort = String(runtime.proxyPort ?? 18080);
  274. const env = {
  275. PILOTDECK_PROXY_PORT: process.env.PILOTDECK_PROXY_PORT || proxyPort,
  276. PROXY_PORT: process.env.PROXY_PORT || proxyPort,
  277. SERVER_PORT: process.env.SERVER_PORT || String(runtime.serverPort ?? 3001),
  278. VITE_PORT: process.env.VITE_PORT || String(runtime.vitePort ?? 5173),
  279. HOST: process.env.HOST || String(runtime.host ?? '0.0.0.0'),
  280. API_TIMEOUT_MS: String(runtime.apiTimeoutMs ?? 120000),
  281. PILOTDECK_MEMORY_ENABLED: normalized.memory?.enabled ? '1' : '0',
  282. };
  283. if (runtime.databasePath) env.DATABASE_PATH = expandTilde(runtime.databasePath);
  284. if (runtime.workspacesRoot) env.WORKSPACES_ROOT = expandTilde(runtime.workspacesRoot);
  285. if (runtime.httpsProxy) {
  286. env.HTTPS_PROXY = runtime.httpsProxy;
  287. env.https_proxy = runtime.httpsProxy;
  288. }
  289. if (main) {
  290. env.PILOTDECK_API_BASE_URL = main.provider.url || '';
  291. env.PILOTDECK_API_KEY = main.provider.apiKey || '';
  292. env.PILOTDECK_MODEL = main.model;
  293. env.OPENAI_BASE_URL = main.provider.url || '';
  294. env.OPENAI_API_KEY = main.provider.apiKey || '';
  295. env.OPENAI_MODEL = main.model;
  296. env.ANTHROPIC_API_KEY = main.provider.apiKey || '';
  297. env.ANTHROPIC_MODEL = main.model;
  298. }
  299. env.ANTHROPIC_BASE_URL = `http://127.0.0.1:${proxyPort}`;
  300. // Reasoning models (DeepSeek-R1, MiniMax-M2.7, etc.) need a generous
  301. // output token cap; honor agent.params.maxOutputTokens / max_tokens.
  302. const mainParams = normalized.agent?.params ?? {};
  303. const requestedMaxOutput = Number.parseInt(
  304. String(
  305. mainParams.maxOutputTokens ??
  306. mainParams.max_output_tokens ??
  307. mainParams.max_tokens ??
  308. ''
  309. ).trim(),
  310. 10,
  311. );
  312. if (Number.isFinite(requestedMaxOutput) && requestedMaxOutput > 0) {
  313. env.PILOTDECK_MAX_OUTPUT_TOKENS = String(requestedMaxOutput);
  314. } else if (process.env.PILOTDECK_MAX_OUTPUT_TOKENS) {
  315. env.PILOTDECK_MAX_OUTPUT_TOKENS = process.env.PILOTDECK_MAX_OUTPUT_TOKENS;
  316. }
  317. const tavilyKey = mainParams.tavilyApiKey ?? mainParams.tavily_api_key ?? process.env.TAVILY_API_KEY;
  318. if (tavilyKey) env.TAVILY_API_KEY = String(tavilyKey);
  319. // Memory uses memory.model (or inherits agent.model when blank).
  320. const memoryRef = normalizeString(normalized.memory?.model) || normalized.agent.model;
  321. const memory = resolveModel(normalized, memoryRef, { allowMissing: true });
  322. if (memory) {
  323. env.PILOTDECK_MEMORY_MODEL = memory.model;
  324. env.PILOTDECK_MEMORY_PROVIDER = memory.providerId;
  325. env.PILOTDECK_MEMORY_BASE_URL = memory.provider.url || '';
  326. env.PILOTDECK_MEMORY_API_KEY = memory.provider.apiKey || '';
  327. env.PILOTDECK_MEMORY_API_TYPE = normalizeString(normalized.memory?.apiType)
  328. || providerProtocolToMemoryApi(memory.provider.protocol);
  329. }
  330. // Pass through customEnv (UI-managed escape hatch).
  331. if (isRecord(normalized.customEnv)) {
  332. for (const [key, value] of Object.entries(normalized.customEnv)) {
  333. if (typeof value === 'string' && value.trim()) env[key] = value;
  334. }
  335. }
  336. return env;
  337. }
  338. export function applyConfigToProcessEnv(config) {
  339. Object.assign(process.env, buildRuntimeEnv(config));
  340. }
  341. // ─── Memory service options ──────────────────────────────────────────────────
  342. export function buildMemoryLlmOptions(config) {
  343. const normalized = normalizePilotDeckConfig(config);
  344. const ref = normalizeString(normalized.memory?.model) || normalized.agent.model;
  345. const memory = resolveModel(normalized, ref, { allowMissing: true });
  346. if (!memory) return undefined;
  347. return {
  348. provider: memory.providerId,
  349. model: memory.model,
  350. apiType: normalizeString(normalized.memory?.apiType)
  351. || providerProtocolToMemoryApi(memory.provider.protocol),
  352. baseUrl: memory.provider.url || '',
  353. apiKey: memory.provider.apiKey || '',
  354. headers: isRecord(memory.provider.headers) ? memory.provider.headers : {},
  355. };
  356. }
  357. export function buildMemoryDefaults(config) {
  358. const memory = normalizePilotDeckConfig(config).memory ?? {};
  359. return {
  360. llm: buildMemoryLlmOptions(config),
  361. defaultIndexingSettings: {
  362. reasoningMode: memory.reasoningMode,
  363. autoIndexIntervalMinutes: memory.autoIndexIntervalMinutes,
  364. autoDreamIntervalMinutes: memory.autoDreamIntervalMinutes,
  365. },
  366. captureStrategy: memory.captureStrategy,
  367. includeAssistant: memory.includeAssistant,
  368. maxMessageChars: memory.maxMessageChars,
  369. heartbeatBatchSize: memory.heartbeatBatchSize,
  370. };
  371. }
  372. // ─── File I/O ────────────────────────────────────────────────────────────────
  373. export function getPilotDeckConfigPath() {
  374. if (process.env.PILOTDECK_CONFIG_PATH?.trim()) {
  375. return process.env.PILOTDECK_CONFIG_PATH.trim();
  376. }
  377. return DEFAULT_CONFIG_PATH;
  378. }
  379. export function readPilotDeckConfigFile() {
  380. const configPath = getPilotDeckConfigPath();
  381. if (!fs.existsSync(configPath)) {
  382. return {
  383. exists: false,
  384. configPath,
  385. raw: '',
  386. config: buildDefaultPilotDeckConfig(),
  387. rawYaml: {},
  388. };
  389. }
  390. const raw = fs.readFileSync(configPath, 'utf8');
  391. const parsed = parseYaml(raw) || {};
  392. const config = normalizePilotDeckConfig(parsed);
  393. return { exists: true, configPath, raw, config, rawYaml: parsed };
  394. }
  395. // Keep `router.scenarios.default` aligned with `agent.model` whenever we
  396. // write the config. The gateway treats agent.model as the source of truth
  397. // (loadPilotConfig.ts auto-overrides router.scenarios.default with
  398. // agent.model on conflict, with a warning). Doing the rewrite here too
  399. // means the on-disk yaml stays consistent — no stale router refs left
  400. // over from before the user picked a new model in onboarding/settings.
  401. //
  402. // Scope is deliberately narrow:
  403. // • only touches `router.scenarios.default` (not tokenSaver tiers,
  404. // fallback chains, or other scenario keys — those are user-curated)
  405. // • no-ops when agent.model is empty or unparseable
  406. // • no-ops when router block doesn't exist (won't create one)
  407. export function syncAgentModelWithRouter(config) {
  408. if (!isRecord(config)) return config;
  409. const agentRef = normalizeString(config.agent?.model);
  410. if (!agentRef) return config;
  411. const slash = agentRef.indexOf('/');
  412. if (slash <= 0 || slash >= agentRef.length - 1) return config;
  413. const providerId = agentRef.slice(0, slash);
  414. const modelId = agentRef.slice(slash + 1);
  415. if (!isRecord(config.router)) return config;
  416. if (!isRecord(config.router.scenarios)) return config;
  417. const currentDefault = config.router.scenarios.default;
  418. // Accept both string ("provider/model") and object ref shapes.
  419. const currentId = typeof currentDefault === 'string'
  420. ? currentDefault.trim()
  421. : (isRecord(currentDefault) ? normalizeString(currentDefault.id) : '');
  422. if (currentId === agentRef) return config;
  423. config.router.scenarios.default = typeof currentDefault === 'string'
  424. ? agentRef
  425. : { id: agentRef, provider: providerId, model: modelId };
  426. return config;
  427. }
  428. const BOOTSTRAP_PLACEHOLDER_KEY = 'PLACEHOLDER_RUN_ONBOARDING_TO_REPLACE';
  429. // Remove bootstrap placeholder providers — both the new `_placeholder` name
  430. // and any legacy provider whose apiKey is still the onboarding sentinel.
  431. // Called automatically on every config write so stale placeholders disappear
  432. // as soon as the user saves real provider details.
  433. function purgeBootstrapPlaceholder(config) {
  434. if (!isRecord(config)) return config;
  435. const providers = config?.model?.providers;
  436. if (isRecord(providers)) {
  437. for (const [pid, prov] of Object.entries(providers)) {
  438. if (pid === '_placeholder' || normalizeString(prov?.apiKey) === BOOTSTRAP_PLACEHOLDER_KEY) {
  439. delete providers[pid];
  440. }
  441. }
  442. }
  443. const agentModel = normalizeString(config?.agent?.model);
  444. if (agentModel === '_placeholder/_placeholder') {
  445. const realProviders = isRecord(providers) ? Object.keys(providers) : [];
  446. if (realProviders.length > 0) {
  447. const firstProvider = realProviders[0];
  448. const models = Object.keys(providers[firstProvider]?.models ?? {});
  449. if (models.length > 0) {
  450. config.agent.model = `${firstProvider}/${models[0]}`;
  451. }
  452. }
  453. }
  454. const router = config?.router;
  455. if (!isRecord(router)) return config;
  456. const agentRef = normalizeString(config.agent?.model);
  457. const survivingProviders = isRecord(providers) ? new Set(Object.keys(providers)) : new Set();
  458. function isOrphanRef(ref) {
  459. const s = normalizeString(ref);
  460. if (!s) return false;
  461. const slash = s.indexOf('/');
  462. if (slash <= 0) return false;
  463. return !survivingProviders.has(s.slice(0, slash));
  464. }
  465. if (isRecord(router.scenarios)) {
  466. for (const [key, val] of Object.entries(router.scenarios)) {
  467. if (isOrphanRef(val)) router.scenarios[key] = agentRef || val;
  468. }
  469. }
  470. if (Array.isArray(router.fallback?.default)) {
  471. router.fallback.default = router.fallback.default.map(
  472. v => isOrphanRef(v) ? (agentRef || v) : v
  473. );
  474. }
  475. if (isRecord(router.tokenSaver)) {
  476. if (isOrphanRef(router.tokenSaver.judge)) {
  477. router.tokenSaver.judge = agentRef || router.tokenSaver.judge;
  478. }
  479. if (isRecord(router.tokenSaver.tiers)) {
  480. for (const tier of Object.values(router.tokenSaver.tiers)) {
  481. if (isRecord(tier) && isOrphanRef(tier.model)) {
  482. tier.model = agentRef || tier.model;
  483. }
  484. }
  485. }
  486. }
  487. return config;
  488. }
  489. // Lossless writer — config object is the V2 disk shape, written verbatim
  490. // after running through validation. UI-internal === disk schema, so
  491. // there's no read-modify-write needed anymore (the previous translation
  492. // layer existed only to bridge an older internal schema).
  493. export async function writePilotDeckConfig(config) {
  494. const sanitized = purgeBootstrapPlaceholder(
  495. syncAgentModelWithRouter(
  496. sanitizeProviderCredentials(
  497. isRecord(config) ? deepMerge({}, config) : config,
  498. ),
  499. ),
  500. );
  501. const validation = validatePilotDeckConfig(sanitized);
  502. if (!validation.valid) {
  503. const error = new Error('Invalid PilotDeck config');
  504. error.validation = validation;
  505. throw error;
  506. }
  507. const configPath = getPilotDeckConfigPath();
  508. await fsPromises.mkdir(path.dirname(configPath), { recursive: true });
  509. const yamlObj = validation.config;
  510. const raw = stringifyYaml(yamlObj, { lineWidth: 0 });
  511. await fsPromises.writeFile(configPath, raw, 'utf8');
  512. return { configPath, raw, validation, config: yamlObj };
  513. }
  514. // Kept as a thin alias for callers that supply an already-parsed YAML
  515. // object (Raw YAML editor path). Behaviour is identical to
  516. // writePilotDeckConfig now that internal === disk.
  517. export async function writeRawPilotDeckYaml(yamlObj) {
  518. return writePilotDeckConfig(yamlObj);
  519. }
  520. export function expandTilde(value) {
  521. const text = normalizeString(value);
  522. if (text === '~') return os.homedir();
  523. if (text.startsWith('~/')) return path.join(os.homedir(), text.slice(2));
  524. return text;
  525. }
  526. export function configToYaml(config) {
  527. const normalized = normalizePilotDeckConfig(config);
  528. return stringifyYaml(normalized, { lineWidth: 0 });
  529. }
  530. // Lossless masked serialization for the "Raw YAML" view. Now that
  531. // internal === disk, this is just `stringifyYaml(maskSecrets(rawYaml))`.
  532. export function rawYamlToMaskedString(rawYaml) {
  533. const obj = isRecord(rawYaml) ? rawYaml : {};
  534. return stringifyYaml(maskSecrets(obj), { lineWidth: 0 });
  535. }
  536. export function parseConfigYaml(raw) {
  537. return normalizePilotDeckConfig(parseYaml(raw) || {});
  538. }