psyclaw 0.27.5 → 0.27.7

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.
Files changed (39) hide show
  1. package/README.md +9 -7
  2. package/dist/apps/panel/index.html +11 -11
  3. package/dist/src/adapters/pi/extension.js +383 -55
  4. package/dist/src/adapters/pi/extension.js.map +1 -1
  5. package/dist/src/chat.js +2 -2
  6. package/dist/src/chat.js.map +1 -1
  7. package/dist/src/cli.js +1 -4
  8. package/dist/src/cli.js.map +1 -1
  9. package/dist/src/index.d.ts +1 -0
  10. package/dist/src/index.js +1 -0
  11. package/dist/src/index.js.map +1 -1
  12. package/dist/src/integrations/mcp-runtime.d.ts +26 -0
  13. package/dist/src/integrations/mcp-runtime.js +154 -0
  14. package/dist/src/integrations/mcp-runtime.js.map +1 -0
  15. package/dist/src/panel/events.d.ts +2 -1
  16. package/dist/src/panel/events.js +2 -5
  17. package/dist/src/panel/events.js.map +1 -1
  18. package/dist/src/panel/server.js +28 -8
  19. package/dist/src/panel/server.js.map +1 -1
  20. package/dist/src/project/bootstrap.d.ts +17 -0
  21. package/dist/src/project/bootstrap.js +87 -1
  22. package/dist/src/project/bootstrap.js.map +1 -1
  23. package/dist/src/skills/user-skills.d.ts +57 -0
  24. package/dist/src/skills/user-skills.js +294 -0
  25. package/dist/src/skills/user-skills.js.map +1 -0
  26. package/dist/src/style/cli-ui.js +1 -1
  27. package/dist/src/style/cli-ui.js.map +1 -1
  28. package/dist/src/telemetry/export.d.ts +8 -6
  29. package/dist/src/telemetry/export.js +81 -18
  30. package/dist/src/telemetry/export.js.map +1 -1
  31. package/dist/src/tui/skill-manager.d.ts +3 -0
  32. package/dist/src/tui/skill-manager.js +11 -1
  33. package/dist/src/tui/skill-manager.js.map +1 -1
  34. package/package.json +1 -1
  35. package/scripts/rebrand-pi.mjs +66 -4
  36. package/skills/core/citation-audit/SKILL.md +9 -9
  37. package/skills/core/evidence-capture/SKILL.md +9 -9
  38. package/skills/core/research-brief/SKILL.md +8 -8
  39. package/skills/core/research-intake/SKILL.md +9 -9
@@ -6,12 +6,14 @@ import { atomicWriteFile } from "../../project/jsonl.js";
6
6
  import { RunEventLog } from "../../panel/events.js";
7
7
  import { readProject } from "../../research/ledger.js";
8
8
  import { join } from "node:path";
9
- import { mkdir, readFile } from "node:fs/promises";
9
+ import { lstat, mkdir, readFile } from "node:fs/promises";
10
10
  import { PROVIDER_PRESETS, saveProviderConfig } from "../../setup.js";
11
11
  import { dirname } from "node:path";
12
12
  import { fileURLToPath } from "node:url";
13
13
  import { coreSkillNames, enabledRecommendedSkillPaths, normalizeRecommendedSkillId, readRecommendationState, readRecommendedCatalog, recommendedSkillTarget, saveRecommendationState, validateModelInstalledRecommendedSkill, } from "../../skills/recommended.js";
14
14
  import { SkillManagerComponent } from "../../tui/skill-manager.js";
15
+ import { enabledLocalSkillPaths, enabledLocalPromptPaths, readUserSkillState, scanLocalSkills, setLocalSkillEnabled, setLocalSkillsEnabled, skillNamesInPaths, userSkillId, } from "../../skills/user-skills.js";
16
+ import { RuntimeMcpRegistry, setUserMcpConfigEnabled, } from "../../integrations/mcp-runtime.js";
15
17
  import { ProviderPickerComponent, SecretInputComponent, } from "../../tui/provider-picker.js";
16
18
  const PARADIGMS = new Set([
17
19
  "survey-observational",
@@ -163,28 +165,32 @@ async function readControlledRun(root) {
163
165
  try {
164
166
  const value = JSON.parse(await readFile(await controlledRunPath(root), "utf8"));
165
167
  return value.schemaVersion === "psyclaw/controlled-run/v1" && value.status === "active" && typeof value.projectId === "string" && typeof value.objective === "string" && typeof value.activatedAt === "string"
166
- ? value
168
+ ? { ...value, selectedSkills: Array.isArray(value.selectedSkills) ? value.selectedSkills.filter((item) => typeof item === "string") : [] }
167
169
  : null;
168
170
  }
169
171
  catch {
170
172
  return null;
171
173
  }
172
174
  }
173
- async function activateControlledRun(root, projectId, objective) {
175
+ async function activateControlledRun(root, projectId, objective, selectedSkills) {
174
176
  const state = {
175
177
  schemaVersion: "psyclaw/controlled-run/v1",
176
178
  projectId,
177
179
  objective,
180
+ selectedSkills: [...new Set(selectedSkills)],
178
181
  activatedAt: new Date().toISOString(),
179
182
  status: "active",
180
183
  };
181
184
  await atomicWriteFile(await controlledRunPath(root), `${JSON.stringify(state, null, 2)}\n`);
182
185
  return state;
183
186
  }
184
- function controlledRunRequest(objective) {
187
+ function controlledRunRequest(objective, selectedSkills) {
185
188
  return [
186
189
  "PsyClaw 受控研究流程已由用户通过 /run 明确启动。先读取 .psyclaw/project.json、.psyclaw/controlled-run.json、notes/research-spec.md 和 notes/plan.md。",
187
190
  `本次目标:${objective}`,
191
+ selectedSkills.length > 0
192
+ ? `本次运行由用户选择的优化 Skill:${selectedSkills.join(", ")}。在相关任务中优先加载并遵循这些 Skill;同名 Skill 只加载一次。`
193
+ : "本次运行未指定额外 Skill,使用 PsyClaw 默认研究流程。不要删除、停用或改写已安装的其他 Skill。",
188
194
  "实际推进当前尚未完成的研究任务;先形成或更新符合学术规范的 Markdown 分析报告,不能只描述计划。分析报告必须清楚区分数据来源、统计结果、文献证据、限制与尚未核验内容。",
189
195
  "分析报告完成后,下一步不是直接询问是否导出 DOCX,而是询问用户是否据此撰写论文。",
190
196
  "若用户选择撰写论文,再单独询问是否先进行文献调研,并说明该阶段可能花费较长时间;提示用户可以指定已启用的 Skill,或者选择 PsyClaw 默认的文献调研方式。不要在获得答复前自动开始长时调研。",
@@ -194,6 +200,53 @@ function controlledRunRequest(objective) {
194
200
  "如果用户在未完成文献调研、全文写作或评审前要求导出 DOCX,允许导出当前分析报告,但必须明确标为遵循学术规范的分析报告,不得称为论文。",
195
201
  ].join("\n");
196
202
  }
203
+ function parseRunArgs(args) {
204
+ const value = args.trim();
205
+ if (!value.startsWith("--skills"))
206
+ return { objective: value };
207
+ const match = value.match(/^--skills(?:=|\s+)([^\s]+)(?:\s+([\s\S]*))?$/);
208
+ if (!match)
209
+ throw new Error("Usage: /run [--skills skill-a,skill-b] [objective]");
210
+ return {
211
+ objective: (match[2] ?? "").trim(),
212
+ requestedSkills: [...new Set(match[1].split(",").map((item) => item.trim()).filter(Boolean))],
213
+ };
214
+ }
215
+ async function selectableRunSkills(root) {
216
+ const state = await readRecommendationState(root);
217
+ const rows = await skillManagerRows(root, state);
218
+ const available = [
219
+ ...coreSkillNames().map((name) => ({ id: name, name })),
220
+ ...rows.filter((row) => row.installed && row.enabled && !row.blocked).map((row) => ({ id: row.id, name: row.name })),
221
+ ];
222
+ const seen = new Set();
223
+ return available.filter((skill) => {
224
+ const key = skill.name.trim().toLocaleLowerCase();
225
+ if (!key || seen.has(key))
226
+ return false;
227
+ seen.add(key);
228
+ return true;
229
+ });
230
+ }
231
+ function resolveRunSkills(requested, available) {
232
+ const lookup = new Map();
233
+ for (const skill of available) {
234
+ lookup.set(skill.id.toLocaleLowerCase(), skill.name);
235
+ lookup.set(skill.name.toLocaleLowerCase(), skill.name);
236
+ }
237
+ const selected = [];
238
+ const unknown = [];
239
+ for (const value of requested) {
240
+ const name = lookup.get(value.toLocaleLowerCase());
241
+ if (!name)
242
+ unknown.push(value);
243
+ else if (!selected.some((item) => item.toLocaleLowerCase() === name.toLocaleLowerCase()))
244
+ selected.push(name);
245
+ }
246
+ if (unknown.length > 0)
247
+ throw new Error(`本次运行不可用的 Skill:${unknown.join(", ")}。请先通过 /skills 安装或启用。`);
248
+ return selected;
249
+ }
197
250
  async function recommendedItems(kind) {
198
251
  const file = kind === "skills" ? "catalog.json" : "mcp-catalog.json";
199
252
  const moduleDir = dirname(fileURLToPath(import.meta.url));
@@ -217,11 +270,13 @@ async function recommendedItems(kind) {
217
270
  return { items: [], externalTools: [], installPrep: [] };
218
271
  }
219
272
  function skillScopeLabel(scope) {
273
+ if (scope === "local")
274
+ return "用户本地目录(所有项目可发现)";
220
275
  return scope === "user" ? "系统目录(所有项目)" : "项目目录(仅当前项目)";
221
276
  }
222
277
  async function skillManagerRows(root, state) {
223
278
  const catalog = await readRecommendedCatalog();
224
- return Promise.all(catalog.items.filter((item) => item.kind === "skill").map(async (item) => {
279
+ const recommendedRows = await Promise.all(catalog.items.filter((item) => item.kind === "skill").map(async (item) => {
225
280
  const id = normalizeRecommendedSkillId(item.id);
226
281
  const scope = state.skillScopes?.[id] ?? "project";
227
282
  try {
@@ -237,9 +292,21 @@ async function skillManagerRows(root, state) {
237
292
  enabled: state.skills.includes(id),
238
293
  scope,
239
294
  blocked: false,
295
+ source: "recommended",
240
296
  };
241
297
  }
242
- catch {
298
+ catch (validationError) {
299
+ // A managed target that exists on disk but fails validation (e.g. a URL
300
+ // install whose manifest/hash does not match the pinned catalog) is still
301
+ // shown as installed so the user sees it; enabling it only takes effect
302
+ // once the install is repaired through /install.
303
+ let onDisk = false;
304
+ try {
305
+ const target = recommendedSkillTarget(root, id, scope);
306
+ const stat = await lstat(target);
307
+ onDisk = stat.isDirectory();
308
+ }
309
+ catch { /* target missing */ }
243
310
  return {
244
311
  id,
245
312
  name: item.name,
@@ -247,20 +314,50 @@ async function skillManagerRows(root, state) {
247
314
  ...(item.sourceRef === undefined ? {} : { sourceRef: item.sourceRef }),
248
315
  ...(typeof item.installHint === "string" ? { installHint: item.installHint } : {}),
249
316
  collection: item.skillLayout === "collection",
250
- installed: false,
251
- enabled: false,
317
+ installed: onDisk,
318
+ enabled: onDisk && state.skills.includes(id),
252
319
  scope,
253
320
  blocked: typeof item.sourceRef !== "string" || !/^https:\/\//.test(item.sourceRef),
254
- reason: typeof item.sourceRef === "string" && /^https:\/\//.test(item.sourceRef)
255
- ? "尚未安装;按 Enter 选择安装范围并交给当前模型处理"
256
- : "没有可交给模型检查的来源网址",
321
+ reason: onDisk
322
+ ? `已检测到目录但未通过来源/许可/哈希校验${state.skills.includes(id) ? "(当前配置为启用,但校验失败不会加载)" : ""};可按 Enter 重新安装修复`
323
+ : typeof item.sourceRef === "string" && /^https:\/\//.test(item.sourceRef)
324
+ ? "尚未安装;按 Enter 选择安装范围并交给当前模型处理"
325
+ : "没有可交给模型检查的来源网址",
326
+ source: "recommended",
257
327
  };
258
328
  }
259
329
  }));
330
+ // Merge user-installed local skills from the expanded discovery roots so the
331
+ // management page shows them and lets the user toggle each one. Managed
332
+ // recommended installs are excluded here — they are shown through their
333
+ // recommended rows above (including on-disk-but-unvalidated installs).
334
+ const userState = await readUserSkillState(root);
335
+ const disabled = new Set(userState.disabled);
336
+ const localSkills = await scanLocalSkills(root, { includeManaged: false });
337
+ const localRows = localSkills.map((skill) => ({
338
+ id: userSkillId(skill.name),
339
+ name: skill.name,
340
+ description: skill.description,
341
+ sourceRef: skill.path,
342
+ collection: false,
343
+ installed: true,
344
+ enabled: !disabled.has(skill.name),
345
+ scope: "local",
346
+ blocked: false,
347
+ source: "local",
348
+ duplicatePaths: skill.duplicatePaths,
349
+ }));
350
+ // A catalog skill that is actually installed is shown once through its
351
+ // recommended row (which carries source/trust info); purely local skills —
352
+ // including a local copy of a catalog skill that failed managed validation —
353
+ // are appended so they stay visible and toggleable.
354
+ const installedRecommendedNames = new Set(recommendedRows.filter((row) => row.installed).map((row) => row.name.toLocaleLowerCase()));
355
+ const uniqueLocalRows = localRows.filter((row) => !installedRecommendedNames.has(row.name.toLocaleLowerCase()));
356
+ return [...recommendedRows, ...uniqueLocalRows];
260
357
  }
261
- async function mcpManagerRows(state) {
358
+ async function mcpManagerRows(root, state, runtime) {
262
359
  const { items, installPrep } = await recommendedItems("mcp");
263
- return items.map((item) => {
360
+ const recommendedRows = items.map((item) => {
264
361
  const id = String(item.id ?? "");
265
362
  const plan = installPrep.find((candidate) => candidate.id === id);
266
363
  const dependencies = Array.isArray(plan?.dependencies)
@@ -272,6 +369,7 @@ async function mcpManagerRows(state) {
272
369
  description: String(item.description ?? ""),
273
370
  ...(typeof item.sourceRef === "string" ? { sourceRef: item.sourceRef } : {}),
274
371
  enabled: state.mcp.includes(id),
372
+ source: "recommended",
275
373
  details: [
276
374
  `传输:${String(item.transport ?? "未声明")} · 风险:${String(item.risk ?? "未声明")}`,
277
375
  `参考版本:${String(plan?.ref ?? "由模型读取来源确定")} · 许可证:${String(plan?.license ?? "由模型读取来源确定")}`,
@@ -279,6 +377,31 @@ async function mcpManagerRows(state) {
279
377
  ],
280
378
  };
281
379
  });
380
+ // Merge user-configured MCP servers (`.psyclaw/mcp/*.json` and
381
+ // `~/.psyclaw/mcp/*.json`) so the management page shows them too, even when
382
+ // disabled. User rows win over a same-id recommended row.
383
+ const userRows = (await runtime.listUserConfigs(root)).map((entry) => ({
384
+ id: entry.id,
385
+ name: entry.name,
386
+ description: `用户配置:${entry.command}`,
387
+ enabled: entry.enabled,
388
+ source: "user",
389
+ userEntry: entry,
390
+ details: [
391
+ `传输:stdio · 范围:${entry.scope === "user" ? "用户级" : "项目级"}`,
392
+ `配置:${entry.path}`,
393
+ ],
394
+ }));
395
+ const merged = new Map(recommendedRows.map((row) => [row.id, row]));
396
+ for (const row of userRows)
397
+ merged.set(row.id, row);
398
+ return [...merged.values()].sort((left, right) => {
399
+ if (left.source === "user" && right.source !== "user")
400
+ return -1;
401
+ if (right.source === "user" && left.source !== "user")
402
+ return 1;
403
+ return left.id.localeCompare(right.id);
404
+ });
282
405
  }
283
406
  function skillRowLabel(row) {
284
407
  const state = row.blocked ? "× 阻断" : row.enabled ? "● 已启用" : row.installed ? "○ 未启用" : "↓ 未安装";
@@ -314,7 +437,12 @@ function skillManagerItems(rows) {
314
437
  description: row.description,
315
438
  status: row.blocked ? "blocked" : row.enabled ? "enabled" : row.installed ? "disabled" : "missing",
316
439
  ...(row.sourceRef === undefined ? {} : { sourceRef: row.sourceRef }),
317
- details: [`安装位置:${skillScopeLabel(row.scope)}`],
440
+ details: [
441
+ `安装位置:${skillScopeLabel(row.scope)}`,
442
+ ...(row.duplicatePaths && row.duplicatePaths.length > 0
443
+ ? [`已忽略 ${row.duplicatePaths.length} 个同名来源`]
444
+ : []),
445
+ ],
318
446
  ...(row.reason === undefined ? {} : { reason: row.reason }),
319
447
  })),
320
448
  ];
@@ -337,18 +465,14 @@ async function openMcpManager(ctx, rows) {
337
465
  return ctx.ui.custom((tui, theme, keybindings, done) => (new SkillManagerComponent(mcpManagerItems(rows), tui, theme, keybindings, done, {
338
466
  title: "MCP 管理",
339
467
  itemLabel: "MCP",
340
- footer: "↑/↓ 移动 · Space 开启/关闭项目配置 · Enter 交给模型安装并配置 · Esc 关闭",
468
+ footer: "↑/↓ 移动 · Space 开启/关闭 · a 全部开启 · d 全部关闭 · Enter 交给模型安装并配置 · Esc 关闭",
341
469
  enterAction: "install",
342
- enabledText: "项目配置已开启;Enter 可让当前模型重新安装或修复配置",
343
- disabledText: "项目配置已关闭",
470
+ enabledText: "已开启;Enter 可让当前模型重新安装或修复推荐配置",
471
+ disabledText: "已关闭",
344
472
  })));
345
473
  }
346
474
  async function setRecommendedMcpEnabled(root, id, enabled) {
347
475
  const state = await readRecommendationState(root);
348
- const rows = await mcpManagerRows(state);
349
- const row = rows.find((candidate) => candidate.id === id);
350
- if (!row)
351
- throw new Error(`未找到推荐 MCP: ${id}`);
352
476
  const current = new Set(state.mcp);
353
477
  if (enabled)
354
478
  current.add(id);
@@ -357,6 +481,9 @@ async function setRecommendedMcpEnabled(root, id, enabled) {
357
481
  state.mcp = [...current];
358
482
  await saveRecommendationState(root, state);
359
483
  }
484
+ async function setUserMcpEnabled(entry, enabled) {
485
+ await setUserMcpConfigEnabled(entry, enabled);
486
+ }
360
487
  function modelMcpInstallTask(root, row, plan) {
361
488
  const suggestedCommand = typeof plan?.command === "string" ? plan.command : "请根据来源仓库的最新安装说明确定";
362
489
  const suggestedTarget = typeof plan?.target === "string" ? plan.target : `.psyclaw/mcp/${row.id}.json`;
@@ -379,48 +506,86 @@ async function queueModelMcpInstall(pi, ctx, row, plan) {
379
506
  pi.sendUserMessage(modelMcpInstallTask(ctx.cwd, row, plan), ctx.isIdle() ? {} : { deliverAs: "followUp" });
380
507
  ctx.ui.notify(`已将 ${row.name} 的下载、安装和配置任务交给当前模型。模型完成并确认可启动后,请执行 /reload。`, "info");
381
508
  }
382
- async function showMcpManager(pi, args, ctx) {
509
+ async function showMcpManager(pi, args, ctx, runtime) {
383
510
  const [verb, id] = args.trim().split(/\s+/).filter(Boolean);
384
- if (verb && !["status", "enable", "disable", "install"].includes(verb)) {
385
- throw new Error("Usage: /mcp [status|enable <id>|disable <id>|install <id>]");
511
+ if (verb && !["status", "enable", "disable", "enable-all", "disable-all", "install"].includes(verb)) {
512
+ throw new Error("Usage: /mcp [status|enable <id>|disable <id>|enable-all|disable-all|install <id>]");
386
513
  }
387
514
  if ((verb === "enable" || verb === "disable" || verb === "install") && !id) {
388
515
  throw new Error(`Usage: /mcp ${verb} <id>`);
389
516
  }
390
517
  const catalog = await recommendedItems("mcp");
391
518
  if (verb === "install") {
392
- const rows = await mcpManagerRows(await readRecommendationState(ctx.cwd));
519
+ const rows = await mcpManagerRows(ctx.cwd, await readRecommendationState(ctx.cwd), runtime);
393
520
  const row = rows.find((candidate) => candidate.id === id);
394
521
  if (!row)
395
522
  throw new Error(`未找到推荐 MCP: ${id}`);
523
+ if (row.source !== "recommended")
524
+ throw new Error(`MCP ${id} 已是用户配置,无需再次安装`);
396
525
  await queueModelMcpInstall(pi, ctx, row, catalog.installPrep.find((candidate) => candidate.id === row.id));
397
526
  return;
398
527
  }
528
+ if (verb === "enable-all" || verb === "disable-all") {
529
+ const enabled = verb === "enable-all";
530
+ const rows = await mcpManagerRows(ctx.cwd, await readRecommendationState(ctx.cwd), runtime);
531
+ for (const row of rows) {
532
+ if (row.source === "user" && row.userEntry)
533
+ await setUserMcpEnabled(row.userEntry, enabled);
534
+ else
535
+ await setRecommendedMcpEnabled(ctx.cwd, row.id, enabled);
536
+ }
537
+ ctx.ui.notify(`已批量${enabled ? "开启" : "关闭"}全部 MCP 配置;重启后重新检查运行时可用性`, "info");
538
+ return;
539
+ }
399
540
  if (verb === "enable" || verb === "disable") {
400
- await setRecommendedMcpEnabled(ctx.cwd, id, verb === "enable");
401
- ctx.ui.notify(`MCP ${id} 的项目配置已${verb === "enable" ? "开启" : "关闭"};重启后重新检查安装、信任和工具策略`, "info");
541
+ const rows = await mcpManagerRows(ctx.cwd, await readRecommendationState(ctx.cwd), runtime);
542
+ const row = rows.find((candidate) => candidate.id === id);
543
+ if (!row)
544
+ throw new Error(`未找到 MCP: ${id}`);
545
+ if (row.source === "user" && row.userEntry)
546
+ await setUserMcpEnabled(row.userEntry, verb === "enable");
547
+ else
548
+ await setRecommendedMcpEnabled(ctx.cwd, row.id, verb === "enable");
549
+ ctx.ui.notify(`MCP ${id} 已${verb === "enable" ? "开启" : "关闭"};重启后重新检查安装、信任和工具策略`, "info");
402
550
  return;
403
551
  }
404
552
  if (!ctx.hasUI || typeof ctx.ui.custom !== "function" || verb === "status") {
405
- const rows = await mcpManagerRows(await readRecommendationState(ctx.cwd));
406
- ctx.ui.notify(rows.map((row) => `${row.enabled ? "[on]" : "[off]"} ${row.id} — ${row.name}`).join("\n"), "info");
553
+ const rows = await mcpManagerRows(ctx.cwd, await readRecommendationState(ctx.cwd), runtime);
554
+ ctx.ui.notify(rows.map((row) => `${row.enabled ? "[on]" : "[off]"} ${row.id} — ${row.name}${row.source === "user" ? "(用户配置)" : ""}`).join("\n"), "info");
407
555
  return;
408
556
  }
409
557
  while (true) {
410
- const rows = await mcpManagerRows(await readRecommendationState(ctx.cwd));
558
+ const rows = await mcpManagerRows(ctx.cwd, await readRecommendationState(ctx.cwd), runtime);
411
559
  const action = await openMcpManager(ctx, rows);
412
560
  if (action.type === "close")
413
561
  return;
562
+ if (action.type === "toggle-all") {
563
+ for (const row of rows) {
564
+ if (row.source === "user" && row.userEntry)
565
+ await setUserMcpEnabled(row.userEntry, action.enabled);
566
+ else
567
+ await setRecommendedMcpEnabled(ctx.cwd, row.id, action.enabled);
568
+ }
569
+ ctx.ui.notify(`已批量${action.enabled ? "开启" : "关闭"}全部 MCP 配置;重启后重新检查运行时可用性`, "info");
570
+ continue;
571
+ }
414
572
  const row = rows.find((candidate) => candidate.id === action.id);
415
573
  if (!row)
416
574
  continue;
417
575
  if (action.type === "install") {
576
+ if (row.source !== "recommended") {
577
+ ctx.ui.notify(`${row.name} 已是用户配置,无需再次安装`, "info");
578
+ continue;
579
+ }
418
580
  await queueModelMcpInstall(pi, ctx, row, catalog.installPrep.find((candidate) => candidate.id === row.id));
419
581
  return;
420
582
  }
421
583
  if (action.type === "toggle") {
422
- await setRecommendedMcpEnabled(ctx.cwd, row.id, action.enabled);
423
- ctx.ui.notify(`${row.name} 的项目配置已${action.enabled ? "开启" : "关闭"};重启后重新检查运行时可用性`, "info");
584
+ if (row.source === "user" && row.userEntry)
585
+ await setUserMcpEnabled(row.userEntry, action.enabled);
586
+ else
587
+ await setRecommendedMcpEnabled(ctx.cwd, row.id, action.enabled);
588
+ ctx.ui.notify(`${row.name} 已${action.enabled ? "开启" : "关闭"};重启后重新检查运行时可用性`, "info");
424
589
  }
425
590
  }
426
591
  }
@@ -469,8 +634,8 @@ async function showSkillManager(pi, args, ctx) {
469
634
  const action = args.trim().split(/\s+/).filter(Boolean);
470
635
  const verb = action[0];
471
636
  const id = action[1];
472
- if (verb && !["status", "enable", "disable", "install"].includes(verb)) {
473
- throw new Error("Usage: /skills [status|enable <id>|disable <id>|install <id>]");
637
+ if (verb && !["status", "enable", "disable", "enable-all", "disable-all", "install"].includes(verb)) {
638
+ throw new Error("Usage: /skills [status|enable <id>|disable <id>|enable-all|disable-all|install <id>]");
474
639
  }
475
640
  if ((verb === "enable" || verb === "disable" || verb === "install") && !id) {
476
641
  throw new Error(`Usage: /skills ${verb} <id>`);
@@ -482,9 +647,44 @@ async function showSkillManager(pi, args, ctx) {
482
647
  await queueModelSkillInstall(pi, ctx, row);
483
648
  return;
484
649
  }
650
+ if (verb === "enable-all" || verb === "disable-all") {
651
+ const enabled = verb === "enable-all";
652
+ const state = await readRecommendationState(ctx.cwd);
653
+ const rows = await skillManagerRows(ctx.cwd, state);
654
+ const managed = rows.filter((row) => row.source === "recommended" && row.installed && !row.blocked);
655
+ const locals = rows.filter((row) => row.source === "local");
656
+ const failed = [];
657
+ for (const row of managed) {
658
+ try {
659
+ await setRecommendedSkillEnabled(ctx.cwd, row.id, enabled);
660
+ }
661
+ catch {
662
+ if (enabled)
663
+ failed.push(row.name);
664
+ }
665
+ }
666
+ if (locals.length > 0) {
667
+ await setLocalSkillsEnabled(ctx.cwd, locals.map((row) => row.name), enabled);
668
+ }
669
+ ctx.ui.notify(enabled && failed.length > 0
670
+ ? `已批量启用(${failed.join("、")} 启用失败)。请运行 /reload 重新加载。`
671
+ : `已批量${enabled ? "启用" : "停用"}可管理的 Skill。请运行 /reload 重新加载。`, enabled && failed.length > 0 ? "warning" : "info");
672
+ return;
673
+ }
485
674
  if (verb === "enable" || verb === "disable") {
486
- await setRecommendedSkillEnabled(ctx.cwd, id, verb === "enable");
487
- ctx.ui.notify(`${verb === "enable" ? "已启用" : "已停用"} ${normalizeRecommendedSkillId(id)}。请运行 /reload 重新加载。`, "info");
675
+ const requested = normalizeRecommendedSkillId(id);
676
+ const state = await readRecommendationState(ctx.cwd);
677
+ const rows = await skillManagerRows(ctx.cwd, state);
678
+ const row = rows.find((candidate) => candidate.id === requested || candidate.name === requested || candidate.id === userSkillId(requested));
679
+ if (!row)
680
+ throw new Error(`未找到 Skill: ${id}`);
681
+ if (row.source === "local") {
682
+ await setLocalSkillEnabled(ctx.cwd, row.name, verb === "enable");
683
+ }
684
+ else {
685
+ await setRecommendedSkillEnabled(ctx.cwd, row.id, verb === "enable");
686
+ }
687
+ ctx.ui.notify(`${verb === "enable" ? "已启用" : "已停用"} ${row.name}。请运行 /reload 重新加载。`, "info");
488
688
  return;
489
689
  }
490
690
  if (!ctx.hasUI || typeof ctx.ui.custom !== "function" || verb === "status") {
@@ -503,6 +703,42 @@ async function showSkillManager(pi, args, ctx) {
503
703
  const action = await openSkillManager(ctx, rows);
504
704
  if (action.type === "close")
505
705
  return;
706
+ if (action.type === "toggle-all") {
707
+ const managed = rows.filter((row) => row.source === "recommended" && row.installed && !row.blocked);
708
+ const locals = rows.filter((row) => row.source === "local");
709
+ if (action.enabled) {
710
+ const failed = [];
711
+ for (const row of managed) {
712
+ try {
713
+ await setRecommendedSkillEnabled(ctx.cwd, row.id, true);
714
+ }
715
+ catch {
716
+ failed.push(row.name);
717
+ }
718
+ }
719
+ if (locals.length > 0) {
720
+ const ids = locals.map((row) => row.name);
721
+ await setLocalSkillsEnabled(ctx.cwd, ids, true);
722
+ }
723
+ ctx.ui.notify(failed.length > 0
724
+ ? `已启用全部可管理的 Skill(${failed.join("、")} 启用失败:可能未通过来源、许可或依赖预检)。请运行 /reload 重新加载。`
725
+ : `已启用全部可管理的 Skill。请运行 /reload 重新加载。`, failed.length > 0 ? "warning" : "info");
726
+ }
727
+ else {
728
+ for (const row of managed) {
729
+ try {
730
+ await setRecommendedSkillEnabled(ctx.cwd, row.id, false);
731
+ }
732
+ catch { /* already disabled */ }
733
+ }
734
+ if (locals.length > 0) {
735
+ const ids = locals.map((row) => row.name);
736
+ await setLocalSkillsEnabled(ctx.cwd, ids, false);
737
+ }
738
+ ctx.ui.notify("已停用全部可管理的 Skill。请运行 /reload 重新加载。", "info");
739
+ }
740
+ continue;
741
+ }
506
742
  const row = rows.find((candidate) => candidate.id === action.id);
507
743
  if (!row)
508
744
  continue;
@@ -511,8 +747,14 @@ async function showSkillManager(pi, args, ctx) {
511
747
  return;
512
748
  }
513
749
  if (action.type === "toggle") {
514
- await setRecommendedSkillEnabled(ctx.cwd, row.id, action.enabled);
515
- ctx.ui.notify(`已${action.enabled ? "启用" : "停用"} ${row.name}。请运行 /reload 重新加载。`, "info");
750
+ if (row.source === "local") {
751
+ await setLocalSkillEnabled(ctx.cwd, row.name, action.enabled);
752
+ ctx.ui.notify(`已${action.enabled ? "启用" : "停用"}本地 Skill ${row.name}。请运行 /reload 重新加载。`, "info");
753
+ }
754
+ else {
755
+ await setRecommendedSkillEnabled(ctx.cwd, row.id, action.enabled);
756
+ ctx.ui.notify(`已${action.enabled ? "启用" : "停用"} ${row.name}。请运行 /reload 重新加载。`, "info");
757
+ }
516
758
  }
517
759
  }
518
760
  }
@@ -614,13 +856,26 @@ const WORKFLOW_RUNNERS = {
614
856
  export default function psyclawExtension(pi) {
615
857
  const developerCommands = process.env.PSYCLAW_DEVELOPER_COMMANDS === "1";
616
858
  const legacyTestApi = typeof pi.registerTool !== "function";
859
+ const runtimeMcps = new RuntimeMcpRegistry();
617
860
  if (!legacyTestApi && typeof pi.on === "function")
618
861
  pi.on("resources_discover", async (event, ctx) => {
619
862
  const enabled = await enabledRecommendedSkillPaths(event.cwd);
620
863
  for (const warning of enabled.warnings)
621
864
  ctx.ui.notify(`PsyClaw Skill: ${warning}`, "warning");
622
- return { skillPaths: enabled.paths };
865
+ const local = await enabledLocalSkillPaths(event.cwd, {
866
+ excludedNames: [...coreSkillNames(), ...await skillNamesInPaths(enabled.paths)],
867
+ });
868
+ for (const warning of local.warnings)
869
+ ctx.ui.notify(`PsyClaw Skill: ${warning}`, "warning");
870
+ // Return only existing Skill directories. Same-name local/core candidates
871
+ // are resolved before Pi sees them, so harmless collisions stay silent.
872
+ return {
873
+ skillPaths: [...enabled.paths, ...local.paths],
874
+ promptPaths: await enabledLocalPromptPaths(event.cwd),
875
+ };
623
876
  });
877
+ if (!legacyTestApi && typeof pi.on === "function")
878
+ pi.on("session_shutdown", () => runtimeMcps.close());
624
879
  pi.registerCommand("init", {
625
880
  description: "初始化可追溯的研究项目",
626
881
  handler: async (args, ctx) => {
@@ -642,18 +897,47 @@ export default function psyclawExtension(pi) {
642
897
  });
643
898
  if (!legacyTestApi)
644
899
  pi.registerCommand("run", {
645
- description: "在 /init 后启动受控研究流程",
900
+ description: "启动受控研究流程,可用 --skills a,b 选择本次优化 Skill",
646
901
  handler: async (args, ctx) => {
647
902
  try {
648
- const project = await readProject(ctx.cwd);
649
- const objective = args.trim() || project.goal;
650
- await activateControlledRun(ctx.cwd, project.id, objective);
651
- pi.appendEntry("psyclaw:controlled-run", { projectId: project.id, objective, activatedAt: new Date().toISOString() });
652
- pi.sendUserMessage(controlledRunRequest(objective), ctx.isIdle() ? {} : { deliverAs: "followUp" });
653
- ctx.ui.notify("受控研究流程已启动。分析完成后会先询问是否撰写论文;不会直接进入 DOCX 导出。", "info");
903
+ const parsed = parseRunArgs(args);
904
+ let project;
905
+ try {
906
+ project = await readProject(ctx.cwd);
907
+ }
908
+ catch (error) {
909
+ // No `.psyclaw/project.json` (or it is corrupt): fall back to the
910
+ // compliant analysis documents when present, so a project prepared
911
+ // outside the /init flow can be analyzed directly.
912
+ const { bootstrapProjectFromAnalysisDocs } = await import("../../project/bootstrap.js");
913
+ try {
914
+ project = await bootstrapProjectFromAnalysisDocs(ctx.cwd);
915
+ }
916
+ catch (bootstrapError) {
917
+ ctx.ui.notify(bootstrapError instanceof Error ? bootstrapError.message : String(bootstrapError), "warning");
918
+ return;
919
+ }
920
+ ctx.ui.notify("检测到合规的分析文档,已自动补齐研究项目记录并启动受控流程。", "info");
921
+ }
922
+ const available = await selectableRunSkills(ctx.cwd);
923
+ let requestedSkills = parsed.requestedSkills;
924
+ if (requestedSkills === undefined && ctx.hasUI && typeof ctx.ui.input === "function") {
925
+ const answer = await ctx.ui.input("选择本次 Run 使用的 Skill(逗号分隔,可留空)", available.map((skill) => skill.name).join(", "));
926
+ if (answer === undefined) {
927
+ ctx.ui.notify("已取消启动受控研究流程", "info");
928
+ return;
929
+ }
930
+ requestedSkills = answer.split(",").map((item) => item.trim()).filter(Boolean);
931
+ }
932
+ const selectedSkills = resolveRunSkills(requestedSkills ?? [], available);
933
+ const objective = parsed.objective || project.goal;
934
+ await activateControlledRun(ctx.cwd, project.id, objective, selectedSkills);
935
+ pi.appendEntry("psyclaw:controlled-run", { projectId: project.id, objective, selectedSkills, activatedAt: new Date().toISOString() });
936
+ pi.sendUserMessage(controlledRunRequest(objective, selectedSkills), ctx.isIdle() ? {} : { deliverAs: "followUp" });
937
+ ctx.ui.notify(`受控研究流程已启动${selectedSkills.length > 0 ? `;本次使用 Skill:${selectedSkills.join(", ")}` : ";使用默认 Skill"}。分析完成后会先询问是否撰写论文。`, "info");
654
938
  }
655
- catch {
656
- ctx.ui.notify("请先使用 /init 初始化研究项目;未运行 /init 和 /run 时保持普通对话模式。", "warning");
939
+ catch (error) {
940
+ await notifyError(ctx, error);
657
941
  }
658
942
  },
659
943
  });
@@ -799,12 +1083,24 @@ export default function psyclawExtension(pi) {
799
1083
  }
800
1084
  },
801
1085
  });
1086
+ if (!legacyTestApi)
1087
+ pi.registerCommand("skill", {
1088
+ description: "调用任意已加载 Skill(/skill <name> [任务])",
1089
+ handler: async (args, ctx) => {
1090
+ const [name, ...rest] = args.trim().split(/\s+/).filter(Boolean);
1091
+ if (!name || !/^[a-z0-9-]+$/.test(name)) {
1092
+ ctx.ui.notify("Usage: /skill <name> [task];也可直接使用 /skill:<name>", "info");
1093
+ return;
1094
+ }
1095
+ pi.sendUserMessage(`/skill:${name}${rest.length ? ` ${rest.join(" ")}` : ""}`, ctx.isIdle() ? {} : { deliverAs: "followUp" });
1096
+ },
1097
+ });
802
1098
  if (!legacyTestApi)
803
1099
  pi.registerCommand("mcp", {
804
1100
  description: "打开 MCP 安装与配置管理页",
805
1101
  handler: async (args, ctx) => {
806
1102
  try {
807
- await showMcpManager(pi, args, ctx);
1103
+ await showMcpManager(pi, args, ctx, runtimeMcps);
808
1104
  }
809
1105
  catch (error) {
810
1106
  await notifyError(ctx, error);
@@ -831,13 +1127,13 @@ export default function psyclawExtension(pi) {
831
1127
  if (kind === "skill")
832
1128
  await showSkillManager(pi, "", ctx);
833
1129
  else
834
- await showMcpManager(pi, "", ctx);
1130
+ await showMcpManager(pi, "", ctx, runtimeMcps);
835
1131
  return;
836
1132
  }
837
1133
  if (kind === "skill")
838
1134
  await showSkillManager(pi, `install ${id}`, ctx);
839
1135
  else
840
- await showMcpManager(pi, `install ${id}`, ctx);
1136
+ await showMcpManager(pi, `install ${id}`, ctx, runtimeMcps);
841
1137
  }
842
1138
  catch (error) {
843
1139
  await notifyError(ctx, error);
@@ -945,18 +1241,18 @@ export default function psyclawExtension(pi) {
945
1241
  },
946
1242
  });
947
1243
  const traceCommand = {
948
- description: "导出脱敏使用路径,供 Langfuse 或 LangSmith 分析",
1244
+ description: "导出包含正文与工具参数的完整使用路径,供 Langfuse 或 LangSmith 分析",
949
1245
  handler: async (args, ctx) => {
950
1246
  try {
951
1247
  if (args.trim())
952
- throw new Error("Usage: /trace");
1248
+ throw new Error("Usage: /export");
953
1249
  const result = await exportTraces({ root: ctx.cwd });
954
1250
  ctx.ui.notify([
955
1251
  "使用路径已导出(未上传)",
956
1252
  `文件:${result.output}`,
957
1253
  `轨迹:${result.traces}`,
958
1254
  `步骤:${result.spans}`,
959
- "隐私:不含对话正文、工具参数、原始 ID 或绝对路径",
1255
+ "包含对话正文、工具参数、原始 ID 与绝对路径,便于排查各环节问题;请勿将导出文件提交到公开仓库。",
960
1256
  ].join("\n"), "info");
961
1257
  }
962
1258
  catch (error) {
@@ -964,8 +1260,40 @@ export default function psyclawExtension(pi) {
964
1260
  }
965
1261
  },
966
1262
  };
967
- pi.registerCommand("trace", traceCommand);
1263
+ pi.registerCommand("export", traceCommand);
968
1264
  if (typeof pi.registerTool === "function") {
1265
+ pi.registerTool({
1266
+ name: "psyclaw_mcp",
1267
+ label: "MCP tools",
1268
+ description: "List and call tools from MCP servers enabled in .psyclaw/mcp/*.json. Use action=list first, then action=call with the exact server and tool names.",
1269
+ promptSnippet: "Discover and call enabled MCP servers such as MNE through PsyClaw.",
1270
+ parameters: Type.Object({
1271
+ action: Type.Union([Type.Literal("list"), Type.Literal("call")]),
1272
+ server: Type.Optional(Type.String({ description: "Configured MCP server id, for example mne-mcp" })),
1273
+ tool: Type.Optional(Type.String({ description: "Exact MCP tool name returned by action=list" })),
1274
+ input: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
1275
+ }),
1276
+ executionMode: "sequential",
1277
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
1278
+ try {
1279
+ if (params.action === "list") {
1280
+ const servers = await runtimeMcps.list(ctx.cwd, params.server);
1281
+ return { content: [{ type: "text", text: JSON.stringify({ servers }, null, 2) }], details: { action: "list", servers: servers.length } };
1282
+ }
1283
+ if (!params.server || !params.tool)
1284
+ throw new Error("server and tool are required for action=call");
1285
+ const result = await runtimeMcps.call(ctx.cwd, params.server, params.tool, params.input ?? {});
1286
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], details: { action: "call", server: params.server, tool: params.tool } };
1287
+ }
1288
+ catch (error) {
1289
+ return {
1290
+ content: [{ type: "text", text: `MCP call failed: ${error instanceof Error ? error.message : String(error)}` }],
1291
+ details: { action: params.action, status: "failed" },
1292
+ isError: true,
1293
+ };
1294
+ }
1295
+ },
1296
+ });
969
1297
  pi.registerTool({
970
1298
  name: "psyclaw_skill",
971
1299
  label: "Research skill",