@yeaft/webchat-agent 0.1.411 → 0.1.413

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,525 @@
1
+ /**
2
+ * eval/runner.js — Eval harness for Yeaft Unify
3
+ *
4
+ * Runs eval cases against one or more models and produces scored results.
5
+ * Each eval case defines:
6
+ * - A prompt (what to send)
7
+ * - Expected behavior (tool calls, output assertions, timing)
8
+ * - Scoring rubric (pass/fail/score per criterion)
9
+ *
10
+ * Results are deterministic and comparable across models for regression detection.
11
+ *
12
+ * Usage:
13
+ * import { runEvals, printResults, saveBaseline, compareToBaseline } from './runner.js';
14
+ * const results = await runEvals({ models: ['claude-sonnet-4-20250514'], suite: 'tools' });
15
+ * printResults(results);
16
+ */
17
+
18
+ import { Engine } from '../engine.js';
19
+ import { NullTrace } from '../debug-trace.js';
20
+ import { buildSystemPrompt } from '../prompts.js';
21
+ import { createEmptyRegistry } from '../tools/registry.js';
22
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
23
+ import { join } from 'path';
24
+
25
+ // ─── Types ───────────────────────────────────────────────────
26
+
27
+ /**
28
+ * @typedef {Object} EvalCase
29
+ * @property {string} id — unique identifier (e.g. "tool-use-search-basic")
30
+ * @property {string} suite — group name (e.g. "tools", "memory", "skills")
31
+ * @property {string} description — human-readable description
32
+ * @property {string} prompt — the user prompt to send
33
+ * @property {string} [mode] — 'chat' | 'work' (default: 'chat')
34
+ * @property {object[]} [messages] — prior conversation messages
35
+ * @property {object[]} [tools] — tool definitions to register
36
+ * @property {object} [registryTools] — ToolDef objects to register in ToolRegistry
37
+ * @property {Function} [setupEngine] — custom engine setup hook
38
+ * @property {EvalCriterion[]} criteria — scoring criteria
39
+ */
40
+
41
+ /**
42
+ * @typedef {Object} EvalCriterion
43
+ * @property {string} id — criterion identifier
44
+ * @property {string} description — what this checks
45
+ * @property {number} weight — importance weight (1-10)
46
+ * @property {(result: EvalRunResult) => CriterionScore} score — scoring function
47
+ */
48
+
49
+ /**
50
+ * @typedef {Object} CriterionScore
51
+ * @property {boolean} pass — did it pass?
52
+ * @property {number} score — 0.0 to 1.0
53
+ * @property {string} [reason] — explanation
54
+ */
55
+
56
+ /**
57
+ * @typedef {Object} EvalRunResult
58
+ * @property {string} caseId
59
+ * @property {string} model
60
+ * @property {object[]} events — all engine events
61
+ * @property {string} fullText — concatenated text_delta
62
+ * @property {object[]} toolCalls — tool_call events
63
+ * @property {object[]} toolResults — tool_end events
64
+ * @property {number} turnCount
65
+ * @property {number} inputTokens
66
+ * @property {number} outputTokens
67
+ * @property {number} latencyMs
68
+ * @property {string|null} error — error message if failed
69
+ */
70
+
71
+ /**
72
+ * @typedef {Object} EvalScore
73
+ * @property {string} caseId
74
+ * @property {string} model
75
+ * @property {number} totalScore — weighted average 0-100
76
+ * @property {Record<string, CriterionScore>} criteria
77
+ * @property {EvalRunResult} raw
78
+ */
79
+
80
+ // ─── Runner ──────────────────────────────────────────────────
81
+
82
+ /**
83
+ * Run a single eval case against a model adapter.
84
+ *
85
+ * @param {EvalCase} evalCase
86
+ * @param {{ adapter: object, model: string, config?: object }} options
87
+ * @returns {Promise<EvalRunResult>}
88
+ */
89
+ export async function runSingleEval(evalCase, { adapter, model, config = {} }) {
90
+ const trace = new NullTrace();
91
+ const engineConfig = { model, maxOutputTokens: 4096, ...config };
92
+
93
+ // Build engine — optionally with ToolRegistry
94
+ const engineOpts = { adapter, trace, config: engineConfig };
95
+
96
+ if (evalCase.registryTools) {
97
+ const registry = createEmptyRegistry();
98
+ for (const tool of evalCase.registryTools) {
99
+ registry.register(tool);
100
+ }
101
+ engineOpts.toolRegistry = registry;
102
+ }
103
+
104
+ const engine = new Engine(engineOpts);
105
+
106
+ // Register legacy tools if provided
107
+ if (evalCase.tools) {
108
+ for (const tool of evalCase.tools) {
109
+ engine.registerTool(tool);
110
+ }
111
+ }
112
+
113
+ // Custom setup hook
114
+ if (evalCase.setupEngine) {
115
+ evalCase.setupEngine(engine);
116
+ }
117
+
118
+ const events = [];
119
+ let fullText = '';
120
+ const toolCalls = [];
121
+ const toolResults = [];
122
+ let turnCount = 0;
123
+ let inputTokens = 0;
124
+ let outputTokens = 0;
125
+ let error = null;
126
+
127
+ const startTime = Date.now();
128
+
129
+ try {
130
+ for await (const event of engine.query({
131
+ prompt: evalCase.prompt,
132
+ mode: evalCase.mode || 'chat',
133
+ messages: evalCase.messages || [],
134
+ })) {
135
+ events.push(event);
136
+
137
+ switch (event.type) {
138
+ case 'text_delta':
139
+ fullText += event.text;
140
+ break;
141
+ case 'tool_call':
142
+ toolCalls.push(event);
143
+ break;
144
+ case 'tool_end':
145
+ toolResults.push(event);
146
+ break;
147
+ case 'turn_start':
148
+ turnCount++;
149
+ break;
150
+ case 'usage':
151
+ inputTokens += event.inputTokens || 0;
152
+ outputTokens += event.outputTokens || 0;
153
+ break;
154
+ case 'error':
155
+ error = event.error?.message || 'Unknown error';
156
+ break;
157
+ }
158
+ }
159
+ } catch (err) {
160
+ error = err.message;
161
+ }
162
+
163
+ const latencyMs = Date.now() - startTime;
164
+
165
+ return {
166
+ caseId: evalCase.id,
167
+ model,
168
+ events,
169
+ fullText,
170
+ toolCalls,
171
+ toolResults,
172
+ turnCount,
173
+ inputTokens,
174
+ outputTokens,
175
+ latencyMs,
176
+ error,
177
+ };
178
+ }
179
+
180
+ /**
181
+ * Score an eval run against its criteria.
182
+ *
183
+ * @param {EvalCase} evalCase
184
+ * @param {EvalRunResult} runResult
185
+ * @returns {EvalScore}
186
+ */
187
+ export function scoreEval(evalCase, runResult) {
188
+ const criteriaResults = {};
189
+ let weightedSum = 0;
190
+ let totalWeight = 0;
191
+
192
+ for (const criterion of evalCase.criteria) {
193
+ const result = criterion.score(runResult);
194
+ criteriaResults[criterion.id] = result;
195
+ weightedSum += result.score * criterion.weight;
196
+ totalWeight += criterion.weight;
197
+ }
198
+
199
+ return {
200
+ caseId: evalCase.id,
201
+ model: runResult.model,
202
+ totalScore: totalWeight > 0 ? Math.round((weightedSum / totalWeight) * 100) : 0,
203
+ criteria: criteriaResults,
204
+ raw: runResult,
205
+ };
206
+ }
207
+
208
+ /**
209
+ * Run multiple eval cases against multiple adapters.
210
+ *
211
+ * @param {{
212
+ * cases: EvalCase[],
213
+ * adapters: { name: string, adapter: object, config?: object }[],
214
+ * }} params
215
+ * @returns {Promise<EvalScore[]>}
216
+ */
217
+ export async function runEvals({ cases, adapters }) {
218
+ const allScores = [];
219
+
220
+ for (const { name: model, adapter, config } of adapters) {
221
+ for (const evalCase of cases) {
222
+ const runResult = await runSingleEval(evalCase, { adapter, model, config });
223
+ const score = scoreEval(evalCase, runResult);
224
+ allScores.push(score);
225
+ }
226
+ }
227
+
228
+ return allScores;
229
+ }
230
+
231
+ // ─── Baseline Management ─────────────────────────────────────
232
+
233
+ /**
234
+ * Save eval scores as a baseline for future comparison.
235
+ *
236
+ * @param {EvalScore[]} scores
237
+ * @param {string} baselineDir — directory to store baselines
238
+ * @param {string} [name] — baseline name (default: timestamp)
239
+ */
240
+ export function saveBaseline(scores, baselineDir, name) {
241
+ mkdirSync(baselineDir, { recursive: true });
242
+ const filename = `${name || new Date().toISOString().replace(/[:.]/g, '-')}.json`;
243
+ const baseline = {
244
+ timestamp: new Date().toISOString(),
245
+ name: name || 'unnamed',
246
+ scores: scores.map(s => ({
247
+ caseId: s.caseId,
248
+ model: s.model,
249
+ totalScore: s.totalScore,
250
+ criteria: s.criteria,
251
+ // Omit raw to keep baseline files small
252
+ })),
253
+ };
254
+ writeFileSync(join(baselineDir, filename), JSON.stringify(baseline, null, 2));
255
+ return join(baselineDir, filename);
256
+ }
257
+
258
+ /**
259
+ * Load a baseline file.
260
+ *
261
+ * @param {string} path
262
+ * @returns {object}
263
+ */
264
+ export function loadBaseline(path) {
265
+ return JSON.parse(readFileSync(path, 'utf8'));
266
+ }
267
+
268
+ /**
269
+ * Compare current scores against a baseline.
270
+ * Returns a list of regressions (cases where score dropped).
271
+ *
272
+ * @param {EvalScore[]} current
273
+ * @param {object} baseline — loaded baseline object
274
+ * @param {number} [threshold=5] — score drop threshold to flag as regression
275
+ * @returns {{ regressions: object[], improvements: object[], unchanged: object[] }}
276
+ */
277
+ export function compareToBaseline(current, baseline, threshold = 5) {
278
+ const baselineMap = new Map();
279
+ for (const s of baseline.scores) {
280
+ baselineMap.set(`${s.model}::${s.caseId}`, s);
281
+ }
282
+
283
+ const regressions = [];
284
+ const improvements = [];
285
+ const unchanged = [];
286
+
287
+ for (const score of current) {
288
+ const key = `${score.model}::${score.caseId}`;
289
+ const base = baselineMap.get(key);
290
+
291
+ if (!base) {
292
+ unchanged.push({ ...score, baseScore: null, delta: null, status: 'new' });
293
+ continue;
294
+ }
295
+
296
+ const delta = score.totalScore - base.totalScore;
297
+
298
+ if (delta < -threshold) {
299
+ regressions.push({
300
+ caseId: score.caseId,
301
+ model: score.model,
302
+ currentScore: score.totalScore,
303
+ baseScore: base.totalScore,
304
+ delta,
305
+ criteria: score.criteria,
306
+ baseCriteria: base.criteria,
307
+ });
308
+ } else if (delta > threshold) {
309
+ improvements.push({
310
+ caseId: score.caseId,
311
+ model: score.model,
312
+ currentScore: score.totalScore,
313
+ baseScore: base.totalScore,
314
+ delta,
315
+ });
316
+ } else {
317
+ unchanged.push({
318
+ caseId: score.caseId,
319
+ model: score.model,
320
+ currentScore: score.totalScore,
321
+ baseScore: base.totalScore,
322
+ delta,
323
+ });
324
+ }
325
+ }
326
+
327
+ return { regressions, improvements, unchanged };
328
+ }
329
+
330
+ // ─── Display ─────────────────────────────────────────────────
331
+
332
+ /**
333
+ * Print eval results as a formatted table.
334
+ *
335
+ * @param {EvalScore[]} scores
336
+ */
337
+ export function printResults(scores) {
338
+ // Group by model
339
+ const byModel = new Map();
340
+ for (const s of scores) {
341
+ if (!byModel.has(s.model)) byModel.set(s.model, []);
342
+ byModel.get(s.model).push(s);
343
+ }
344
+
345
+ for (const [model, modelScores] of byModel) {
346
+ console.log(`\n${'═'.repeat(60)}`);
347
+ console.log(` Model: ${model}`);
348
+ console.log(`${'═'.repeat(60)}`);
349
+
350
+ for (const s of modelScores) {
351
+ const icon = s.totalScore >= 80 ? '✅' : s.totalScore >= 50 ? '⚠️' : '❌';
352
+ console.log(`\n ${icon} ${s.caseId} — ${s.totalScore}/100`);
353
+
354
+ for (const [critId, crit] of Object.entries(s.criteria)) {
355
+ const critIcon = crit.pass ? ' ✓' : ' ✗';
356
+ console.log(` ${critIcon} ${critId}: ${Math.round(crit.score * 100)}%${crit.reason ? ` — ${crit.reason}` : ''}`);
357
+ }
358
+ }
359
+
360
+ const avg = Math.round(modelScores.reduce((sum, s) => sum + s.totalScore, 0) / modelScores.length);
361
+ console.log(`\n Average: ${avg}/100`);
362
+ }
363
+ }
364
+
365
+ /**
366
+ * Print baseline comparison results.
367
+ *
368
+ * @param {{ regressions: object[], improvements: object[], unchanged: object[] }} comparison
369
+ */
370
+ export function printComparison(comparison) {
371
+ const { regressions, improvements, unchanged } = comparison;
372
+
373
+ console.log(`\n${'═'.repeat(60)}`);
374
+ console.log(' Baseline Comparison');
375
+ console.log(`${'═'.repeat(60)}`);
376
+
377
+ if (regressions.length > 0) {
378
+ console.log(`\n 🔴 REGRESSIONS (${regressions.length}):`);
379
+ for (const r of regressions) {
380
+ console.log(` ${r.model} :: ${r.caseId}: ${r.baseScore} → ${r.currentScore} (${r.delta})`);
381
+ }
382
+ }
383
+
384
+ if (improvements.length > 0) {
385
+ console.log(`\n 🟢 IMPROVEMENTS (${improvements.length}):`);
386
+ for (const i of improvements) {
387
+ console.log(` ${i.model} :: ${i.caseId}: ${i.baseScore} → ${i.currentScore} (+${i.delta})`);
388
+ }
389
+ }
390
+
391
+ console.log(`\n ⚪ Unchanged: ${unchanged.length} cases`);
392
+
393
+ if (regressions.length > 0) {
394
+ console.log('\n ⛔ REGRESSION DETECTED — eval failed');
395
+ } else {
396
+ console.log('\n ✅ No regressions detected');
397
+ }
398
+ }
399
+
400
+ // ─── Criterion Helpers ───────────────────────────────────────
401
+
402
+ /** Check that no error occurred. */
403
+ export const noError = {
404
+ id: 'no-error',
405
+ description: 'No error during execution',
406
+ weight: 10,
407
+ score: (result) => ({
408
+ pass: !result.error,
409
+ score: result.error ? 0 : 1,
410
+ reason: result.error || undefined,
411
+ }),
412
+ };
413
+
414
+ /** Check that response contains expected text. */
415
+ export function containsText(text, opts = {}) {
416
+ return {
417
+ id: opts.id || `contains-${text.slice(0, 20)}`,
418
+ description: opts.description || `Response contains "${text}"`,
419
+ weight: opts.weight || 5,
420
+ score: (result) => {
421
+ const found = result.fullText.toLowerCase().includes(text.toLowerCase());
422
+ return { pass: found, score: found ? 1 : 0 };
423
+ },
424
+ };
425
+ }
426
+
427
+ /** Check that a specific tool was called. */
428
+ export function toolWasCalled(toolName, opts = {}) {
429
+ return {
430
+ id: opts.id || `tool-called-${toolName}`,
431
+ description: opts.description || `Tool "${toolName}" was called`,
432
+ weight: opts.weight || 8,
433
+ score: (result) => {
434
+ const called = result.toolCalls.some(tc => tc.name === toolName);
435
+ return { pass: called, score: called ? 1 : 0 };
436
+ },
437
+ };
438
+ }
439
+
440
+ /** Check that a tool was called with specific input. */
441
+ export function toolCalledWith(toolName, inputMatcher, opts = {}) {
442
+ return {
443
+ id: opts.id || `tool-input-${toolName}`,
444
+ description: opts.description || `Tool "${toolName}" called with correct input`,
445
+ weight: opts.weight || 6,
446
+ score: (result) => {
447
+ const call = result.toolCalls.find(tc => tc.name === toolName);
448
+ if (!call) return { pass: false, score: 0, reason: 'Tool not called' };
449
+ const match = inputMatcher(call.input);
450
+ return { pass: match, score: match ? 1 : 0 };
451
+ },
452
+ };
453
+ }
454
+
455
+ /** Check that a tool was NOT called. */
456
+ export function toolNotCalled(toolName, opts = {}) {
457
+ return {
458
+ id: opts.id || `tool-not-called-${toolName}`,
459
+ description: opts.description || `Tool "${toolName}" was NOT called`,
460
+ weight: opts.weight || 5,
461
+ score: (result) => {
462
+ const called = result.toolCalls.some(tc => tc.name === toolName);
463
+ return { pass: !called, score: called ? 0 : 1 };
464
+ },
465
+ };
466
+ }
467
+
468
+ /** Check that a tool succeeded (not error). */
469
+ export function toolSucceeded(toolName, opts = {}) {
470
+ return {
471
+ id: opts.id || `tool-success-${toolName}`,
472
+ description: opts.description || `Tool "${toolName}" succeeded`,
473
+ weight: opts.weight || 7,
474
+ score: (result) => {
475
+ const toolResult = result.toolResults.find(tr => tr.name === toolName);
476
+ if (!toolResult) return { pass: false, score: 0, reason: 'Tool not found in results' };
477
+ return { pass: !toolResult.isError, score: toolResult.isError ? 0 : 1, reason: toolResult.isError ? toolResult.output : undefined };
478
+ },
479
+ };
480
+ }
481
+
482
+ /** Check total turn count is within bounds. */
483
+ export function turnCountInRange(min, max, opts = {}) {
484
+ return {
485
+ id: opts.id || `turns-${min}-${max}`,
486
+ description: opts.description || `Turn count between ${min} and ${max}`,
487
+ weight: opts.weight || 3,
488
+ score: (result) => {
489
+ const inRange = result.turnCount >= min && result.turnCount <= max;
490
+ return { pass: inRange, score: inRange ? 1 : 0, reason: `${result.turnCount} turns` };
491
+ },
492
+ };
493
+ }
494
+
495
+ /** Check that response does NOT contain text. */
496
+ export function doesNotContain(text, opts = {}) {
497
+ return {
498
+ id: opts.id || `not-contains-${text.slice(0, 20)}`,
499
+ description: opts.description || `Response does NOT contain "${text}"`,
500
+ weight: opts.weight || 4,
501
+ score: (result) => {
502
+ const found = result.fullText.toLowerCase().includes(text.toLowerCase());
503
+ return { pass: !found, score: found ? 0 : 1 };
504
+ },
505
+ };
506
+ }
507
+
508
+ /** Check response length is within bounds (characters). */
509
+ export function responseLengthInRange(min, max, opts = {}) {
510
+ return {
511
+ id: opts.id || `length-${min}-${max}`,
512
+ description: opts.description || `Response length between ${min} and ${max} chars`,
513
+ weight: opts.weight || 2,
514
+ score: (result) => {
515
+ const len = result.fullText.length;
516
+ const inRange = len >= min && len <= max;
517
+ return { pass: inRange, score: inRange ? 1 : 0, reason: `${len} chars` };
518
+ },
519
+ };
520
+ }
521
+
522
+ /** Custom criterion with arbitrary scoring function. */
523
+ export function custom(id, description, weight, scoreFn) {
524
+ return { id, description, weight, score: scoreFn };
525
+ }
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';
package/unify/init.js CHANGED
@@ -23,56 +23,28 @@ const SUBDIRS = [
23
23
  'skills',
24
24
  ];
25
25
 
26
- /** Default config.md content (YAML frontmatter + markdown body). */
27
- const DEFAULT_CONFIG = `---
28
- model: claude-sonnet-4-20250514
29
- language: en
30
- debug: false
31
- maxContextTokens: 200000
32
- ---
33
-
34
- # Yeaft Config
35
-
36
- Edit the YAML frontmatter above to change settings.
37
- The \`model\` field is a model ID (e.g. \`gpt-5\`, \`claude-sonnet-4-20250514\`).
38
- Yeaft auto-detects the correct API adapter and endpoint from the model ID.
39
-
40
- ## Language
41
-
42
- Set \`language\` in frontmatter or \`YEAFT_LANGUAGE\` env var:
43
- - \`en\` — English system prompts (default)
44
- - \`zh\` — Chinese system prompts (中文系统提示)
45
-
46
- ## Model IDs
47
-
48
- - \`claude-sonnet-4-20250514\` (default)
49
- - \`claude-opus-4-20250514\`
50
- - \`gpt-5\`, \`gpt-5.4\`, \`gpt-4.1\`, \`gpt-4.1-mini\`
51
- - \`o3\`, \`o4-mini\`
52
- - \`deepseek-chat\`, \`deepseek-reasoner\`
53
- - \`gemini-2.5-pro\`, \`gemini-2.5-flash\`
54
-
55
- ## API Keys
56
-
57
- Store API keys in \`~/.yeaft/.env\` (recommended) or export as env vars:
58
-
59
- \`\`\`bash
60
- # ~/.yeaft/.env
61
- YEAFT_API_KEY=sk-ant-... # Anthropic
62
- YEAFT_OPENAI_API_KEY=sk-... # OpenAI / DeepSeek / Gemini
63
- \`\`\`
64
-
65
- ## Environment Variables
66
-
67
- Shell env vars take precedence over .env and config.md:
68
-
69
- - \`YEAFT_MODEL\` — override model ID
70
- - \`YEAFT_LANGUAGE\` — language for system prompts (en/zh)
71
- - \`YEAFT_API_KEY\` — Anthropic API key
72
- - \`YEAFT_OPENAI_API_KEY\` — OpenAI-compatible API key
73
- - \`YEAFT_PROXY_URL\` — CopilotProxy URL (default: http://localhost:6628)
74
- - \`YEAFT_DEBUG\` — enable debug mode (1/true)
75
- - \`YEAFT_DIR\` — data directory (default: ~/.yeaft)
26
+ /** Default config.json — generated on first init as default configuration. */
27
+ const DEFAULT_CONFIG_JSON = `{
28
+ "providers": [
29
+ {
30
+ "name": "my-proxy",
31
+ "baseUrl": "http://localhost:6628/v1",
32
+ "apiKey": "proxy",
33
+ "models": [
34
+ "claude-sonnet-4-20250514",
35
+ "claude-haiku-3-20250414",
36
+ "gpt-5",
37
+ "deepseek-chat"
38
+ ]
39
+ }
40
+ ],
41
+ "primaryModel": "my-proxy/claude-sonnet-4-20250514",
42
+ "fastModel": "my-proxy/claude-sonnet-4-20250514",
43
+ "language": "en",
44
+ "debug": false,
45
+ "maxContextTokens": 200000,
46
+ "messageTokenBudget": 8192
47
+ }
76
48
  `;
77
49
 
78
50
  /** Default MEMORY.md content. */
@@ -88,6 +60,23 @@ This file stores persistent memory entries. The agent will read and update this
88
60
 
89
61
  `;
90
62
 
63
+ /** Default mcp.json example — generated as reference for MCP server configuration. */
64
+ const DEFAULT_MCP_EXAMPLE = `{
65
+ "_comment": "MCP server configuration. Rename to mcp.json to enable.",
66
+ "_docs": "Each server needs 'name' + 'command'. Optional: 'args', 'env'.",
67
+ "servers": [
68
+ {
69
+ "name": "example-github",
70
+ "command": "npx",
71
+ "args": ["@modelcontextprotocol/server-github"],
72
+ "env": {
73
+ "GITHUB_TOKEN": "ghp_your_token_here"
74
+ }
75
+ }
76
+ ]
77
+ }
78
+ `;
79
+
91
80
  /** Default conversation/index.md content. */
92
81
  const DEFAULT_CONVERSATION_INDEX = `---
93
82
  lastMessageId: null
@@ -125,10 +114,11 @@ export function initYeaftDir(dir) {
125
114
  }
126
115
 
127
116
  // Create default files if they don't exist
128
- const configPath = join(root, 'config.md');
129
- if (!existsSync(configPath)) {
130
- writeFileSync(configPath, DEFAULT_CONFIG, 'utf8');
131
- created.push(configPath);
117
+ // config.json — default configuration (user edits this directly)
118
+ const configJsonPath = join(root, 'config.json');
119
+ if (!existsSync(configJsonPath)) {
120
+ writeFileSync(configJsonPath, DEFAULT_CONFIG_JSON, 'utf8');
121
+ created.push(configJsonPath);
132
122
  }
133
123
 
134
124
  const memoryPath = join(root, 'memory', 'MEMORY.md');
@@ -143,5 +133,12 @@ export function initYeaftDir(dir) {
143
133
  created.push(indexPath);
144
134
  }
145
135
 
136
+ // mcp.json.example — reference template for MCP server configuration
137
+ const mcpExamplePath = join(root, 'mcp.json.example');
138
+ if (!existsSync(mcpExamplePath)) {
139
+ writeFileSync(mcpExamplePath, DEFAULT_MCP_EXAMPLE, 'utf8');
140
+ created.push(mcpExamplePath);
141
+ }
142
+
146
143
  return { dir: root, created };
147
144
  }
@@ -143,10 +143,22 @@ export class LLMAdapter {
143
143
  /**
144
144
  * Create an LLM adapter based on configuration.
145
145
  *
146
+ * If config.providers exists (config.json path), creates an AdapterRouter
147
+ * that routes requests to the correct provider based on model ID.
148
+ *
149
+ * Otherwise falls back to legacy single-adapter creation from env vars.
150
+ *
146
151
  * @param {object} config — From loadConfig()
147
152
  * @returns {Promise<LLMAdapter>}
148
153
  */
149
154
  export async function createLLMAdapter(config) {
155
+ // ─── New path: config.json with providers ─────────────
156
+ if (config.providers && config.providers.length > 0) {
157
+ const { AdapterRouter } = await import('./router.js');
158
+ return new AdapterRouter({ providers: config.providers });
159
+ }
160
+
161
+ // ─── Legacy path: single adapter from env vars ────────
150
162
  // Normalize adapter name — accept 'chat-completions' as alias for 'openai'
151
163
  const adapter = config.adapter === 'chat-completions' ? 'openai' : config.adapter;
152
164