psyclaw 0.27.6 → 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 (37) hide show
  1. package/README.md +7 -7
  2. package/dist/apps/panel/index.html +11 -11
  3. package/dist/src/adapters/pi/extension.js +336 -67
  4. package/dist/src/adapters/pi/extension.js.map +1 -1
  5. package/dist/src/cli.js +1 -4
  6. package/dist/src/cli.js.map +1 -1
  7. package/dist/src/index.d.ts +1 -0
  8. package/dist/src/index.js +1 -0
  9. package/dist/src/index.js.map +1 -1
  10. package/dist/src/integrations/mcp-runtime.d.ts +13 -0
  11. package/dist/src/integrations/mcp-runtime.js +48 -0
  12. package/dist/src/integrations/mcp-runtime.js.map +1 -1
  13. package/dist/src/panel/events.d.ts +2 -1
  14. package/dist/src/panel/events.js +2 -5
  15. package/dist/src/panel/events.js.map +1 -1
  16. package/dist/src/panel/server.js +28 -8
  17. package/dist/src/panel/server.js.map +1 -1
  18. package/dist/src/project/bootstrap.d.ts +17 -0
  19. package/dist/src/project/bootstrap.js +87 -1
  20. package/dist/src/project/bootstrap.js.map +1 -1
  21. package/dist/src/skills/user-skills.d.ts +57 -0
  22. package/dist/src/skills/user-skills.js +294 -0
  23. package/dist/src/skills/user-skills.js.map +1 -0
  24. package/dist/src/style/cli-ui.js +1 -1
  25. package/dist/src/style/cli-ui.js.map +1 -1
  26. package/dist/src/telemetry/export.d.ts +8 -6
  27. package/dist/src/telemetry/export.js +81 -18
  28. package/dist/src/telemetry/export.js.map +1 -1
  29. package/dist/src/tui/skill-manager.d.ts +3 -0
  30. package/dist/src/tui/skill-manager.js +11 -1
  31. package/dist/src/tui/skill-manager.js.map +1 -1
  32. package/package.json +1 -1
  33. package/scripts/rebrand-pi.mjs +66 -4
  34. package/skills/core/citation-audit/SKILL.md +9 -9
  35. package/skills/core/evidence-capture/SKILL.md +9 -9
  36. package/skills/core/research-brief/SKILL.md +8 -8
  37. package/skills/core/research-intake/SKILL.md +9 -9
@@ -6,14 +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";
10
- import { homedir } from "node:os";
9
+ import { lstat, mkdir, readFile } from "node:fs/promises";
11
10
  import { PROVIDER_PRESETS, saveProviderConfig } from "../../setup.js";
12
11
  import { dirname } from "node:path";
13
12
  import { fileURLToPath } from "node:url";
14
13
  import { coreSkillNames, enabledRecommendedSkillPaths, normalizeRecommendedSkillId, readRecommendationState, readRecommendedCatalog, recommendedSkillTarget, saveRecommendationState, validateModelInstalledRecommendedSkill, } from "../../skills/recommended.js";
15
14
  import { SkillManagerComponent } from "../../tui/skill-manager.js";
16
- import { RuntimeMcpRegistry } from "../../integrations/mcp-runtime.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";
17
17
  import { ProviderPickerComponent, SecretInputComponent, } from "../../tui/provider-picker.js";
18
18
  const PARADIGMS = new Set([
19
19
  "survey-observational",
@@ -165,28 +165,32 @@ async function readControlledRun(root) {
165
165
  try {
166
166
  const value = JSON.parse(await readFile(await controlledRunPath(root), "utf8"));
167
167
  return value.schemaVersion === "psyclaw/controlled-run/v1" && value.status === "active" && typeof value.projectId === "string" && typeof value.objective === "string" && typeof value.activatedAt === "string"
168
- ? value
168
+ ? { ...value, selectedSkills: Array.isArray(value.selectedSkills) ? value.selectedSkills.filter((item) => typeof item === "string") : [] }
169
169
  : null;
170
170
  }
171
171
  catch {
172
172
  return null;
173
173
  }
174
174
  }
175
- async function activateControlledRun(root, projectId, objective) {
175
+ async function activateControlledRun(root, projectId, objective, selectedSkills) {
176
176
  const state = {
177
177
  schemaVersion: "psyclaw/controlled-run/v1",
178
178
  projectId,
179
179
  objective,
180
+ selectedSkills: [...new Set(selectedSkills)],
180
181
  activatedAt: new Date().toISOString(),
181
182
  status: "active",
182
183
  };
183
184
  await atomicWriteFile(await controlledRunPath(root), `${JSON.stringify(state, null, 2)}\n`);
184
185
  return state;
185
186
  }
186
- function controlledRunRequest(objective) {
187
+ function controlledRunRequest(objective, selectedSkills) {
187
188
  return [
188
189
  "PsyClaw 受控研究流程已由用户通过 /run 明确启动。先读取 .psyclaw/project.json、.psyclaw/controlled-run.json、notes/research-spec.md 和 notes/plan.md。",
189
190
  `本次目标:${objective}`,
191
+ selectedSkills.length > 0
192
+ ? `本次运行由用户选择的优化 Skill:${selectedSkills.join(", ")}。在相关任务中优先加载并遵循这些 Skill;同名 Skill 只加载一次。`
193
+ : "本次运行未指定额外 Skill,使用 PsyClaw 默认研究流程。不要删除、停用或改写已安装的其他 Skill。",
190
194
  "实际推进当前尚未完成的研究任务;先形成或更新符合学术规范的 Markdown 分析报告,不能只描述计划。分析报告必须清楚区分数据来源、统计结果、文献证据、限制与尚未核验内容。",
191
195
  "分析报告完成后,下一步不是直接询问是否导出 DOCX,而是询问用户是否据此撰写论文。",
192
196
  "若用户选择撰写论文,再单独询问是否先进行文献调研,并说明该阶段可能花费较长时间;提示用户可以指定已启用的 Skill,或者选择 PsyClaw 默认的文献调研方式。不要在获得答复前自动开始长时调研。",
@@ -196,6 +200,53 @@ function controlledRunRequest(objective) {
196
200
  "如果用户在未完成文献调研、全文写作或评审前要求导出 DOCX,允许导出当前分析报告,但必须明确标为遵循学术规范的分析报告,不得称为论文。",
197
201
  ].join("\n");
198
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
+ }
199
250
  async function recommendedItems(kind) {
200
251
  const file = kind === "skills" ? "catalog.json" : "mcp-catalog.json";
201
252
  const moduleDir = dirname(fileURLToPath(import.meta.url));
@@ -219,11 +270,13 @@ async function recommendedItems(kind) {
219
270
  return { items: [], externalTools: [], installPrep: [] };
220
271
  }
221
272
  function skillScopeLabel(scope) {
273
+ if (scope === "local")
274
+ return "用户本地目录(所有项目可发现)";
222
275
  return scope === "user" ? "系统目录(所有项目)" : "项目目录(仅当前项目)";
223
276
  }
224
277
  async function skillManagerRows(root, state) {
225
278
  const catalog = await readRecommendedCatalog();
226
- 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) => {
227
280
  const id = normalizeRecommendedSkillId(item.id);
228
281
  const scope = state.skillScopes?.[id] ?? "project";
229
282
  try {
@@ -239,9 +292,21 @@ async function skillManagerRows(root, state) {
239
292
  enabled: state.skills.includes(id),
240
293
  scope,
241
294
  blocked: false,
295
+ source: "recommended",
242
296
  };
243
297
  }
244
- 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 */ }
245
310
  return {
246
311
  id,
247
312
  name: item.name,
@@ -249,20 +314,50 @@ async function skillManagerRows(root, state) {
249
314
  ...(item.sourceRef === undefined ? {} : { sourceRef: item.sourceRef }),
250
315
  ...(typeof item.installHint === "string" ? { installHint: item.installHint } : {}),
251
316
  collection: item.skillLayout === "collection",
252
- installed: false,
253
- enabled: false,
317
+ installed: onDisk,
318
+ enabled: onDisk && state.skills.includes(id),
254
319
  scope,
255
320
  blocked: typeof item.sourceRef !== "string" || !/^https:\/\//.test(item.sourceRef),
256
- reason: typeof item.sourceRef === "string" && /^https:\/\//.test(item.sourceRef)
257
- ? "尚未安装;按 Enter 选择安装范围并交给当前模型处理"
258
- : "没有可交给模型检查的来源网址",
321
+ reason: onDisk
322
+ ? `已检测到目录但未通过来源/许可/哈希校验${state.skills.includes(id) ? "(当前配置为启用,但校验失败不会加载)" : ""};可按 Enter 重新安装修复`
323
+ : typeof item.sourceRef === "string" && /^https:\/\//.test(item.sourceRef)
324
+ ? "尚未安装;按 Enter 选择安装范围并交给当前模型处理"
325
+ : "没有可交给模型检查的来源网址",
326
+ source: "recommended",
259
327
  };
260
328
  }
261
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];
262
357
  }
263
- async function mcpManagerRows(state) {
358
+ async function mcpManagerRows(root, state, runtime) {
264
359
  const { items, installPrep } = await recommendedItems("mcp");
265
- return items.map((item) => {
360
+ const recommendedRows = items.map((item) => {
266
361
  const id = String(item.id ?? "");
267
362
  const plan = installPrep.find((candidate) => candidate.id === id);
268
363
  const dependencies = Array.isArray(plan?.dependencies)
@@ -274,6 +369,7 @@ async function mcpManagerRows(state) {
274
369
  description: String(item.description ?? ""),
275
370
  ...(typeof item.sourceRef === "string" ? { sourceRef: item.sourceRef } : {}),
276
371
  enabled: state.mcp.includes(id),
372
+ source: "recommended",
277
373
  details: [
278
374
  `传输:${String(item.transport ?? "未声明")} · 风险:${String(item.risk ?? "未声明")}`,
279
375
  `参考版本:${String(plan?.ref ?? "由模型读取来源确定")} · 许可证:${String(plan?.license ?? "由模型读取来源确定")}`,
@@ -281,6 +377,31 @@ async function mcpManagerRows(state) {
281
377
  ],
282
378
  };
283
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
+ });
284
405
  }
285
406
  function skillRowLabel(row) {
286
407
  const state = row.blocked ? "× 阻断" : row.enabled ? "● 已启用" : row.installed ? "○ 未启用" : "↓ 未安装";
@@ -316,7 +437,12 @@ function skillManagerItems(rows) {
316
437
  description: row.description,
317
438
  status: row.blocked ? "blocked" : row.enabled ? "enabled" : row.installed ? "disabled" : "missing",
318
439
  ...(row.sourceRef === undefined ? {} : { sourceRef: row.sourceRef }),
319
- details: [`安装位置:${skillScopeLabel(row.scope)}`],
440
+ details: [
441
+ `安装位置:${skillScopeLabel(row.scope)}`,
442
+ ...(row.duplicatePaths && row.duplicatePaths.length > 0
443
+ ? [`已忽略 ${row.duplicatePaths.length} 个同名来源`]
444
+ : []),
445
+ ],
320
446
  ...(row.reason === undefined ? {} : { reason: row.reason }),
321
447
  })),
322
448
  ];
@@ -339,18 +465,14 @@ async function openMcpManager(ctx, rows) {
339
465
  return ctx.ui.custom((tui, theme, keybindings, done) => (new SkillManagerComponent(mcpManagerItems(rows), tui, theme, keybindings, done, {
340
466
  title: "MCP 管理",
341
467
  itemLabel: "MCP",
342
- footer: "↑/↓ 移动 · Space 开启/关闭项目配置 · Enter 交给模型安装并配置 · Esc 关闭",
468
+ footer: "↑/↓ 移动 · Space 开启/关闭 · a 全部开启 · d 全部关闭 · Enter 交给模型安装并配置 · Esc 关闭",
343
469
  enterAction: "install",
344
- enabledText: "项目配置已开启;Enter 可让当前模型重新安装或修复配置",
345
- disabledText: "项目配置已关闭",
470
+ enabledText: "已开启;Enter 可让当前模型重新安装或修复推荐配置",
471
+ disabledText: "已关闭",
346
472
  })));
347
473
  }
348
474
  async function setRecommendedMcpEnabled(root, id, enabled) {
349
475
  const state = await readRecommendationState(root);
350
- const rows = await mcpManagerRows(state);
351
- const row = rows.find((candidate) => candidate.id === id);
352
- if (!row)
353
- throw new Error(`未找到推荐 MCP: ${id}`);
354
476
  const current = new Set(state.mcp);
355
477
  if (enabled)
356
478
  current.add(id);
@@ -359,6 +481,9 @@ async function setRecommendedMcpEnabled(root, id, enabled) {
359
481
  state.mcp = [...current];
360
482
  await saveRecommendationState(root, state);
361
483
  }
484
+ async function setUserMcpEnabled(entry, enabled) {
485
+ await setUserMcpConfigEnabled(entry, enabled);
486
+ }
362
487
  function modelMcpInstallTask(root, row, plan) {
363
488
  const suggestedCommand = typeof plan?.command === "string" ? plan.command : "请根据来源仓库的最新安装说明确定";
364
489
  const suggestedTarget = typeof plan?.target === "string" ? plan.target : `.psyclaw/mcp/${row.id}.json`;
@@ -381,48 +506,86 @@ async function queueModelMcpInstall(pi, ctx, row, plan) {
381
506
  pi.sendUserMessage(modelMcpInstallTask(ctx.cwd, row, plan), ctx.isIdle() ? {} : { deliverAs: "followUp" });
382
507
  ctx.ui.notify(`已将 ${row.name} 的下载、安装和配置任务交给当前模型。模型完成并确认可启动后,请执行 /reload。`, "info");
383
508
  }
384
- async function showMcpManager(pi, args, ctx) {
509
+ async function showMcpManager(pi, args, ctx, runtime) {
385
510
  const [verb, id] = args.trim().split(/\s+/).filter(Boolean);
386
- if (verb && !["status", "enable", "disable", "install"].includes(verb)) {
387
- 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>]");
388
513
  }
389
514
  if ((verb === "enable" || verb === "disable" || verb === "install") && !id) {
390
515
  throw new Error(`Usage: /mcp ${verb} <id>`);
391
516
  }
392
517
  const catalog = await recommendedItems("mcp");
393
518
  if (verb === "install") {
394
- const rows = await mcpManagerRows(await readRecommendationState(ctx.cwd));
519
+ const rows = await mcpManagerRows(ctx.cwd, await readRecommendationState(ctx.cwd), runtime);
395
520
  const row = rows.find((candidate) => candidate.id === id);
396
521
  if (!row)
397
522
  throw new Error(`未找到推荐 MCP: ${id}`);
523
+ if (row.source !== "recommended")
524
+ throw new Error(`MCP ${id} 已是用户配置,无需再次安装`);
398
525
  await queueModelMcpInstall(pi, ctx, row, catalog.installPrep.find((candidate) => candidate.id === row.id));
399
526
  return;
400
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
+ }
401
540
  if (verb === "enable" || verb === "disable") {
402
- await setRecommendedMcpEnabled(ctx.cwd, id, verb === "enable");
403
- 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");
404
550
  return;
405
551
  }
406
552
  if (!ctx.hasUI || typeof ctx.ui.custom !== "function" || verb === "status") {
407
- const rows = await mcpManagerRows(await readRecommendationState(ctx.cwd));
408
- 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");
409
555
  return;
410
556
  }
411
557
  while (true) {
412
- const rows = await mcpManagerRows(await readRecommendationState(ctx.cwd));
558
+ const rows = await mcpManagerRows(ctx.cwd, await readRecommendationState(ctx.cwd), runtime);
413
559
  const action = await openMcpManager(ctx, rows);
414
560
  if (action.type === "close")
415
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
+ }
416
572
  const row = rows.find((candidate) => candidate.id === action.id);
417
573
  if (!row)
418
574
  continue;
419
575
  if (action.type === "install") {
576
+ if (row.source !== "recommended") {
577
+ ctx.ui.notify(`${row.name} 已是用户配置,无需再次安装`, "info");
578
+ continue;
579
+ }
420
580
  await queueModelMcpInstall(pi, ctx, row, catalog.installPrep.find((candidate) => candidate.id === row.id));
421
581
  return;
422
582
  }
423
583
  if (action.type === "toggle") {
424
- await setRecommendedMcpEnabled(ctx.cwd, row.id, action.enabled);
425
- 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");
426
589
  }
427
590
  }
428
591
  }
@@ -471,8 +634,8 @@ async function showSkillManager(pi, args, ctx) {
471
634
  const action = args.trim().split(/\s+/).filter(Boolean);
472
635
  const verb = action[0];
473
636
  const id = action[1];
474
- if (verb && !["status", "enable", "disable", "install"].includes(verb)) {
475
- 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>]");
476
639
  }
477
640
  if ((verb === "enable" || verb === "disable" || verb === "install") && !id) {
478
641
  throw new Error(`Usage: /skills ${verb} <id>`);
@@ -484,9 +647,44 @@ async function showSkillManager(pi, args, ctx) {
484
647
  await queueModelSkillInstall(pi, ctx, row);
485
648
  return;
486
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
+ }
487
674
  if (verb === "enable" || verb === "disable") {
488
- await setRecommendedSkillEnabled(ctx.cwd, id, verb === "enable");
489
- 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");
490
688
  return;
491
689
  }
492
690
  if (!ctx.hasUI || typeof ctx.ui.custom !== "function" || verb === "status") {
@@ -505,6 +703,42 @@ async function showSkillManager(pi, args, ctx) {
505
703
  const action = await openSkillManager(ctx, rows);
506
704
  if (action.type === "close")
507
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
+ }
508
742
  const row = rows.find((candidate) => candidate.id === action.id);
509
743
  if (!row)
510
744
  continue;
@@ -513,8 +747,14 @@ async function showSkillManager(pi, args, ctx) {
513
747
  return;
514
748
  }
515
749
  if (action.type === "toggle") {
516
- await setRecommendedSkillEnabled(ctx.cwd, row.id, action.enabled);
517
- 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
+ }
518
758
  }
519
759
  }
520
760
  }
@@ -622,17 +862,17 @@ export default function psyclawExtension(pi) {
622
862
  const enabled = await enabledRecommendedSkillPaths(event.cwd);
623
863
  for (const warning of enabled.warnings)
624
864
  ctx.ui.notify(`PsyClaw Skill: ${warning}`, "warning");
625
- const localSkillPaths = [
626
- join(homedir(), ".claude", "skills"),
627
- join(homedir(), ".claude", "commands"),
628
- join(homedir(), ".codex", "skills"),
629
- join(homedir(), ".agents", "skills"),
630
- join(event.cwd, ".claude", "skills"),
631
- join(event.cwd, ".claude", "commands"),
632
- join(event.cwd, ".codex", "skills"),
633
- join(event.cwd, ".agents", "skills"),
634
- ];
635
- return { skillPaths: [...enabled.paths, ...localSkillPaths] };
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
+ };
636
876
  });
637
877
  if (!legacyTestApi && typeof pi.on === "function")
638
878
  pi.on("session_shutdown", () => runtimeMcps.close());
@@ -657,18 +897,47 @@ export default function psyclawExtension(pi) {
657
897
  });
658
898
  if (!legacyTestApi)
659
899
  pi.registerCommand("run", {
660
- description: "在 /init 后启动受控研究流程",
900
+ description: "启动受控研究流程,可用 --skills a,b 选择本次优化 Skill",
661
901
  handler: async (args, ctx) => {
662
902
  try {
663
- const project = await readProject(ctx.cwd);
664
- const objective = args.trim() || project.goal;
665
- await activateControlledRun(ctx.cwd, project.id, objective);
666
- pi.appendEntry("psyclaw:controlled-run", { projectId: project.id, objective, activatedAt: new Date().toISOString() });
667
- pi.sendUserMessage(controlledRunRequest(objective), ctx.isIdle() ? {} : { deliverAs: "followUp" });
668
- 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");
669
938
  }
670
- catch {
671
- ctx.ui.notify("请先使用 /init 初始化研究项目;未运行 /init 和 /run 时保持普通对话模式。", "warning");
939
+ catch (error) {
940
+ await notifyError(ctx, error);
672
941
  }
673
942
  },
674
943
  });
@@ -831,7 +1100,7 @@ export default function psyclawExtension(pi) {
831
1100
  description: "打开 MCP 安装与配置管理页",
832
1101
  handler: async (args, ctx) => {
833
1102
  try {
834
- await showMcpManager(pi, args, ctx);
1103
+ await showMcpManager(pi, args, ctx, runtimeMcps);
835
1104
  }
836
1105
  catch (error) {
837
1106
  await notifyError(ctx, error);
@@ -858,13 +1127,13 @@ export default function psyclawExtension(pi) {
858
1127
  if (kind === "skill")
859
1128
  await showSkillManager(pi, "", ctx);
860
1129
  else
861
- await showMcpManager(pi, "", ctx);
1130
+ await showMcpManager(pi, "", ctx, runtimeMcps);
862
1131
  return;
863
1132
  }
864
1133
  if (kind === "skill")
865
1134
  await showSkillManager(pi, `install ${id}`, ctx);
866
1135
  else
867
- await showMcpManager(pi, `install ${id}`, ctx);
1136
+ await showMcpManager(pi, `install ${id}`, ctx, runtimeMcps);
868
1137
  }
869
1138
  catch (error) {
870
1139
  await notifyError(ctx, error);
@@ -972,18 +1241,18 @@ export default function psyclawExtension(pi) {
972
1241
  },
973
1242
  });
974
1243
  const traceCommand = {
975
- description: "导出脱敏使用路径,供 Langfuse 或 LangSmith 分析",
1244
+ description: "导出包含正文与工具参数的完整使用路径,供 Langfuse 或 LangSmith 分析",
976
1245
  handler: async (args, ctx) => {
977
1246
  try {
978
1247
  if (args.trim())
979
- throw new Error("Usage: /trace");
1248
+ throw new Error("Usage: /export");
980
1249
  const result = await exportTraces({ root: ctx.cwd });
981
1250
  ctx.ui.notify([
982
1251
  "使用路径已导出(未上传)",
983
1252
  `文件:${result.output}`,
984
1253
  `轨迹:${result.traces}`,
985
1254
  `步骤:${result.spans}`,
986
- "隐私:不含对话正文、工具参数、原始 ID 或绝对路径",
1255
+ "包含对话正文、工具参数、原始 ID 与绝对路径,便于排查各环节问题;请勿将导出文件提交到公开仓库。",
987
1256
  ].join("\n"), "info");
988
1257
  }
989
1258
  catch (error) {
@@ -991,7 +1260,7 @@ export default function psyclawExtension(pi) {
991
1260
  }
992
1261
  },
993
1262
  };
994
- pi.registerCommand("trace", traceCommand);
1263
+ pi.registerCommand("export", traceCommand);
995
1264
  if (typeof pi.registerTool === "function") {
996
1265
  pi.registerTool({
997
1266
  name: "psyclaw_mcp",