@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/connection/message-router.js +22 -1
- package/package.json +1 -1
- package/yeaft/config-api.js +192 -0
- package/yeaft/conversation/persist.js +12 -1
- package/yeaft/engine.js +1 -0
- package/yeaft/init.js +208 -4
- package/yeaft/session.js +26 -2
- package/yeaft/skills.js +213 -30
- package/yeaft/stop-hooks.js +6 -0
- package/yeaft/tools/index.js +8 -2
- package/yeaft/tools/mcp-tools.js +141 -23
- package/yeaft/tools/registry.js +42 -0
- package/yeaft/web-bridge.js +232 -0
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,
|
|
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
|
-
#
|
|
355
|
+
#userDir;
|
|
356
|
+
|
|
357
|
+
/** @type {Map<string, string>} — dir path → tier label */
|
|
358
|
+
#tierByDir;
|
|
326
359
|
|
|
327
360
|
/**
|
|
328
|
-
* @param {string}
|
|
361
|
+
* @param {string | string[]} dirs — single 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(
|
|
331
|
-
|
|
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
|
|
393
|
+
/** The user-writable skills directory (save/remove target). */
|
|
335
394
|
get skillsDir() {
|
|
336
|
-
return this.#
|
|
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
|
|
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 (
|
|
417
|
+
if (this.#skillsDirs.length === 0) {
|
|
348
418
|
return { loaded: 0, errors: [] };
|
|
349
419
|
}
|
|
350
420
|
|
|
351
|
-
const
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|
-
|
|
432
|
-
//
|
|
433
|
-
|
|
434
|
-
|
|
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.#
|
|
593
|
+
const filePath = join(this.#userDir, filename);
|
|
505
594
|
|
|
506
|
-
if (!existsSync(this.#
|
|
507
|
-
mkdirSync(this.#
|
|
595
|
+
if (!existsSync(this.#userDir)) {
|
|
596
|
+
mkdirSync(this.#userDir, { recursive: true });
|
|
508
597
|
}
|
|
509
598
|
|
|
510
599
|
writeFileSync(filePath, serializeSkill(skill), 'utf8');
|
|
511
|
-
this.#
|
|
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.#
|
|
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
|
|
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
|
|
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
|
+
}
|
package/yeaft/stop-hooks.js
CHANGED
|
@@ -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
|
}
|
package/yeaft/tools/index.js
CHANGED
|
@@ -11,7 +11,14 @@
|
|
|
11
11
|
import { ToolRegistry } from './registry.js';
|
|
12
12
|
|
|
13
13
|
// --- Existing tools ---
|
|
14
|
-
|
|
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,
|
package/yeaft/tools/mcp-tools.js
CHANGED
|
@@ -1,17 +1,138 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* mcp-tools.js — MCP (Model Context Protocol) tool bridge
|
|
3
3
|
*
|
|
4
|
-
* Provides
|
|
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
|
-
*
|
|
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.
|
|
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
|
|
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.
|
|
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
|
-
|
|
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];
|
package/yeaft/tools/registry.js
CHANGED
|
@@ -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
|
/**
|