@yeaft/webchat-agent 0.1.934 → 0.1.936

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.
package/yeaft/skills.js CHANGED
@@ -19,12 +19,32 @@
19
19
  * - keywords: array of match keywords (alternative to trigger)
20
20
  * - platforms: array of ["macos", "linux", "windows"]
21
21
  *
22
+ * ─── Layered loading (Claude-Code-style) ─────────────────
23
+ *
24
+ * A `SkillManager` can scan multiple directories in priority order — later
25
+ * directories OVERRIDE earlier ones for skills with the same `name`. This
26
+ * mirrors Claude Code's plugin → user → project layering and lets a user
27
+ * customise a bundled skill without touching the bundled file.
28
+ *
29
+ * The standard tier order set up by `createSkillManager` is:
30
+ *
31
+ * tier 1 (bundled): wherever yeaft-skills is installed on disk — typically
32
+ * ~/.claude/skills/yeaft-skills/skills/. Read-only — `save()` and
33
+ * `remove()` never target this tier.
34
+ * tier 2 (user): <yeaftDir>/skills (e.g. ~/.yeaft/skills). User edits
35
+ * land here. `save()` writes here. `init.js` seeds it from tier 1
36
+ * on first boot so users start with the full bundled set.
37
+ * tier 3 (project): <workDir>/.yeaft/skills (if provided). Highest
38
+ * priority — a project can pin a skill version without affecting
39
+ * the user's other projects.
40
+ *
22
41
  * Reference: yeaft-yeaft-design.md §8, yeaft-yeaft-core-systems.md
23
42
  */
24
43
 
25
44
  import { existsSync, readFileSync, readdirSync, writeFileSync, unlinkSync, mkdirSync, statSync } from 'fs';
26
- import { join, basename, relative, dirname, sep } from 'path';
27
- import { platform } from 'os';
45
+ import { join, basename, sep, dirname, resolve } from 'path';
46
+ import { platform, homedir } from 'os';
47
+ import { fileURLToPath } from 'url';
28
48
 
29
49
  // ─── Platform Matching ────────────────────────────────────
30
50
 
@@ -61,6 +81,7 @@ export function matchesPlatform(platforms) {
61
81
  * @property {string} content — full skill instructions (markdown body)
62
82
  * @property {string} _source — 'file' | 'directory'
63
83
  * @property {string} _path — full path to skill file or directory
84
+ * @property {string} [_tier] — 'bundled' | 'user' | 'project' — origin tier this skill was loaded from
64
85
  * @property {string[]} [_references] — filenames in references/ dir
65
86
  * @property {string[]} [_templates] — filenames in templates/ dir
66
87
  */
@@ -316,48 +337,103 @@ function matchKeywords(keywords, prompt) {
316
337
  /**
317
338
  * SkillManager — loads, indexes, and queries skills.
318
339
  * Supports both single-file and directory-based skills.
340
+ *
341
+ * Layered loading: pass an array of directories (lowest priority → highest).
342
+ * Skills with the same `name` in a later directory OVERRIDE earlier entries.
343
+ * The user-writable tier (where `save()` / `remove()` operate) is configured
344
+ * via the `userDir` option and must match one of the entries in `dirs` —
345
+ * if it isn't given, the last entry in `dirs` is used as the user tier.
319
346
  */
320
347
  export class SkillManager {
321
348
  /** @type {Map<string, Skill>} */
322
349
  #skills = new Map();
323
350
 
351
+ /** @type {string[]} */
352
+ #skillsDirs;
353
+
324
354
  /** @type {string} */
325
- #skillsDir;
355
+ #userDir;
356
+
357
+ /** @type {Map<string, string>} — dir path → tier label */
358
+ #tierByDir;
326
359
 
327
360
  /**
328
- * @param {string} yeaftDirYeaft root directory (e.g. ~/.yeaft)
361
+ * @param {string | string[]} dirssingle directory (back-compat) or array of
362
+ * directories in priority order (lowest → highest). Falsy entries are
363
+ * filtered out so callers can write `[bundled, user, projectOrNull]`.
364
+ * @param {{ userDir?: string, tierByDir?: Record<string, string> }} [opts]
365
+ * userDir: directory where `save()` and `remove()` write. Defaults to the
366
+ * last entry in `dirs` (typical case: user dir is highest priority that
367
+ * isn't a per-project layer).
368
+ * tierByDir: optional label map — dir path → 'bundled' | 'user' | 'project'.
369
+ * Decorates each discovered Skill with `_tier` for diagnostics (Settings
370
+ * UI uses this to show "where this skill came from").
329
371
  */
330
- constructor(yeaftDir) {
331
- this.#skillsDir = join(yeaftDir, 'skills');
372
+ constructor(dirs, opts = {}) {
373
+ const list = Array.isArray(dirs)
374
+ ? dirs.filter(d => typeof d === 'string' && d.length > 0)
375
+ : (typeof dirs === 'string' && dirs.length > 0 ? [dirs] : []);
376
+ this.#skillsDirs = list;
377
+ // Default user-writable tier: explicit opt → last array entry → first
378
+ // entry → empty string. Empty string disables write attempts but the
379
+ // manager still loads.
380
+ this.#userDir = (opts && typeof opts.userDir === 'string' && opts.userDir.length > 0)
381
+ ? opts.userDir
382
+ : (list.length > 0 ? list[list.length - 1] : '');
383
+ this.#tierByDir = new Map();
384
+ if (opts && opts.tierByDir && typeof opts.tierByDir === 'object') {
385
+ for (const [d, tier] of Object.entries(opts.tierByDir)) {
386
+ if (typeof d === 'string' && typeof tier === 'string') {
387
+ this.#tierByDir.set(d, tier);
388
+ }
389
+ }
390
+ }
332
391
  }
333
392
 
334
- /** The skills root directory path. */
393
+ /** The user-writable skills directory (save/remove target). */
335
394
  get skillsDir() {
336
- return this.#skillsDir;
395
+ return this.#userDir;
396
+ }
397
+
398
+ /** All directories scanned, in priority order (lowest → highest). */
399
+ get skillsDirs() {
400
+ return [...this.#skillsDirs];
337
401
  }
338
402
 
339
403
  /**
340
- * Load all skills from the skills directory (recursive).
404
+ * Load all skills from all configured directories.
405
+ *
406
+ * Lower-priority directories load first; later (higher-priority) entries
407
+ * with the same skill `name` overwrite earlier ones. Each loaded skill is
408
+ * tagged with `_tier` (from the constructor's `tierByDir` map, or the dir
409
+ * basename as fallback) so consumers can show provenance.
341
410
  *
342
411
  * @returns {{ loaded: number, errors: string[] }}
343
412
  */
344
413
  load() {
345
414
  this.#skills.clear();
415
+ const allErrors = [];
346
416
 
347
- if (!existsSync(this.#skillsDir)) {
417
+ if (this.#skillsDirs.length === 0) {
348
418
  return { loaded: 0, errors: [] };
349
419
  }
350
420
 
351
- const { skills, errors } = discoverSkills(this.#skillsDir);
352
-
353
- for (const skill of skills) {
354
- // Platform filtering at load time
355
- if (matchesPlatform(skill.platforms)) {
421
+ for (const dir of this.#skillsDirs) {
422
+ if (!existsSync(dir)) continue;
423
+ const { skills, errors } = discoverSkills(dir);
424
+ const tier = this.#tierByDir.get(dir) || basename(dir);
425
+ for (const skill of skills) {
426
+ // Platform filtering at load time
427
+ if (!matchesPlatform(skill.platforms)) continue;
428
+ skill._tier = tier;
429
+ // Later (higher-priority) tier overrides earlier entries with the
430
+ // same name — this is the layered-load contract.
356
431
  this.#skills.set(skill.name, skill);
357
432
  }
433
+ allErrors.push(...errors);
358
434
  }
359
435
 
360
- return { loaded: this.#skills.size, errors };
436
+ return { loaded: this.#skills.size, errors: allErrors };
361
437
  }
362
438
 
363
439
  /**
@@ -390,7 +466,7 @@ export class SkillManager {
390
466
  * surfaced for historic YAML compatibility.
391
467
  *
392
468
  * @param {string} [_mode] — deprecated, ignored
393
- * @returns {Array<{ name: string, description: string, trigger: string, mode: string, category?: string, platforms?: string[], keywords?: string[], source: string, hasReferences: boolean, hasTemplates: boolean }>}
469
+ * @returns {Array<{ name: string, description: string, trigger: string, mode: string, category?: string, platforms?: string[], keywords?: string[], source: string, tier?: string, hasReferences: boolean, hasTemplates: boolean }>}
394
470
  */
395
471
  list(_mode) {
396
472
  const skills = [...this.#skills.values()];
@@ -404,6 +480,7 @@ export class SkillManager {
404
480
  platforms: s.platforms || undefined,
405
481
  keywords: s.keywords || undefined,
406
482
  source: s._source,
483
+ tier: s._tier || undefined,
407
484
  hasReferences: (s._references && s._references.length > 0) || false,
408
485
  hasTemplates: (s._templates && s._templates.length > 0) || false,
409
486
  }));
@@ -428,10 +505,13 @@ export class SkillManager {
428
505
 
429
506
  // Read a specific linked file if requested
430
507
  if (filePath && skill._source === 'directory') {
431
- const fullPath = join(skill._path, filePath);
432
- // Security: ensure path doesn't escape skill directory
433
- const resolved = join(skill._path, filePath);
434
- if (!resolved.startsWith(skill._path)) {
508
+ // Security: resolve both ends to absolute paths and require fullPath to
509
+ // sit under the skill root (separator-anchored so /foo-evil isn't seen
510
+ // as a child of /foo). `path.join` alone collapses `..` but does not
511
+ // detect symlink-escapes or absolute-path overrides.
512
+ const fullPath = resolve(skill._path, filePath);
513
+ const root = resolve(skill._path) + sep;
514
+ if (fullPath !== resolve(skill._path) && !fullPath.startsWith(root)) {
435
515
  result.linkedContent = 'Error: path traversal not allowed';
436
516
  } else if (existsSync(fullPath)) {
437
517
  try {
@@ -494,21 +574,36 @@ export class SkillManager {
494
574
  /**
495
575
  * Add or update a skill (single-file format).
496
576
  *
577
+ * Always writes to the USER tier (`#userDir`) regardless of where the
578
+ * existing skill (if any) came from. This matches Claude Code: editing a
579
+ * bundled skill produces a user-tier override, leaving the bundled file
580
+ * untouched. Calling `load()` after a `save()` will then surface the
581
+ * user version (higher priority).
582
+ *
497
583
  * @param {Skill} skill
498
584
  * @returns {string} — filename
499
585
  */
500
586
  save(skill) {
501
587
  if (!skill.name) throw new Error('Skill must have a name');
588
+ if (!this.#userDir) {
589
+ throw new Error('SkillManager has no writable user directory configured');
590
+ }
502
591
 
503
592
  const filename = `${skill.name}.md`;
504
- const filePath = join(this.#skillsDir, filename);
593
+ const filePath = join(this.#userDir, filename);
505
594
 
506
- if (!existsSync(this.#skillsDir)) {
507
- mkdirSync(this.#skillsDir, { recursive: true });
595
+ if (!existsSync(this.#userDir)) {
596
+ mkdirSync(this.#userDir, { recursive: true });
508
597
  }
509
598
 
510
599
  writeFileSync(filePath, serializeSkill(skill), 'utf8');
511
- this.#skills.set(skill.name, { ...skill, _source: 'file', _path: filePath });
600
+ const userTier = this.#tierByDir.get(this.#userDir) || basename(this.#userDir);
601
+ this.#skills.set(skill.name, {
602
+ ...skill,
603
+ _source: 'file',
604
+ _path: filePath,
605
+ _tier: userTier,
606
+ });
512
607
 
513
608
  return filename;
514
609
  }
@@ -516,6 +611,11 @@ export class SkillManager {
516
611
  /**
517
612
  * Remove a skill (supports both file and directory skills).
518
613
  *
614
+ * Only removes user-tier files. Bundled / project-tier skills are
615
+ * read-only — attempting to remove one returns `false` and leaves the
616
+ * file alone (the in-memory entry is also kept so a subsequent `load()`
617
+ * still picks it up).
618
+ *
519
619
  * @param {string} name
520
620
  * @returns {boolean}
521
621
  */
@@ -523,13 +623,20 @@ export class SkillManager {
523
623
  const skill = this.#skills.get(name);
524
624
  if (!skill) return false;
525
625
 
626
+ // Only allow removing files inside the user-writable directory. A
627
+ // bundled or project-tier file would silently come back on the next
628
+ // load() anyway; refusing here makes the failure obvious.
629
+ if (!this.#userDir || !skill._path || !skill._path.startsWith(this.#userDir)) {
630
+ return false;
631
+ }
632
+
526
633
  if (skill._source === 'directory' && skill._path) {
527
634
  // For directory skills, we only delete the SKILL.md to "deactivate"
528
635
  // Full directory removal is left to the user (too dangerous to rm -rf)
529
636
  const skillMd = join(skill._path, 'SKILL.md');
530
637
  try { unlinkSync(skillMd); } catch { /* noop */ }
531
638
  } else {
532
- const filePath = skill._path || join(this.#skillsDir, `${name}.md`);
639
+ const filePath = skill._path || join(this.#userDir, `${name}.md`);
533
640
  try { unlinkSync(filePath); } catch { /* noop */ }
534
641
  }
535
642
 
@@ -584,13 +691,89 @@ export class SkillManager {
584
691
  }
585
692
 
586
693
  /**
587
- * Create a SkillManager and load skills.
694
+ * Create a SkillManager wired with the standard layered tier list and load it.
695
+ *
696
+ * Tier order (lowest → highest priority):
697
+ * 1. bundled — the yeaft-skills package on disk, located via
698
+ * `bundledYeaftSkillsDir()` (typically ~/.claude/skills/yeaft-skills/skills/).
699
+ * 2. user — `<yeaftDir>/skills` (e.g. ~/.yeaft/skills). User edits + saves.
700
+ * 3. project — `<workDir>/.yeaft/skills` when a workDir is provided.
701
+ *
702
+ * `save()` / `remove()` always target the USER tier, matching Claude Code.
588
703
  *
589
- * @param {string} yeaftDir
704
+ * @param {string} yeaftDir — Yeaft data dir (user tier root)
705
+ * @param {string} [workDir] — optional project working directory (project tier root)
590
706
  * @returns {SkillManager}
591
707
  */
592
- export function createSkillManager(yeaftDir) {
593
- const manager = new SkillManager(yeaftDir);
708
+ export function createSkillManager(yeaftDir, workDir) {
709
+ const bundled = bundledYeaftSkillsDir();
710
+ const userDir = join(yeaftDir, 'skills');
711
+ const projectDir = workDir ? join(workDir, '.yeaft', 'skills') : null;
712
+
713
+ const dirs = [bundled, userDir, projectDir].filter(Boolean);
714
+ const tierByDir = {};
715
+ if (bundled) tierByDir[bundled] = 'bundled';
716
+ tierByDir[userDir] = 'user';
717
+ if (projectDir) tierByDir[projectDir] = 'project';
718
+
719
+ const manager = new SkillManager(dirs, { userDir, tierByDir });
594
720
  manager.load();
595
721
  return manager;
596
722
  }
723
+
724
+ // ─── Bundled-skills resolver ──────────────────────────────
725
+
726
+ /**
727
+ * Locate the bundled `yeaft-skills` package on disk.
728
+ *
729
+ * Resolution order (first existing directory wins):
730
+ * 1. $YEAFT_SKILLS_BUNDLED_DIR — explicit env override (testing / packaging)
731
+ * 2. ~/.claude/skills/yeaft-skills/skills/ — standard Claude Code plugin layout
732
+ * 3. ~/.claude/plugins/yeaft-skills/skills/ — alternate plugin layout
733
+ * 4. <agent-pkg>/skills/ — bundled-with-agent fallback so
734
+ * a future npm release can ship its own skills directory
735
+ *
736
+ * Returns `null` when none exist — callers must tolerate this (Yeaft still
737
+ * runs; the user just sees no pre-installed bundled skills). Co-located here
738
+ * (rather than in init.js) so both `createSkillManager()` and init.js's seed
739
+ * step can call it without a module cycle.
740
+ *
741
+ * @returns {string|null}
742
+ */
743
+ export function bundledYeaftSkillsDir() {
744
+ const candidates = [];
745
+
746
+ const envOverride = process.env.YEAFT_SKILLS_BUNDLED_DIR;
747
+ if (envOverride && typeof envOverride === 'string' && envOverride.length > 0) {
748
+ candidates.push(envOverride);
749
+ }
750
+
751
+ const home = homedir();
752
+ if (home) {
753
+ candidates.push(join(home, '.claude', 'skills', 'yeaft-skills', 'skills'));
754
+ candidates.push(join(home, '.claude', 'plugins', 'yeaft-skills', 'skills'));
755
+ }
756
+
757
+ // Bundled-with-agent fallback: <agent-pkg-root>/skills. The package root is
758
+ // the directory containing agent/, so we walk up from this file.
759
+ try {
760
+ const here = dirname(fileURLToPath(import.meta.url));
761
+ // here = .../agent/yeaft
762
+ const agentRoot = join(here, '..', '..');
763
+ candidates.push(join(agentRoot, 'skills'));
764
+ } catch {
765
+ // fileURLToPath can throw on exotic loaders — non-fatal.
766
+ }
767
+
768
+ for (const c of candidates) {
769
+ try {
770
+ if (c && existsSync(c) && statSync(c).isDirectory()) {
771
+ return c;
772
+ }
773
+ } catch {
774
+ // permission errors etc. — try the next candidate.
775
+ }
776
+ }
777
+
778
+ return null;
779
+ }
@@ -65,6 +65,9 @@ export async function runStopHooks(context) {
65
65
  // history replay can route messages back into the originating group.
66
66
  sessionId,
67
67
  threadId,
68
+ // Group VP attribution: persist the engine-bound VP id on assistant/tool
69
+ // rows so history replay can route replies back to the same visible VP.
70
+ vpId,
68
71
  // Multi-VP fan-out (history-dedup): when several engines run the
69
72
  // same user prompt in parallel, the orchestrator persists the user
70
73
  // row exactly once before fan-out. Each VP's stop-hook then skips
@@ -164,6 +167,9 @@ export async function runStopHooks(context) {
164
167
  // Bug 6: stamp sessionId / threadId so replay can re-route by group.
165
168
  if (sessionId) record.sessionId = sessionId;
166
169
  if (threadId) record.threadId = threadId;
170
+ if (vpId && (msg.role === 'assistant' || msg.role === 'tool')) {
171
+ record.speakerVpId = vpId;
172
+ }
167
173
  conversationStore.append(record);
168
174
  result.messagesPersisted++;
169
175
  }
@@ -11,7 +11,14 @@
11
11
  import { ToolRegistry } from './registry.js';
12
12
 
13
13
  // --- Existing tools ---
14
- import mcpTools from './mcp-tools.js';
14
+ // NOTE: MCP tools are no longer auto-registered here. The mcp_list_tools /
15
+ // mcp_call_tool meta-tools from `./mcp-tools.js` are kept exported for
16
+ // back-compat, but the default registry now ships only flattened MCP tools
17
+ // (mcp__<server>__<tool>), registered in session.js after the MCPManager
18
+ // has connected to its configured servers. See agent/yeaft/session.js for
19
+ // the flatten-and-register step, and agent/yeaft/tools/mcp-tools.js for
20
+ // the `buildMcpFlattenedTools(mcpManager)` builder used to construct them
21
+ // on first connect and on hot-reload.
15
22
  import skillTool from './skill.js';
16
23
  import enterWorktree from './enter-worktree.js';
17
24
  import exitWorktree from './exit-worktree.js';
@@ -73,7 +80,6 @@ import viewImage from './view-image.js';
73
80
  */
74
81
  export const allTools = [
75
82
  // Existing tools
76
- ...mcpTools,
77
83
  skillTool,
78
84
  enterWorktree,
79
85
  exitWorktree,
@@ -1,17 +1,138 @@
1
1
  /**
2
2
  * mcp-tools.js — MCP (Model Context Protocol) tool bridge
3
3
  *
4
- * Provides two tools for interacting with MCP servers:
5
- * - mcp_list_tools: Discover available MCP tools from connected servers
6
- * - mcp_call_tool: Invoke an MCP tool by name with arguments
4
+ * Provides TWO surfaces for exposing MCP tools to the LLM:
7
5
  *
8
- * These are exported as an array (unlike other tool files that export a single tool).
6
+ * 1. **Flattened tools (preferred)** every MCP tool is registered as a
7
+ * first-class entry in the ToolRegistry under the canonical
8
+ * `mcp__<server>__<originalToolName>` name (Claude Code convention).
9
+ * The LLM sees these in its tool catalogue and can call them in a
10
+ * single turn — no `mcp_list_tools` / `mcp_call_tool` indirection.
11
+ * Use `buildMcpFlattenedTools(mcpManager)` to obtain the registration
12
+ * array, then `toolRegistry.registerAll(...)`. Hot-reload after a
13
+ * server connect/disconnect via `toolRegistry.replaceMcpTools(mcpManager)`.
14
+ *
15
+ * 2. **Meta tools (back-compat / fallback)** — `mcp_list_tools` and
16
+ * `mcp_call_tool` are still exported as standalone ToolDefs. They are
17
+ * NOT in the default tool registry anymore; a caller can opt them
18
+ * back in by importing and registering them explicitly. Kept for any
19
+ * external integration that depends on the old indirection shape.
9
20
  *
10
21
  * Reference: yeaft-yeaft-design.md §8
11
22
  */
12
23
 
13
24
  import { defineTool } from './types.js';
14
25
 
26
+ // ─── Constants ─────────────────────────────────────────────
27
+
28
+ /**
29
+ * Cap each flattened MCP tool's description at this many characters when
30
+ * registering. The system-prompt tool catalogue inflates linearly with
31
+ * description length and the LLM doesn't need 2KB of vendor-prose per
32
+ * tool — the inputSchema is what tells it how to call. 256 is enough to
33
+ * communicate intent for ~all real-world MCP tools and bounds worst-case
34
+ * prompt growth at `256 * num_mcp_tools` chars.
35
+ */
36
+ const MAX_DESCRIPTION_LENGTH = 256;
37
+
38
+ /**
39
+ * Truncate a description for the tool catalogue.
40
+ * @param {string} desc
41
+ * @returns {string}
42
+ */
43
+ function truncateDescription(desc) {
44
+ const s = String(desc || '');
45
+ if (s.length <= MAX_DESCRIPTION_LENGTH) return s;
46
+ return s.slice(0, MAX_DESCRIPTION_LENGTH - 1) + '…';
47
+ }
48
+
49
+ /**
50
+ * Best-effort serialize an MCP tool result into a string the engine can
51
+ * forward as a tool_result message. MCP's standard `content` array of
52
+ * `{type:'text', text}` parts is concatenated; anything else falls back
53
+ * to JSON.
54
+ *
55
+ * @param {unknown} result
56
+ * @returns {string}
57
+ */
58
+ function formatMcpResult(result) {
59
+ if (result && typeof result === 'object' && Array.isArray(result.content)) {
60
+ const textParts = result.content
61
+ .filter(c => c && c.type === 'text')
62
+ .map(c => c.text);
63
+ if (textParts.length > 0) return textParts.join('\n');
64
+ }
65
+ if (typeof result === 'string') return result;
66
+ try {
67
+ return JSON.stringify(result, null, 2);
68
+ } catch {
69
+ return String(result);
70
+ }
71
+ }
72
+
73
+ // ─── Flatten builder (preferred surface) ───────────────────
74
+
75
+ /**
76
+ * Build a `defineTool(...)` registration for every tool exposed by the
77
+ * given MCPManager. Tools are named `mcp__<server>__<originalName>`
78
+ * matching Claude Code's convention so an LLM that's seen this pattern
79
+ * before treats them identically across hosts.
80
+ *
81
+ * The execute function captures the MANAGER reference (not the individual
82
+ * server / tool entry) so that even if the user disconnects and
83
+ * reconnects the underlying server between registration and call, the
84
+ * lookup goes through `mcpManager.callTool(fullName, ...)` and finds the
85
+ * fresh connection.
86
+ *
87
+ * Token-budget note: descriptions are truncated to MAX_DESCRIPTION_LENGTH
88
+ * chars before being attached. The full description stays accessible to
89
+ * code via mcpManager.listTools() if a future feature wants it.
90
+ *
91
+ * @param {import('../mcp.js').MCPManager} mcpManager
92
+ * @returns {import('./types.js').ToolDef[]}
93
+ */
94
+ export function buildMcpFlattenedTools(mcpManager) {
95
+ if (!mcpManager || typeof mcpManager.listTools !== 'function') {
96
+ return [];
97
+ }
98
+
99
+ const tools = mcpManager.listTools();
100
+ return tools.map(t => {
101
+ // mcpManager.listTools() returns entries shaped as
102
+ // { name: '<server>__<tool>', server: '<server>', description, inputSchema }
103
+ // Pull the original tool name from the suffix; the manager's callTool
104
+ // expects the full `server__tool` name.
105
+ const fullName = t.name;
106
+ const flattenedName = `mcp__${fullName}`;
107
+
108
+ return defineTool({
109
+ name: flattenedName,
110
+ description: truncateDescription(
111
+ t.description || `MCP tool ${fullName.split('__').slice(1).join('__')} from server ${t.server}`
112
+ ),
113
+ parameters: t.inputSchema || { type: 'object', properties: {} },
114
+ async execute(input = {}, _ctx) {
115
+ // Look up the manager fresh on each call. We deliberately don't
116
+ // close over a server reference — hot-reload may have replaced
117
+ // the connection since registration.
118
+ //
119
+ // Errors are THROWN, not stringified into the result. The engine's
120
+ // tool-execution catch (engine.js: `catch (err) { output = 'Error: …';
121
+ // isError = true; }`) turns thrown errors into a `tool_result` with
122
+ // `is_error: true` so the LLM sees an error signal rather than a
123
+ // plausible-looking JSON blob it might mistake for normal output.
124
+ if (!mcpManager || typeof mcpManager.callTool !== 'function') {
125
+ throw new Error(`MCP manager not available for ${flattenedName}`);
126
+ }
127
+ const result = await mcpManager.callTool(fullName, input || {});
128
+ return formatMcpResult(result);
129
+ },
130
+ });
131
+ });
132
+ }
133
+
134
+ // ─── Meta tools (legacy / fallback surface) ────────────────
135
+
15
136
  export const mcpListTools = defineTool({
16
137
  name: 'mcp_list_tools',
17
138
  description: `List all tools available from connected MCP (Model Context Protocol) servers.
@@ -39,11 +160,11 @@ Usage guidelines:
39
160
  if (!mcpManager || !mcpManager.hasServers) {
40
161
  return JSON.stringify({
41
162
  tools: [],
42
- message: 'No MCP servers are connected. Configure MCP servers in ~/.yeaft/config.md',
163
+ message: 'No MCP servers are connected. Configure MCP servers in ~/.yeaft/config.json',
43
164
  });
44
165
  }
45
166
 
46
- const tools = mcpManager.listTools(input.server || undefined);
167
+ const tools = mcpManager.listTools(input?.server || undefined);
47
168
 
48
169
  return JSON.stringify({
49
170
  tools: tools.map(t => ({
@@ -91,12 +212,16 @@ Usage guidelines:
91
212
  const mcpManager = ctx?.mcpManager;
92
213
 
93
214
  if (!mcpManager || !mcpManager.hasServers) {
215
+ // Same shape used by the meta tools historically — kept as a JSON
216
+ // payload (NOT throw) because callers of `mcp_call_tool` already pattern-
217
+ // match on the `error` field. Flattened tools (the preferred surface)
218
+ // throw instead; see buildMcpFlattenedTools above.
94
219
  return JSON.stringify({
95
- error: 'No MCP servers are connected. Configure MCP servers in ~/.yeaft/config.md',
220
+ error: 'No MCP servers are connected. Configure MCP servers in ~/.yeaft/config.json',
96
221
  });
97
222
  }
98
223
 
99
- const { tool_name, arguments: args = {}, timeout_ms } = input;
224
+ const { tool_name, arguments: args = {}, timeout_ms } = input || {};
100
225
 
101
226
  if (!tool_name) {
102
227
  return JSON.stringify({ error: 'tool_name is required' });
@@ -104,20 +229,7 @@ Usage guidelines:
104
229
 
105
230
  try {
106
231
  const result = await mcpManager.callTool(tool_name, args, timeout_ms || 30000);
107
-
108
- // Format MCP result
109
- if (result?.content) {
110
- // MCP standard response format
111
- const textParts = result.content
112
- .filter(c => c.type === 'text')
113
- .map(c => c.text);
114
- if (textParts.length > 0) {
115
- return textParts.join('\n');
116
- }
117
- }
118
-
119
- return typeof result === 'string' ? result : JSON.stringify(result, null, 2);
120
-
232
+ return formatMcpResult(result);
121
233
  } catch (err) {
122
234
  return JSON.stringify({
123
235
  error: err.message,
@@ -127,5 +239,11 @@ Usage guidelines:
127
239
  },
128
240
  });
129
241
 
130
- // Default export as array for compatibility with bulk registration
242
+ /**
243
+ * Legacy default export: the meta tools as an array. Kept so any existing
244
+ * caller doing `import mcpTools from './mcp-tools.js'` still works, but
245
+ * `tools/index.js` no longer ships these in `createFullRegistry()`.
246
+ * Flattened tools (preferred) are registered by `session.js` after the
247
+ * MCPManager has connected to its servers.
248
+ */
131
249
  export default [mcpListTools, mcpCallTool];
@@ -359,6 +359,48 @@ export class ToolRegistry {
359
359
  get names() {
360
360
  return this.getAllTools().map(t => t.name);
361
361
  }
362
+
363
+ /**
364
+ * Hot-swap the flattened MCP tool set.
365
+ *
366
+ * Removes every currently registered tool whose canonical name starts
367
+ * with `mcp__` (the flattened MCP tool naming convention used by
368
+ * `buildMcpFlattenedTools()`), then re-registers a fresh set built from
369
+ * the live MCPManager. Called by the MCP web-bridge after a successful
370
+ * connect/disconnect/reload so the running engine's next turn sees the
371
+ * new tool catalogue without needing a full session restart.
372
+ *
373
+ * Why "starts with `mcp__`": that prefix is the canonical Claude Code
374
+ * naming for flattened MCP tools. It cleanly distinguishes them from
375
+ * built-in tools (Bash, FileRead, etc.) and from the legacy meta-tools
376
+ * (`mcp_list_tools`, `mcp_call_tool` — single underscore, NOT removed
377
+ * here) which a caller may still opt into.
378
+ *
379
+ * @param {import('../mcp.js').MCPManager} mcpManager
380
+ * @param {(mgr: import('../mcp.js').MCPManager) => import('./types.js').ToolDef[]} buildFlattened
381
+ * — the builder from `./mcp-tools.js`. Injected so this registry file
382
+ * stays free of circular `import` to mcp-tools.js (mcp-tools imports
383
+ * `defineTool` from types.js, which lives alongside this file).
384
+ * @returns {{ removed: number, added: number }}
385
+ */
386
+ replaceMcpTools(mcpManager, buildFlattened) {
387
+ let removed = 0;
388
+ for (const name of [...this.#tools.keys()]) {
389
+ if (name.startsWith('mcp__')) {
390
+ this.#tools.delete(name);
391
+ removed += 1;
392
+ }
393
+ }
394
+ let added = 0;
395
+ if (typeof buildFlattened === 'function' && mcpManager) {
396
+ const fresh = buildFlattened(mcpManager) || [];
397
+ for (const tool of fresh) {
398
+ this.register(tool);
399
+ added += 1;
400
+ }
401
+ }
402
+ return { removed, added };
403
+ }
362
404
  }
363
405
 
364
406
  /**