@yeaft/webchat-agent 0.1.412 → 0.1.414

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,117 @@
1
+ /**
2
+ * config-api.js — Read/write Yeaft config.json for remote management
3
+ *
4
+ * Provides functions to get and update the LLM-related portion of
5
+ * ~/.yeaft/config.json via WebSocket messages from the web UI.
6
+ *
7
+ * Only exposes provider/model configuration — not internal fields
8
+ * like maxContinueTurns or debug that don't belong in the UI.
9
+ */
10
+
11
+ import { existsSync, readFileSync, writeFileSync } from 'fs';
12
+ import { join } from 'path';
13
+ import { DEFAULT_YEAFT_DIR } from './init.js';
14
+
15
+ /**
16
+ * Read the LLM-relevant portion of config.json.
17
+ *
18
+ * @param {string} [dir] — Yeaft data directory
19
+ * @returns {{ providers, primaryModel, fastModel, language } | { error: string }}
20
+ */
21
+ export function getLlmConfig(dir) {
22
+ const root = dir || process.env.YEAFT_DIR || DEFAULT_YEAFT_DIR;
23
+ const configPath = join(root, 'config.json');
24
+
25
+ if (!existsSync(configPath)) {
26
+ return { providers: [], primaryModel: null, fastModel: null, language: 'en', needsSetup: true };
27
+ }
28
+
29
+ try {
30
+ const raw = readFileSync(configPath, 'utf8');
31
+ const json = JSON.parse(raw);
32
+ const providers = Array.isArray(json.providers) ? json.providers : [];
33
+
34
+ // Detect if config still has default/placeholder values (first-time setup needed)
35
+ const needsSetup = providers.length === 0 || providers.every(p =>
36
+ p.apiKey === 'proxy' || p.apiKey === '' || !p.apiKey
37
+ );
38
+
39
+ return {
40
+ providers,
41
+ primaryModel: json.primaryModel || null,
42
+ fastModel: json.fastModel || null,
43
+ language: json.language || 'en',
44
+ needsSetup,
45
+ };
46
+ } catch (e) {
47
+ return { error: `Failed to read config.json: ${e.message}` };
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Update the LLM-relevant portion of config.json.
53
+ * Merges into existing config — preserves fields like debug, maxContextTokens, etc.
54
+ *
55
+ * @param {object} update — { providers?, primaryModel?, fastModel?, language? }
56
+ * @param {string} [dir] — Yeaft data directory
57
+ * @returns {{ providers, primaryModel, fastModel, language } | { error: string }}
58
+ */
59
+ export function updateLlmConfig(update, dir) {
60
+ const root = dir || process.env.YEAFT_DIR || DEFAULT_YEAFT_DIR;
61
+ const configPath = join(root, 'config.json');
62
+
63
+ // Read existing config (preserve non-LLM fields)
64
+ let existing = {};
65
+ if (existsSync(configPath)) {
66
+ try {
67
+ existing = JSON.parse(readFileSync(configPath, 'utf8'));
68
+ } catch {
69
+ // Start fresh if corrupt
70
+ existing = {};
71
+ }
72
+ }
73
+
74
+ // Validate providers structure
75
+ if (update.providers !== undefined) {
76
+ if (!Array.isArray(update.providers)) {
77
+ return { error: 'providers must be an array' };
78
+ }
79
+ for (const p of update.providers) {
80
+ if (!p.name || typeof p.name !== 'string') {
81
+ return { error: 'Each provider must have a name' };
82
+ }
83
+ if (!p.baseUrl || typeof p.baseUrl !== 'string') {
84
+ return { error: `Provider "${p.name}" must have a baseUrl` };
85
+ }
86
+ if (!Array.isArray(p.models) || p.models.length === 0) {
87
+ return { error: `Provider "${p.name}" must have at least one model` };
88
+ }
89
+ }
90
+ existing.providers = update.providers;
91
+ }
92
+
93
+ // Update model selections
94
+ if (update.primaryModel !== undefined) {
95
+ existing.primaryModel = update.primaryModel || null;
96
+ }
97
+ if (update.fastModel !== undefined) {
98
+ existing.fastModel = update.fastModel || null;
99
+ }
100
+ if (update.language !== undefined) {
101
+ existing.language = update.language;
102
+ }
103
+
104
+ // Write back
105
+ try {
106
+ writeFileSync(configPath, JSON.stringify(existing, null, 2) + '\n', 'utf8');
107
+ } catch (e) {
108
+ return { error: `Failed to write config.json: ${e.message}` };
109
+ }
110
+
111
+ return {
112
+ providers: Array.isArray(existing.providers) ? existing.providers : [],
113
+ primaryModel: existing.primaryModel || null,
114
+ fastModel: existing.fastModel || null,
115
+ language: existing.language || 'en',
116
+ };
117
+ }
package/unify/config.js CHANGED
@@ -1,38 +1,66 @@
1
1
  /**
2
2
  * config.js — Yeaft configuration management
3
3
  *
4
- * Priority (high → low): CLI overrides > ENV vars > .env file > config.md frontmatter > defaults
4
+ * All configuration lives in ~/.yeaft/config.json:
5
5
  *
6
- * Note: "model" in Yeaft always means a model ID (e.g. "gpt-5", "claude-sonnet-4-20250514").
7
- * Yeaft does not provide its own models — it routes to external LLM providers via adapters.
6
+ * {
7
+ * "providers": [
8
+ * { "name": "my-proxy", "baseUrl": "http://localhost:6628/v1", "apiKey": "proxy",
9
+ * "models": ["claude-sonnet-4-20250514", "gpt-5", "deepseek-chat"] }
10
+ * ],
11
+ * "primaryModel": "my-proxy/claude-sonnet-4-20250514",
12
+ * "fastModel": "my-proxy/claude-haiku-3-20250414",
13
+ * "language": "en",
14
+ * "debug": false,
15
+ * "maxContextTokens": 200000,
16
+ * "messageTokenBudget": 8192
17
+ * }
18
+ *
19
+ * Legacy: if config.json doesn't exist, falls back to old config.md + .env for migration.
8
20
  */
9
21
 
10
22
  import { existsSync, readFileSync } from 'fs';
11
23
  import { join } from 'path';
12
24
  import { DEFAULT_YEAFT_DIR } from './init.js';
13
- import { resolveModel } from './models.js';
25
+ import { resolveModel, parseModelRef } from './models.js';
14
26
 
15
27
  /** Default configuration values. */
16
28
  const DEFAULTS = {
17
- model: 'claude-sonnet-4-20250514',
18
- fallbackModel: null,
19
- language: 'en', // 'en' | 'zh'
20
- apiKey: null,
21
- openaiApiKey: null,
22
- proxyUrl: 'http://localhost:6628',
23
- baseUrl: null,
24
- adapter: null, // auto-detect: 'anthropic' | 'openai' | 'proxy'
29
+ language: 'en',
25
30
  debug: false,
26
31
  dir: DEFAULT_YEAFT_DIR,
27
32
  maxContextTokens: 200000,
28
33
  maxOutputTokens: 16384,
29
- messageTokenBudget: 8192, // Phase 2: context * 4%, triggers consolidation
30
- maxContinueTurns: 3, // Phase 2: auto-continue on max_tokens
34
+ messageTokenBudget: 8192,
35
+ maxContinueTurns: 3,
31
36
  };
32
37
 
38
+ // ─── config.json reader ─────────────────────────────────────────
39
+
40
+ /**
41
+ * Read and parse ~/.yeaft/config.json.
42
+ *
43
+ * @param {string} dir
44
+ * @returns {object|null} — Parsed JSON or null if not found/invalid
45
+ */
46
+ function readConfigJson(dir) {
47
+ const configPath = join(dir, 'config.json');
48
+ if (!existsSync(configPath)) return null;
49
+
50
+ try {
51
+ const raw = readFileSync(configPath, 'utf8');
52
+ return JSON.parse(raw);
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
57
+
58
+ // ─── Legacy support (config.md + .env) ──────────────────────────
59
+
33
60
  /**
34
61
  * Parse YAML frontmatter from a markdown file.
35
62
  * Simple parser — handles key: value pairs, no nested objects.
63
+ * @deprecated Use config.json instead. Kept for migration.
36
64
  *
37
65
  * @param {string} content — File content
38
66
  * @returns {Record<string, string>} — Parsed frontmatter
@@ -49,7 +77,6 @@ export function parseFrontmatter(content) {
49
77
  if (colonIdx === -1) continue;
50
78
  const key = trimmed.slice(0, colonIdx).trim();
51
79
  let value = trimmed.slice(colonIdx + 1).trim();
52
- // Parse booleans and numbers
53
80
  if (value === 'true') value = true;
54
81
  else if (value === 'false') value = false;
55
82
  else if (value === 'null') value = null;
@@ -60,243 +87,209 @@ export function parseFrontmatter(content) {
60
87
  }
61
88
 
62
89
  /**
63
- * Load configuration from config.md file.
64
- *
65
- * @param {string} dir — Yeaft data directory
66
- * @returns {Record<string, unknown>} — Config from file
90
+ * Load legacy config.md.
91
+ * @deprecated
67
92
  */
68
- function loadConfigFile(dir) {
93
+ function loadLegacyConfigFile(dir) {
69
94
  const configPath = join(dir, 'config.md');
70
95
  if (!existsSync(configPath)) return {};
71
-
72
96
  try {
73
- const content = readFileSync(configPath, 'utf8');
74
- return parseFrontmatter(content);
97
+ return parseFrontmatter(readFileSync(configPath, 'utf8'));
75
98
  } catch {
76
99
  return {};
77
100
  }
78
101
  }
79
102
 
80
103
  /**
81
- * Load .env file from a directory. Sets process.env for any keys
82
- * not already defined (env vars take precedence over .env file).
83
- *
84
- * ⚠️ Side-effect: mutates process.env globally. Values set by a previous
85
- * call persist across subsequent loadConfig() calls within the same process.
86
- * This is by design (matches dotenv behavior), but callers that need isolation
87
- * (e.g. tests) must manually delete keys from process.env between calls.
88
- *
89
- * @param {string} dir — Directory containing .env file
104
+ * Load legacy .env file.
105
+ * @deprecated
90
106
  */
91
- function loadEnvFile(dir) {
107
+ function loadLegacyEnvFile(dir) {
92
108
  const envPath = join(dir, '.env');
93
109
  if (!existsSync(envPath)) return;
94
-
95
110
  try {
96
111
  const content = readFileSync(envPath, 'utf8');
97
112
  for (const line of content.split('\n')) {
98
113
  let trimmed = line.trim();
99
- // Skip empty lines and comments
100
114
  if (!trimmed || trimmed.startsWith('#')) continue;
101
- // Strip optional 'export ' prefix (common in .env files)
102
115
  if (trimmed.startsWith('export ')) trimmed = trimmed.slice(7);
103
116
  const eqIdx = trimmed.indexOf('=');
104
117
  if (eqIdx === -1) continue;
105
-
106
118
  const key = trimmed.slice(0, eqIdx).trim();
107
119
  let value = trimmed.slice(eqIdx + 1).trim();
108
- // Remove surrounding quotes if present
109
120
  if ((value.startsWith('"') && value.endsWith('"')) ||
110
121
  (value.startsWith("'") && value.endsWith("'"))) {
111
122
  value = value.slice(1, -1);
112
123
  }
113
-
114
- // Only set if not already defined (shell env takes precedence)
115
124
  if (process.env[key] === undefined) {
116
125
  process.env[key] = value;
117
126
  }
118
127
  }
119
128
  } catch {
120
- // Silently ignore .env read errors
129
+ // Silently ignore
121
130
  }
122
131
  }
123
132
 
124
- /**
125
- * Helper: check if an env var is truthy.
126
- * @param {string|undefined} val
127
- * @returns {boolean}
128
- */
129
133
  function isTruthy(val) {
130
134
  return val === '1' || val === 'true' || val === 'yes';
131
135
  }
132
136
 
133
137
  /**
134
- * Load full configuration.
135
- *
136
- * @param {Record<string, unknown>} [overrides] — CLI overrides
137
- * @returns {object} — Merged configuration
138
+ * Build config from legacy config.md + .env + env vars.
139
+ * @deprecated — used only when config.json doesn't exist.
138
140
  */
139
- export function loadConfig(overrides = {}) {
141
+ function loadLegacyConfig(dir, overrides) {
140
142
  const env = process.env;
143
+ loadLegacyEnvFile(dir);
144
+ const fileConfig = loadLegacyConfigFile(dir);
141
145
 
142
- // Determine data directory first (needed to load .env and config.md)
143
- const dir = overrides.dir || env.YEAFT_DIR || DEFAULTS.dir;
144
-
145
- // Load .env file (sets process.env for undefined keys — shell env takes precedence)
146
- loadEnvFile(dir);
147
-
148
- // Load from config.md
149
- const fileConfig = loadConfigFile(dir);
150
-
151
- // Build merged config: defaults < file < env < overrides
152
146
  const config = {
153
- model:
154
- overrides.model ||
155
- env.YEAFT_MODEL ||
156
- fileConfig.model ||
157
- DEFAULTS.model,
158
-
159
- fallbackModel:
160
- overrides.fallbackModel ||
161
- env.YEAFT_FALLBACK_MODEL ||
162
- fileConfig.fallbackModel ||
163
- DEFAULTS.fallbackModel,
164
-
165
- language:
166
- overrides.language ||
167
- env.YEAFT_LANGUAGE ||
168
- fileConfig.language ||
169
- DEFAULTS.language,
170
-
171
- apiKey:
172
- overrides.apiKey ||
173
- env.YEAFT_API_KEY ||
174
- fileConfig.apiKey ||
175
- DEFAULTS.apiKey,
176
-
177
- openaiApiKey:
178
- overrides.openaiApiKey ||
179
- env.YEAFT_OPENAI_API_KEY ||
180
- fileConfig.openaiApiKey ||
181
- DEFAULTS.openaiApiKey,
182
-
183
- proxyUrl:
184
- overrides.proxyUrl ||
185
- env.YEAFT_PROXY_URL ||
186
- fileConfig.proxyUrl ||
187
- DEFAULTS.proxyUrl,
188
-
189
- baseUrl:
190
- overrides.baseUrl ||
191
- env.YEAFT_BASE_URL ||
192
- fileConfig.baseUrl ||
193
- DEFAULTS.baseUrl,
194
-
195
- adapter:
196
- overrides.adapter ||
197
- env.YEAFT_ADAPTER ||
198
- fileConfig.adapter ||
199
- DEFAULTS.adapter,
200
-
201
- debug:
202
- overrides.debug !== undefined
203
- ? overrides.debug
204
- : env.YEAFT_DEBUG !== undefined
205
- ? isTruthy(env.YEAFT_DEBUG)
206
- : fileConfig.debug !== undefined
207
- ? fileConfig.debug
208
- : DEFAULTS.debug,
209
-
147
+ model: overrides.model || env.YEAFT_MODEL || fileConfig.model || 'claude-sonnet-4-20250514',
148
+ fallbackModel: overrides.fallbackModel || env.YEAFT_FALLBACK_MODEL || fileConfig.fallbackModel || null,
149
+ language: overrides.language || env.YEAFT_LANGUAGE || fileConfig.language || DEFAULTS.language,
150
+ apiKey: overrides.apiKey || env.YEAFT_API_KEY || fileConfig.apiKey || null,
151
+ openaiApiKey: overrides.openaiApiKey || env.YEAFT_OPENAI_API_KEY || fileConfig.openaiApiKey || null,
152
+ proxyUrl: overrides.proxyUrl || env.YEAFT_PROXY_URL || fileConfig.proxyUrl || 'http://localhost:6628',
153
+ baseUrl: overrides.baseUrl || env.YEAFT_BASE_URL || fileConfig.baseUrl || null,
154
+ adapter: overrides.adapter || env.YEAFT_ADAPTER || fileConfig.adapter || null,
155
+ debug: overrides.debug !== undefined ? overrides.debug
156
+ : env.YEAFT_DEBUG !== undefined ? isTruthy(env.YEAFT_DEBUG)
157
+ : fileConfig.debug !== undefined ? fileConfig.debug : DEFAULTS.debug,
210
158
  dir,
211
-
212
- maxContextTokens:
213
- overrides.maxContextTokens ??
214
- (env.YEAFT_MAX_CONTEXT ? parseInt(env.YEAFT_MAX_CONTEXT, 10) : null) ??
215
- fileConfig.maxContextTokens ??
216
- DEFAULTS.maxContextTokens,
217
-
218
- maxOutputTokens:
219
- overrides.maxOutputTokens ??
220
- fileConfig.maxOutputTokens ??
221
- DEFAULTS.maxOutputTokens,
222
-
223
- messageTokenBudget:
224
- overrides.messageTokenBudget ??
225
- (env.YEAFT_MESSAGE_TOKEN_BUDGET ? parseInt(env.YEAFT_MESSAGE_TOKEN_BUDGET, 10) : null) ??
226
- fileConfig.messageTokenBudget ??
227
- DEFAULTS.messageTokenBudget,
228
-
229
- maxContinueTurns:
230
- overrides.maxContinueTurns ??
231
- fileConfig.maxContinueTurns ??
232
- DEFAULTS.maxContinueTurns,
159
+ maxContextTokens: overrides.maxContextTokens ?? (env.YEAFT_MAX_CONTEXT ? parseInt(env.YEAFT_MAX_CONTEXT, 10) : null) ?? fileConfig.maxContextTokens ?? DEFAULTS.maxContextTokens,
160
+ maxOutputTokens: overrides.maxOutputTokens ?? fileConfig.maxOutputTokens ?? DEFAULTS.maxOutputTokens,
161
+ messageTokenBudget: overrides.messageTokenBudget ?? (env.YEAFT_MESSAGE_TOKEN_BUDGET ? parseInt(env.YEAFT_MESSAGE_TOKEN_BUDGET, 10) : null) ?? fileConfig.messageTokenBudget ?? DEFAULTS.messageTokenBudget,
162
+ maxContinueTurns: overrides.maxContinueTurns ?? fileConfig.maxContinueTurns ?? DEFAULTS.maxContinueTurns,
163
+ providers: null,
164
+ primaryModel: null,
165
+ fastModel: null,
233
166
  };
234
167
 
235
- // Auto-detect adapter using model registry + credential fallback
168
+ // Auto-detect adapter
236
169
  if (!config.adapter) {
237
170
  const modelInfo = resolveModel(config.model);
238
171
  if (modelInfo) {
239
- // Known model → set adapter from registry
240
172
  config.adapter = modelInfo.adapter === 'anthropic' ? 'anthropic' : 'openai';
241
- // Use registry baseUrl if not explicitly overridden
242
- if (!config.baseUrl) {
243
- config.baseUrl = modelInfo.baseUrl;
244
- }
245
- // Use registry contextWindow if still at default
246
- if (config.maxContextTokens === DEFAULTS.maxContextTokens) {
247
- config.maxContextTokens = modelInfo.contextWindow;
248
- }
249
- // Use registry maxOutputTokens if still at default
250
- if (config.maxOutputTokens === DEFAULTS.maxOutputTokens) {
251
- config.maxOutputTokens = modelInfo.maxOutputTokens;
252
- }
173
+ if (!config.baseUrl) config.baseUrl = modelInfo.baseUrl;
174
+ if (config.maxContextTokens === DEFAULTS.maxContextTokens) config.maxContextTokens = modelInfo.contextWindow;
175
+ if (config.maxOutputTokens === DEFAULTS.maxOutputTokens) config.maxOutputTokens = modelInfo.maxOutputTokens;
253
176
  } else {
254
- // Unknown model → fallback to credential-based detection
255
- if (config.apiKey) {
256
- config.adapter = 'anthropic';
257
- } else if (config.openaiApiKey) {
258
- config.adapter = 'openai';
259
- } else if (config.proxyUrl) {
260
- config.adapter = 'proxy';
261
- }
177
+ if (config.apiKey) config.adapter = 'anthropic';
178
+ else if (config.openaiApiKey) config.adapter = 'openai';
179
+ else if (config.proxyUrl) config.adapter = 'proxy';
262
180
  }
263
181
  }
264
182
 
265
- // Store resolved model info for reference
266
183
  config.modelInfo = resolveModel(config.model) || null;
184
+ return config;
185
+ }
186
+
187
+ // ─── Main: loadConfig ───────────────────────────────────────────
188
+
189
+ /**
190
+ * Load full configuration.
191
+ *
192
+ * Reads ~/.yeaft/config.json first. If not found, falls back to legacy
193
+ * config.md + .env for backward compatibility.
194
+ *
195
+ * @param {Record<string, unknown>} [overrides] — CLI overrides
196
+ * @returns {object} — Merged configuration
197
+ */
198
+ export function loadConfig(overrides = {}) {
199
+ // Determine data directory
200
+ const dir = overrides.dir || process.env.YEAFT_DIR || DEFAULTS.dir;
201
+
202
+ // Try config.json first
203
+ const jsonConfig = readConfigJson(dir);
204
+
205
+ if (!jsonConfig) {
206
+ // No config.json → legacy path
207
+ return loadLegacyConfig(dir, overrides);
208
+ }
209
+
210
+ // ─── Build config from config.json ────────────────────────
211
+ const providers = Array.isArray(jsonConfig.providers) ? jsonConfig.providers : null;
212
+
213
+ // Resolve primary model
214
+ let model = 'claude-sonnet-4-20250514';
215
+ let primaryModel = jsonConfig.primaryModel || null;
216
+ if (primaryModel) {
217
+ const parsed = parseModelRef(primaryModel);
218
+ model = parsed.modelId;
219
+ }
220
+
221
+ // Resolve fast model
222
+ let fastModel = jsonConfig.fastModel || primaryModel || null;
223
+ let fastModelId = null;
224
+ if (fastModel) {
225
+ const parsed = parseModelRef(fastModel);
226
+ fastModelId = parsed.modelId;
227
+ }
228
+
229
+ // Resolve model info for context window / output limits
230
+ const modelInfo = resolveModel(model);
231
+
232
+ const config = {
233
+ // Model
234
+ model: overrides.model || model,
235
+ primaryModel: overrides.primaryModel || primaryModel,
236
+ fastModel: overrides.fastModel || fastModel,
237
+ fastModelId: fastModelId,
238
+ fallbackModel: jsonConfig.fallbackModel || null,
239
+ modelInfo: modelInfo || null,
240
+
241
+ // Providers
242
+ providers: providers,
243
+
244
+ // General settings
245
+ language: overrides.language || jsonConfig.language || DEFAULTS.language,
246
+ debug: overrides.debug !== undefined ? overrides.debug : (jsonConfig.debug ?? DEFAULTS.debug),
247
+ dir,
248
+
249
+ // Token limits
250
+ maxContextTokens: overrides.maxContextTokens ?? jsonConfig.maxContextTokens ?? modelInfo?.contextWindow ?? DEFAULTS.maxContextTokens,
251
+ maxOutputTokens: overrides.maxOutputTokens ?? jsonConfig.maxOutputTokens ?? modelInfo?.maxOutputTokens ?? DEFAULTS.maxOutputTokens,
252
+ messageTokenBudget: overrides.messageTokenBudget ?? jsonConfig.messageTokenBudget ?? DEFAULTS.messageTokenBudget,
253
+ maxContinueTurns: overrides.maxContinueTurns ?? jsonConfig.maxContinueTurns ?? DEFAULTS.maxContinueTurns,
254
+
255
+ // Legacy fields (null when using config.json)
256
+ apiKey: overrides.apiKey || null,
257
+ openaiApiKey: null,
258
+ proxyUrl: null,
259
+ baseUrl: null,
260
+ adapter: null,
261
+ };
267
262
 
268
263
  return config;
269
264
  }
270
265
 
266
+ // ─── MCP config ─────────────────────────────────────────────────
267
+
271
268
  /**
272
- * Load MCP server configuration from ~/.yeaft/mcp.json.
269
+ * Load MCP server configuration.
273
270
  *
274
- * JSON format (frontmatter parser can't handle nested objects):
275
- * {
276
- * "servers": [
277
- * {
278
- * "name": "github",
279
- * "command": "npx",
280
- * "args": ["@mcp/github"],
281
- * "env": { "GITHUB_TOKEN": "ghp_..." }
282
- * }
283
- * ]
284
- * }
271
+ * Reads from config.json "mcpServers" field first, then falls back to
272
+ * standalone ~/.yeaft/mcp.json for backward compatibility.
285
273
  *
286
- * @param {string} yeaftDir — e.g. ~/.yeaft
274
+ * @param {string} yeaftDir
275
+ * @param {object} [jsonConfig] — Already-parsed config.json (optional, avoids re-read)
287
276
  * @returns {{ servers: object[] }}
288
277
  */
289
- export function loadMCPConfig(yeaftDir) {
278
+ export function loadMCPConfig(yeaftDir, jsonConfig) {
279
+ // Check config.json mcpServers field
280
+ if (jsonConfig && Array.isArray(jsonConfig.mcpServers)) {
281
+ const valid = jsonConfig.mcpServers.filter(s => s.name && s.command);
282
+ if (valid.length > 0) return { servers: valid };
283
+ }
284
+
285
+ // Fallback: standalone mcp.json
290
286
  const mcpPath = join(yeaftDir, 'mcp.json');
291
287
  if (!existsSync(mcpPath)) return { servers: [] };
292
288
 
293
289
  try {
294
290
  const raw = readFileSync(mcpPath, 'utf8');
295
291
  const parsed = JSON.parse(raw);
296
- if (!parsed.servers || !Array.isArray(parsed.servers)) {
297
- return { servers: [] };
298
- }
299
- // Each server must have at least name + command
292
+ if (!parsed.servers || !Array.isArray(parsed.servers)) return { servers: [] };
300
293
  const valid = parsed.servers.filter(s => s.name && s.command);
301
294
  return { servers: valid };
302
295
  } catch {
package/unify/engine.js CHANGED
@@ -80,6 +80,9 @@ export class Engine {
80
80
  /** @type {string|null} */
81
81
  #yeaftDir;
82
82
 
83
+ /** @type {object|null} — Config override for internal tasks (recall, consolidation, dream) using fastModel */
84
+ #fastConfig;
85
+
83
86
  /**
84
87
  * @param {{
85
88
  * adapter: import('./llm/adapter.js').LLMAdapter,
@@ -105,6 +108,15 @@ export class Engine {
105
108
  this.#skillManager = skillManager || null;
106
109
  this.#mcpManager = mcpManager || null;
107
110
  this.#yeaftDir = yeaftDir || null;
111
+
112
+ // Build fast config: uses fastModelId for internal tasks (recall, consolidation, dream)
113
+ // Falls back to primary model if no fastModel configured
114
+ const fastModelId = config.fastModelId || config.model;
115
+ if (fastModelId !== config.model) {
116
+ this.#fastConfig = { ...config, model: fastModelId };
117
+ } else {
118
+ this.#fastConfig = config;
119
+ }
108
120
  }
109
121
 
110
122
  /**
@@ -215,12 +227,12 @@ export class Engine {
215
227
  // Read user profile
216
228
  memory.profile = this.#memoryStore.readProfile();
217
229
 
218
- // Recall relevant entries
230
+ // Recall relevant entries (uses fastModel for cheaper/faster side-queries)
219
231
  try {
220
232
  const result = await recall({
221
233
  prompt,
222
234
  adapter: this.#adapter,
223
- config: this.#config,
235
+ config: this.#fastConfig,
224
236
  memoryStore: this.#memoryStore,
225
237
  });
226
238
  memory.entries = result.entries;
@@ -288,7 +300,7 @@ export class Engine {
288
300
  conversationStore: this.#conversationStore,
289
301
  memoryStore: this.#memoryStore,
290
302
  adapter: this.#adapter,
291
- config: this.#config,
303
+ config: this.#fastConfig,
292
304
  budget,
293
305
  });
294
306
  return { archivedCount: result.archivedCount, extractedCount: result.extractedEntries.length };
@@ -481,13 +493,16 @@ export class Engine {
481
493
  // ─── Post-query: StopHooks or Legacy ─────────────
482
494
  if (this.#yeaftDir && this.#conversationStore) {
483
495
  // Full pipeline: persist + consolidate + dream gate
496
+ // Note: stopHooks uses fastConfig for consolidation/dream (cheaper internal tasks)
497
+ // but receives both configs — messages are persisted with primary model name
484
498
  const hookResult = await runStopHooks({
485
499
  yeaftDir: this.#yeaftDir,
486
500
  mode,
487
501
  conversationStore: this.#conversationStore,
488
502
  memoryStore: this.#memoryStore,
489
503
  adapter: this.#adapter,
490
- config: this.#config,
504
+ config: this.#fastConfig,
505
+ primaryModel: this.#config.model,
491
506
  messages: conversationMessages,
492
507
  trace: this.#trace,
493
508
  });
@@ -616,4 +631,7 @@ export class Engine {
616
631
 
617
632
  /** @returns {string|null} */
618
633
  get yeaftDir() { return this.#yeaftDir; }
634
+
635
+ /** @returns {object} — Config with fastModel as model (for internal tasks) */
636
+ get fastConfig() { return this.#fastConfig; }
619
637
  }
package/unify/index.js CHANGED
@@ -16,7 +16,8 @@ export {
16
16
  LLMAbortError,
17
17
  createLLMAdapter,
18
18
  } from './llm/adapter.js';
19
- export { MODEL_REGISTRY, resolveModel, listModels, isKnownModel } from './models.js';
19
+ export { AdapterRouter } from './llm/router.js';
20
+ export { MODEL_REGISTRY, resolveModel, listModels, isKnownModel, getProviderForModel, parseModelRef } from './models.js';
20
21
  export { buildSystemPrompt, SUPPORTED_LANGUAGES } from './prompts.js';
21
22
  export { Engine } from './engine.js';
22
23
  export { ConversationStore, parseMessage, estimateTokens } from './conversation/persist.js';