@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
|
@@ -37,7 +37,7 @@ import { handleRestartAgent, handleUpgradeAgent } from './upgrade.js';
|
|
|
37
37
|
import { loadMcpServers, updateMcpConfig } from '../mcp.js';
|
|
38
38
|
import { getLlmConfig, updateLlmConfig, getYeaftSettings, updateYeaftSettings, getSearchSettings, updateSearchSettings, fetchTavilyUsage } from '../yeaft/config-api.js';
|
|
39
39
|
import { fetchModelsDev } from '../yeaft/llm/models-dev.js';
|
|
40
|
-
import { handleYeaftSessionSend, handleYeaftModeSwitch, handleYeaftModelSwitch, resetYeaftSession, handleYeaftLoadHistory, handleYeaftLoadMoreHistory, handleYeaftAbortThread, handleYeaftAbortAll, handleYeaftAbortTurn, handleYeaftVpSubscribe, handleYeaftVpCreate, handleYeaftVpUpdate, handleYeaftVpDelete, handleYeaftVpRead, handleYeaftListSessions, handleYeaftCreateSession, handleYeaftRenameSession, handleYeaftUpdateSession, handleYeaftUpdateSessionConfig, handleYeaftArchiveSession, handleYeaftDeleteSession, handleYeaftSessionAddMember, handleYeaftSessionRemoveMember, handleYeaftSessionSetDefaultVp, handleYeaftScanWorkdirSessions, handleYeaftRestoreSession, handleYeaftDreamTrigger, handleYeaftFetchToolStats, handleYeaftFetchDebugHistory, broadcastLanguageChange, broadcastYeaftSessionSnapshotEager } from '../yeaft/web-bridge.js';
|
|
40
|
+
import { handleYeaftSessionSend, handleYeaftModeSwitch, handleYeaftModelSwitch, resetYeaftSession, handleYeaftLoadHistory, handleYeaftLoadMoreHistory, handleYeaftAbortThread, handleYeaftAbortAll, handleYeaftAbortTurn, handleYeaftVpSubscribe, handleYeaftVpCreate, handleYeaftVpUpdate, handleYeaftVpDelete, handleYeaftVpRead, handleYeaftListSessions, handleYeaftCreateSession, handleYeaftRenameSession, handleYeaftUpdateSession, handleYeaftUpdateSessionConfig, handleYeaftArchiveSession, handleYeaftDeleteSession, handleYeaftSessionAddMember, handleYeaftSessionRemoveMember, handleYeaftSessionSetDefaultVp, handleYeaftScanWorkdirSessions, handleYeaftRestoreSession, handleYeaftDreamTrigger, handleYeaftFetchToolStats, handleYeaftFetchDebugHistory, handleYeaftMcpList, handleYeaftMcpAdd, handleYeaftMcpRemove, handleYeaftMcpReload, broadcastLanguageChange, broadcastYeaftSessionSnapshotEager } from '../yeaft/web-bridge.js';
|
|
41
41
|
import { startYeaftStatusRefresh, refreshYeaftStatus } from '../yeaft/status-cache.js';
|
|
42
42
|
|
|
43
43
|
export async function handleMessage(msg) {
|
|
@@ -441,6 +441,27 @@ export async function handleMessage(msg) {
|
|
|
441
441
|
break;
|
|
442
442
|
}
|
|
443
443
|
|
|
444
|
+
// Yeaft MCP CRUD (Claude-Code-style Settings → MCP tab).
|
|
445
|
+
// Each wire op mutates ~/.yeaft/config.json `mcpServers` AND, when
|
|
446
|
+
// the session is alive, mirrors the change into `mcpManager` + hot-
|
|
447
|
+
// swaps the live `toolRegistry`. See handlers in web-bridge.js for
|
|
448
|
+
// the broadcast contract (`yeaft_mcp_updated`).
|
|
449
|
+
case 'yeaft_mcp_list':
|
|
450
|
+
handleYeaftMcpList(msg);
|
|
451
|
+
break;
|
|
452
|
+
|
|
453
|
+
case 'yeaft_mcp_add':
|
|
454
|
+
await handleYeaftMcpAdd(msg);
|
|
455
|
+
break;
|
|
456
|
+
|
|
457
|
+
case 'yeaft_mcp_remove':
|
|
458
|
+
await handleYeaftMcpRemove(msg);
|
|
459
|
+
break;
|
|
460
|
+
|
|
461
|
+
case 'yeaft_mcp_reload':
|
|
462
|
+
await handleYeaftMcpReload(msg);
|
|
463
|
+
break;
|
|
464
|
+
|
|
444
465
|
// Yeaft — single conversation backed by the default session.
|
|
445
466
|
//
|
|
446
467
|
// Wire-alias scope: the `yeaft_group_chat` op (and its envelope
|
package/package.json
CHANGED
package/yeaft/config-api.js
CHANGED
|
@@ -376,3 +376,195 @@ export async function fetchTavilyUsage(dir) {
|
|
|
376
376
|
}
|
|
377
377
|
}
|
|
378
378
|
|
|
379
|
+
// ─── MCP server config (mcpServers array in config.json) ──
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Server-name regex: lowercase letters, digits, underscore, dash. Matches
|
|
383
|
+
* what Claude Code accepts so config files are portable. Single source of
|
|
384
|
+
* truth for both add/update/remove validation.
|
|
385
|
+
*/
|
|
386
|
+
const MCP_NAME_RE = /^[a-z0-9_-]+$/;
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Normalise one MCP server entry on read. The on-disk shape that the
|
|
390
|
+
* MCPManager understands is `{ name, command, args?, env? }`. This pass
|
|
391
|
+
* strips unknown / non-string keys, coerces args to an array of strings,
|
|
392
|
+
* and forces env to a plain {string→string} object so the UI doesn't
|
|
393
|
+
* crash on a malformed handwritten config.
|
|
394
|
+
*
|
|
395
|
+
* @param {unknown} entry
|
|
396
|
+
* @returns {{ name: string, command: string, args: string[], env: Record<string,string> } | null}
|
|
397
|
+
*/
|
|
398
|
+
function normaliseMcpServer(entry) {
|
|
399
|
+
if (!entry || typeof entry !== 'object') return null;
|
|
400
|
+
const e = /** @type {any} */ (entry);
|
|
401
|
+
const name = typeof e.name === 'string' ? e.name.trim() : '';
|
|
402
|
+
const command = typeof e.command === 'string' ? e.command.trim() : '';
|
|
403
|
+
if (!name || !command) return null;
|
|
404
|
+
const args = Array.isArray(e.args)
|
|
405
|
+
? e.args.filter(a => typeof a === 'string')
|
|
406
|
+
: [];
|
|
407
|
+
/** @type {Record<string,string>} */
|
|
408
|
+
// Use a null-prototype object so a malicious config entry can't poison
|
|
409
|
+
// future lookups via `__proto__` / `constructor` / `prototype` keys. Even
|
|
410
|
+
// with the explicit skip list below, the null-prototype is the right
|
|
411
|
+
// baseline: env maps are plain key/value bags, they have no business
|
|
412
|
+
// owning prototype methods.
|
|
413
|
+
const env = Object.create(null);
|
|
414
|
+
if (e.env && typeof e.env === 'object' && !Array.isArray(e.env)) {
|
|
415
|
+
for (const [k, v] of Object.entries(e.env)) {
|
|
416
|
+
// Skip dangerous keys that would let attacker-controlled config
|
|
417
|
+
// pollute Object.prototype if env were ever spread / merged
|
|
418
|
+
// somewhere that doesn't expect a null-proto map.
|
|
419
|
+
if (k === '__proto__' || k === 'constructor' || k === 'prototype') continue;
|
|
420
|
+
if (typeof k === 'string' && typeof v === 'string') env[k] = v;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
return { name, command, args, env };
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Validate a server config for add/update. Returns null on success or a
|
|
428
|
+
* string error suitable for forwarding to the UI.
|
|
429
|
+
*
|
|
430
|
+
* @param {unknown} entry
|
|
431
|
+
* @returns {string|null}
|
|
432
|
+
*/
|
|
433
|
+
function validateMcpServer(entry) {
|
|
434
|
+
if (!entry || typeof entry !== 'object') return 'server payload required';
|
|
435
|
+
const e = /** @type {any} */ (entry);
|
|
436
|
+
if (typeof e.name !== 'string' || !MCP_NAME_RE.test(e.name)) {
|
|
437
|
+
return 'server name must match /^[a-z0-9_-]+$/';
|
|
438
|
+
}
|
|
439
|
+
if (typeof e.command !== 'string' || !e.command.trim()) {
|
|
440
|
+
return 'server command is required';
|
|
441
|
+
}
|
|
442
|
+
if (e.args !== undefined && (!Array.isArray(e.args) || !e.args.every(a => typeof a === 'string'))) {
|
|
443
|
+
return 'server args must be an array of strings';
|
|
444
|
+
}
|
|
445
|
+
if (e.env !== undefined && (typeof e.env !== 'object' || e.env === null || Array.isArray(e.env))) {
|
|
446
|
+
return 'server env must be an object of string→string';
|
|
447
|
+
}
|
|
448
|
+
if (e.env && typeof e.env === 'object') {
|
|
449
|
+
for (const [k, v] of Object.entries(e.env)) {
|
|
450
|
+
if (typeof k !== 'string' || typeof v !== 'string') {
|
|
451
|
+
return 'server env entries must all be strings';
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
return null;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Read existing config.json (silently start fresh on missing / corrupt).
|
|
460
|
+
* Internal helper used by the MCP CRUD trio to share one parse path.
|
|
461
|
+
*
|
|
462
|
+
* @param {string} configPath
|
|
463
|
+
* @returns {object}
|
|
464
|
+
*/
|
|
465
|
+
function readConfigJson(configPath) {
|
|
466
|
+
if (!existsSync(configPath)) return {};
|
|
467
|
+
try {
|
|
468
|
+
const raw = readFileSync(configPath, 'utf8');
|
|
469
|
+
const json = JSON.parse(raw);
|
|
470
|
+
return (json && typeof json === 'object') ? json : {};
|
|
471
|
+
} catch {
|
|
472
|
+
return {};
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* List MCP servers currently saved in config.json. Returns an array — empty
|
|
478
|
+
* when none configured. Each entry is the normalised on-disk shape, NOT
|
|
479
|
+
* the runtime status (which lives on `mcpManager.status()`).
|
|
480
|
+
*
|
|
481
|
+
* @param {string} [dir]
|
|
482
|
+
* @returns {{ servers: Array<{ name: string, command: string, args: string[], env: Record<string,string> }> } | { error: string }}
|
|
483
|
+
*/
|
|
484
|
+
export function listMcpServers(dir) {
|
|
485
|
+
const root = dir || process.env.YEAFT_DIR || DEFAULT_YEAFT_DIR;
|
|
486
|
+
const configPath = join(root, 'config.json');
|
|
487
|
+
try {
|
|
488
|
+
const json = readConfigJson(configPath);
|
|
489
|
+
const raw = Array.isArray(json.mcpServers) ? json.mcpServers : [];
|
|
490
|
+
const servers = raw.map(normaliseMcpServer).filter(Boolean);
|
|
491
|
+
return { servers };
|
|
492
|
+
} catch (e) {
|
|
493
|
+
return { error: `Failed to read config.json: ${e.message}` };
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Add or update an MCP server config entry. Match is by `name`. Returns
|
|
499
|
+
* the post-update list of servers (same shape as `listMcpServers`) plus
|
|
500
|
+
* the entry that was just written, so callers can pass it directly into
|
|
501
|
+
* `mcpManager.connect()` without re-reading the file.
|
|
502
|
+
*
|
|
503
|
+
* @param {{ name: string, command: string, args?: string[], env?: Record<string,string> }} server
|
|
504
|
+
* @param {string} [dir]
|
|
505
|
+
* @returns {{ servers: Array<object>, server: object } | { error: string }}
|
|
506
|
+
*/
|
|
507
|
+
export function upsertMcpServer(server, dir) {
|
|
508
|
+
const err = validateMcpServer(server);
|
|
509
|
+
if (err) return { error: err };
|
|
510
|
+
|
|
511
|
+
const root = dir || process.env.YEAFT_DIR || DEFAULT_YEAFT_DIR;
|
|
512
|
+
const configPath = join(root, 'config.json');
|
|
513
|
+
const existing = readConfigJson(configPath);
|
|
514
|
+
const list = Array.isArray(existing.mcpServers) ? existing.mcpServers.slice() : [];
|
|
515
|
+
|
|
516
|
+
const normalised = normaliseMcpServer(server);
|
|
517
|
+
if (!normalised) return { error: 'invalid server payload' };
|
|
518
|
+
|
|
519
|
+
const idx = list.findIndex(s => s && typeof s === 'object' && s.name === normalised.name);
|
|
520
|
+
if (idx >= 0) {
|
|
521
|
+
list[idx] = normalised;
|
|
522
|
+
} else {
|
|
523
|
+
list.push(normalised);
|
|
524
|
+
}
|
|
525
|
+
existing.mcpServers = list;
|
|
526
|
+
|
|
527
|
+
try {
|
|
528
|
+
writeFileSync(configPath, JSON.stringify(existing, null, 2) + '\n', 'utf8');
|
|
529
|
+
} catch (e) {
|
|
530
|
+
return { error: `Failed to write config.json: ${e.message}` };
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
return { servers: list.map(normaliseMcpServer).filter(Boolean), server: normalised };
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Remove the MCP server config entry with the given name. Idempotent —
|
|
538
|
+
* removing a non-existent name returns the unchanged list with
|
|
539
|
+
* `removed: false`. This lets the UI safely call delete after a
|
|
540
|
+
* concurrent change without surfacing a spurious error.
|
|
541
|
+
*
|
|
542
|
+
* @param {string} name
|
|
543
|
+
* @param {string} [dir]
|
|
544
|
+
* @returns {{ servers: Array<object>, removed: boolean } | { error: string }}
|
|
545
|
+
*/
|
|
546
|
+
export function removeMcpServer(name, dir) {
|
|
547
|
+
if (typeof name !== 'string' || !name.trim()) {
|
|
548
|
+
return { error: 'name required' };
|
|
549
|
+
}
|
|
550
|
+
// Trim once for the comparison too — without this, " github " from the
|
|
551
|
+
// wire would silently fail to delete "github" on disk because we'd be
|
|
552
|
+
// matching against the padded string.
|
|
553
|
+
const target = name.trim();
|
|
554
|
+
const root = dir || process.env.YEAFT_DIR || DEFAULT_YEAFT_DIR;
|
|
555
|
+
const configPath = join(root, 'config.json');
|
|
556
|
+
const existing = readConfigJson(configPath);
|
|
557
|
+
const list = Array.isArray(existing.mcpServers) ? existing.mcpServers.slice() : [];
|
|
558
|
+
const next = list.filter(s => !(s && typeof s === 'object' && s.name === target));
|
|
559
|
+
const removed = next.length !== list.length;
|
|
560
|
+
existing.mcpServers = next;
|
|
561
|
+
|
|
562
|
+
try {
|
|
563
|
+
writeFileSync(configPath, JSON.stringify(existing, null, 2) + '\n', 'utf8');
|
|
564
|
+
} catch (e) {
|
|
565
|
+
return { error: `Failed to write config.json: ${e.message}` };
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
return { servers: next.map(normaliseMcpServer).filter(Boolean), removed };
|
|
569
|
+
}
|
|
570
|
+
|
|
@@ -1064,7 +1064,18 @@ export class ConversationStore {
|
|
|
1064
1064
|
|
|
1065
1065
|
const startIdx = indexOfNthTurnFromEnd(visible, turnsLimit);
|
|
1066
1066
|
const start = startIdx === -1 ? 0 : startIdx;
|
|
1067
|
-
|
|
1067
|
+
// Visible history is for UI replay, not LLM context. The visible loader
|
|
1068
|
+
// already excludes tool-result rows, so running pairSanitize here can
|
|
1069
|
+
// incorrectly treat tool-using assistant replies as orphaned tool arcs and
|
|
1070
|
+
// drop/trim VP messages. Strip tool-call metadata instead and keep the
|
|
1071
|
+
// user-visible assistant text for the conversation pane.
|
|
1072
|
+
const messages = visible.slice(start).map(m => {
|
|
1073
|
+
if (m && m.role === 'assistant' && Array.isArray(m.toolCalls) && m.toolCalls.length > 0) {
|
|
1074
|
+
const { toolCalls, ...rest } = m;
|
|
1075
|
+
return rest;
|
|
1076
|
+
}
|
|
1077
|
+
return m;
|
|
1078
|
+
});
|
|
1068
1079
|
const oldestSeq = messages.length ? parseSeqFromId(messages[0].id) : null;
|
|
1069
1080
|
const firstVisibleSeq = parseSeqFromId(visible[0].id);
|
|
1070
1081
|
const hasMore = messages.length > 0
|
package/yeaft/engine.js
CHANGED
|
@@ -2219,6 +2219,7 @@ export class Engine {
|
|
|
2219
2219
|
// history replay can re-stamp them on reload.
|
|
2220
2220
|
sessionId,
|
|
2221
2221
|
threadId,
|
|
2222
|
+
vpId: this.#vpId,
|
|
2222
2223
|
// Multi-VP fan-out (history-dedup): skip the user-row append
|
|
2223
2224
|
// in stop-hooks when the orchestrator already wrote it once
|
|
2224
2225
|
// for this turn. The hook still persists assistant + tool
|
package/yeaft/init.js
CHANGED
|
@@ -5,14 +5,16 @@
|
|
|
5
5
|
* Creates default config.md, MEMORY.md, and chat/index.md if missing.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { existsSync, mkdirSync, writeFileSync, accessSync, constants } from 'fs';
|
|
9
|
-
import { join } from 'path';
|
|
8
|
+
import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, accessSync, constants } from 'fs';
|
|
9
|
+
import { join, dirname } from 'path';
|
|
10
10
|
import { homedir } from 'os';
|
|
11
|
+
import { createHash } from 'crypto';
|
|
11
12
|
// NOTE: migrateSessions runs at the end of initYeaftDir(). It collapses
|
|
12
13
|
// legacy groups/ + chats/ + memory/{group,chat}/ into the unified sessions/
|
|
13
14
|
// layout AND rewrites pre-rename per-message frontmatter (groupId → sessionId).
|
|
14
15
|
// Idempotent via the `.yeaft-migration.done` sentinel file.
|
|
15
16
|
import { migrateSessions } from './migrate/sessions.js';
|
|
17
|
+
import { bundledYeaftSkillsDir } from './skills.js';
|
|
16
18
|
|
|
17
19
|
/**
|
|
18
20
|
* Check if an error is a permission error (EACCES or EPERM).
|
|
@@ -157,7 +159,7 @@ This file tracks one Yeaft message-history mode.
|
|
|
157
159
|
* Initialize the Yeaft data directory structure.
|
|
158
160
|
*
|
|
159
161
|
* @param {string} [dir] — Root directory path. Defaults to ~/.yeaft/
|
|
160
|
-
* @returns {{ dir: string, created: string[], writable: boolean, warnings: string[] }} — The root dir, list of created paths, writability status, and
|
|
162
|
+
* @returns {{ dir: string, created: string[], writable: boolean, warnings: string[], seededSkills?: number }} — The root dir, list of created paths, writability status, any warnings, and how many bundled skills were seeded
|
|
161
163
|
*/
|
|
162
164
|
export function initYeaftDir(dir) {
|
|
163
165
|
const root = dir || DEFAULT_YEAFT_DIR;
|
|
@@ -215,6 +217,22 @@ export function initYeaftDir(dir) {
|
|
|
215
217
|
created.push(mcpExamplePath);
|
|
216
218
|
}
|
|
217
219
|
|
|
220
|
+
// Seed bundled yeaft-skills into the user dir (Claude-Code-style). After
|
|
221
|
+
// every successful boot the user dir mirrors the bundled set (modulo files
|
|
222
|
+
// the user has hand-edited — those are detected via the manifest sha and
|
|
223
|
+
// left alone). New bundled skills appear automatically; bundled-version
|
|
224
|
+
// upgrades flow through; user edits are never clobbered.
|
|
225
|
+
let seededSkills = 0;
|
|
226
|
+
try {
|
|
227
|
+
const seedResult = seedBundledSkills(join(root, 'skills'), warnings);
|
|
228
|
+
seededSkills = seedResult.copied;
|
|
229
|
+
if (seedResult.copied > 0 || seedResult.updated > 0) {
|
|
230
|
+
console.log(`[yeaft] seeded ${seedResult.copied} new + ${seedResult.updated} updated bundled skills (${seedResult.preserved} user edits preserved)`);
|
|
231
|
+
}
|
|
232
|
+
} catch (err) {
|
|
233
|
+
warnings.push(`Failed to seed bundled skills: ${err?.message || err}`);
|
|
234
|
+
}
|
|
235
|
+
|
|
218
236
|
// NOTE: sessions migration runs synchronously here. It MUST complete before
|
|
219
237
|
// any LLM request fires, because step 7 (per-message frontmatter rewrite)
|
|
220
238
|
// is what lets the persist.js parser drop the legacy `groupId:` row alias.
|
|
@@ -230,5 +248,191 @@ export function initYeaftDir(dir) {
|
|
|
230
248
|
console.warn(`[yeaft] session migration failed (continuing): ${err?.message || err}`);
|
|
231
249
|
}
|
|
232
250
|
|
|
233
|
-
return { dir: root, created, writable, warnings };
|
|
251
|
+
return { dir: root, created, writable, warnings, seededSkills };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ─── Bundled skills seeding ───────────────────────────────
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Filename inside the user skills directory that tracks which files we
|
|
258
|
+
* previously installed from the bundled package, keyed by their sha256.
|
|
259
|
+
* On re-seed, files whose CURRENT on-disk content matches a manifest entry
|
|
260
|
+
* are considered "still bundled" and can be safely overwritten if the
|
|
261
|
+
* bundled version has changed. Files whose sha doesn't match the manifest
|
|
262
|
+
* are treated as user-modified and left alone — Claude Code uses the same
|
|
263
|
+
* "user edits win" rule.
|
|
264
|
+
*/
|
|
265
|
+
const SEED_MANIFEST_FILE = '.bundled-manifest.json';
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* sha256 of a string, hex encoded.
|
|
269
|
+
* @param {string} content
|
|
270
|
+
* @returns {string}
|
|
271
|
+
*/
|
|
272
|
+
function sha256(content) {
|
|
273
|
+
return createHash('sha256').update(content, 'utf8').digest('hex');
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Recursively walk a directory and yield relative file paths.
|
|
278
|
+
* @param {string} root
|
|
279
|
+
* @param {string} [sub]
|
|
280
|
+
* @returns {string[]}
|
|
281
|
+
*/
|
|
282
|
+
function walkFiles(root, sub = '') {
|
|
283
|
+
const dir = sub ? join(root, sub) : root;
|
|
284
|
+
if (!existsSync(dir)) return [];
|
|
285
|
+
let entries;
|
|
286
|
+
try {
|
|
287
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
288
|
+
} catch {
|
|
289
|
+
return [];
|
|
290
|
+
}
|
|
291
|
+
const out = [];
|
|
292
|
+
for (const entry of entries) {
|
|
293
|
+
const rel = sub ? join(sub, entry.name) : entry.name;
|
|
294
|
+
if (entry.isFile()) {
|
|
295
|
+
out.push(rel);
|
|
296
|
+
} else if (entry.isDirectory()) {
|
|
297
|
+
out.push(...walkFiles(root, rel));
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
return out;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Read a tiny JSON file and return parsed value, or `{}` on missing/broken.
|
|
305
|
+
* @param {string} filePath
|
|
306
|
+
* @returns {Record<string, string>}
|
|
307
|
+
*/
|
|
308
|
+
function readManifest(filePath) {
|
|
309
|
+
try {
|
|
310
|
+
if (!existsSync(filePath)) return {};
|
|
311
|
+
const raw = readFileSync(filePath, 'utf8');
|
|
312
|
+
const parsed = JSON.parse(raw);
|
|
313
|
+
return (parsed && typeof parsed === 'object') ? parsed : {};
|
|
314
|
+
} catch {
|
|
315
|
+
return {};
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Seed bundled `yeaft-skills` into a user skills directory.
|
|
321
|
+
*
|
|
322
|
+
* Behavior (Claude-Code style):
|
|
323
|
+
* - Every file under the bundled dir is mirrored to the user dir at the
|
|
324
|
+
* same relative path.
|
|
325
|
+
* - If target doesn't exist → copy verbatim, record sha in manifest.
|
|
326
|
+
* - If target exists AND its current sha matches the manifest entry → the
|
|
327
|
+
* file is "still the version we installed", so overwrite with the new
|
|
328
|
+
* bundled version (= picks up a bundled upgrade) and refresh the sha.
|
|
329
|
+
* - If target exists AND its sha differs from manifest → the user edited
|
|
330
|
+
* it. Leave it alone. Do NOT touch the manifest (so future runs still
|
|
331
|
+
* see the divergence).
|
|
332
|
+
* - Manifest is per-relative-path → sha256 of the BUNDLED version we last
|
|
333
|
+
* installed at that path.
|
|
334
|
+
*
|
|
335
|
+
* Re-runs are idempotent: stable state on disk + manifest produces no
|
|
336
|
+
* writes on the second pass.
|
|
337
|
+
*
|
|
338
|
+
* @param {string} userSkillsDir — absolute path to user-tier skills dir (e.g. ~/.yeaft/skills)
|
|
339
|
+
* @param {string[]} warnings — array to push warnings into
|
|
340
|
+
* @returns {{ copied: number, updated: number, preserved: number, skipped: number }}
|
|
341
|
+
*/
|
|
342
|
+
export function seedBundledSkills(userSkillsDir, warnings = []) {
|
|
343
|
+
const bundled = bundledYeaftSkillsDir();
|
|
344
|
+
if (!bundled) {
|
|
345
|
+
return { copied: 0, updated: 0, preserved: 0, skipped: 0 };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Ensure user dir exists (no-op if already there from SUBDIRS).
|
|
349
|
+
if (!existsSync(userSkillsDir)) {
|
|
350
|
+
if (!safeMkdir(userSkillsDir, warnings)) {
|
|
351
|
+
return { copied: 0, updated: 0, preserved: 0, skipped: 0 };
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const manifestPath = join(userSkillsDir, SEED_MANIFEST_FILE);
|
|
356
|
+
const manifest = readManifest(manifestPath);
|
|
357
|
+
const nextManifest = { ...manifest };
|
|
358
|
+
|
|
359
|
+
let copied = 0;
|
|
360
|
+
let updated = 0;
|
|
361
|
+
let preserved = 0;
|
|
362
|
+
let skipped = 0;
|
|
363
|
+
|
|
364
|
+
const files = walkFiles(bundled);
|
|
365
|
+
for (const rel of files) {
|
|
366
|
+
const sourcePath = join(bundled, rel);
|
|
367
|
+
const targetPath = join(userSkillsDir, rel);
|
|
368
|
+
|
|
369
|
+
let bundledContent;
|
|
370
|
+
try {
|
|
371
|
+
bundledContent = readFileSync(sourcePath, 'utf8');
|
|
372
|
+
} catch (err) {
|
|
373
|
+
warnings.push(`Cannot read bundled skill ${rel}: ${err.message}`);
|
|
374
|
+
skipped += 1;
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
const bundledSha = sha256(bundledContent);
|
|
378
|
+
|
|
379
|
+
if (!existsSync(targetPath)) {
|
|
380
|
+
// First-time install — copy.
|
|
381
|
+
if (!safeMkdir(dirname(targetPath), warnings)) {
|
|
382
|
+
skipped += 1;
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
safeWriteFile(targetPath, bundledContent, warnings);
|
|
386
|
+
if (existsSync(targetPath)) {
|
|
387
|
+
nextManifest[rel] = bundledSha;
|
|
388
|
+
copied += 1;
|
|
389
|
+
} else {
|
|
390
|
+
skipped += 1;
|
|
391
|
+
}
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Target exists. Read it, sha it, decide.
|
|
396
|
+
let currentContent;
|
|
397
|
+
try {
|
|
398
|
+
currentContent = readFileSync(targetPath, 'utf8');
|
|
399
|
+
} catch (err) {
|
|
400
|
+
warnings.push(`Cannot read existing skill ${rel}: ${err.message}`);
|
|
401
|
+
skipped += 1;
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
const currentSha = sha256(currentContent);
|
|
405
|
+
|
|
406
|
+
if (currentSha === bundledSha) {
|
|
407
|
+
// Already up to date — no write, but make sure the manifest knows.
|
|
408
|
+
nextManifest[rel] = bundledSha;
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const manifestSha = manifest[rel];
|
|
413
|
+
if (manifestSha && manifestSha === currentSha) {
|
|
414
|
+
// The on-disk file is the version WE installed; user hasn't touched
|
|
415
|
+
// it. The bundled version has changed (we passed the previous
|
|
416
|
+
// currentSha === bundledSha check), so apply the upgrade.
|
|
417
|
+
safeWriteFile(targetPath, bundledContent, warnings);
|
|
418
|
+
nextManifest[rel] = bundledSha;
|
|
419
|
+
updated += 1;
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Either: (a) no manifest entry (= file pre-existed before we tracked
|
|
424
|
+
// it) or (b) manifest sha doesn't match current sha (= user edited).
|
|
425
|
+
// Either way the file is user-owned now — leave it alone, don't
|
|
426
|
+
// overwrite, don't touch the manifest (so divergence stays visible).
|
|
427
|
+
preserved += 1;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Write manifest only if it actually changed (avoids needless disk
|
|
431
|
+
// writes when nothing new happened).
|
|
432
|
+
const manifestChanged = JSON.stringify(manifest) !== JSON.stringify(nextManifest);
|
|
433
|
+
if (manifestChanged) {
|
|
434
|
+
safeWriteFile(manifestPath, JSON.stringify(nextManifest, null, 2) + '\n', warnings);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
return { copied, updated, preserved, skipped };
|
|
234
438
|
}
|
package/yeaft/session.js
CHANGED
|
@@ -21,6 +21,7 @@ import { ConversationStore, setDefaultRecentTurnsLimit } from './conversation/pe
|
|
|
21
21
|
import { SkillManager, createSkillManager } from './skills.js';
|
|
22
22
|
import { MCPManager } from './mcp.js';
|
|
23
23
|
import { createFullRegistry } from './tools/index.js';
|
|
24
|
+
import { buildMcpFlattenedTools } from './tools/mcp-tools.js';
|
|
24
25
|
import { Engine } from './engine.js';
|
|
25
26
|
import { Compactor } from './compact/compactor.js';
|
|
26
27
|
import { resolveContextWindow } from './models.js';
|
|
@@ -369,10 +370,19 @@ export async function loadSession(options = {}) {
|
|
|
369
370
|
// ─── 6. Load skills ────────────────────────────────────
|
|
370
371
|
let skillManager;
|
|
371
372
|
if (skipSkills) {
|
|
372
|
-
|
|
373
|
+
// Pass the literal user-tier dir (matches the normal branch's tier 2)
|
|
374
|
+
// so any save/remove calls land in the same place users expect. New
|
|
375
|
+
// `SkillManager` API takes literal scan dirs — no auto-suffix of /skills.
|
|
376
|
+
skillManager = new SkillManager(join(yeaftDir, 'skills'));
|
|
373
377
|
// Don't call .load() — empty skill manager
|
|
374
378
|
} else {
|
|
375
|
-
|
|
379
|
+
// Pass the agent's current working directory as the project tier root.
|
|
380
|
+
// Per-session/per-group workdirs override at tool-execution time via
|
|
381
|
+
// ToolContext.cwd; for the SYSTEM-PROMPT skill set we just use the
|
|
382
|
+
// agent process cwd, which is the common case when an agent is
|
|
383
|
+
// launched inside a project the user wants project-tier skills for.
|
|
384
|
+
const projectTierRoot = process.cwd();
|
|
385
|
+
skillManager = createSkillManager(yeaftDir, projectTierRoot);
|
|
376
386
|
}
|
|
377
387
|
|
|
378
388
|
// ─── 7. Connect MCP servers ────────────────────────────
|
|
@@ -392,6 +402,20 @@ export async function loadSession(options = {}) {
|
|
|
392
402
|
toolRegistry.register(tool);
|
|
393
403
|
}
|
|
394
404
|
|
|
405
|
+
// Register flattened MCP tools (one ToolDef per MCP tool, named
|
|
406
|
+
// `mcp__<server>__<tool>` per Claude Code's convention). This replaces
|
|
407
|
+
// the legacy mcp_list_tools / mcp_call_tool meta-tools — the LLM now
|
|
408
|
+
// calls MCP tools directly in a single turn, no discovery dance.
|
|
409
|
+
// Re-built and re-registered on every connect/disconnect via
|
|
410
|
+
// `toolRegistry.replaceMcpTools(mcpManager, buildMcpFlattenedTools)`
|
|
411
|
+
// which is invoked from the MCP web-bridge handlers.
|
|
412
|
+
if (mcpManager.hasServers) {
|
|
413
|
+
const flattened = buildMcpFlattenedTools(mcpManager);
|
|
414
|
+
for (const tool of flattened) {
|
|
415
|
+
toolRegistry.register(tool);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
395
419
|
// ─── 9. Create engine (wires everything) ───────────────
|
|
396
420
|
// Tool-call usage statistics: persisted to <yeaftDir>/stats/tool-usage.json.
|
|
397
421
|
// Loaded synchronously at boot so the first turn already sees prior counts.
|