psyclaw 0.30.16 → 0.30.17

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.
@@ -31,9 +31,10 @@ import { readPublishedVersions } from "../workflows/publish.js";
31
31
  export { verifyDoi } from "../core/doi.js";
32
32
  import { PSYCLAW_IDENTITY_PROMPT } from "../branding.js";
33
33
  import { sessionHelpDocument } from "../session/help.js";
34
- import { recommendedSkillTarget } from "../skills/recommended.js";
34
+ import { normalizeRecommendedSkillId, readMergedRecommendationState, readRecommendationState as readCanonicalRecommendationState, recommendedSkillTarget, saveRecommendationState, validateModelInstalledRecommendedSkill, } from "../skills/recommended.js";
35
35
  import { buildRecommendedSkillInstallTask } from "../skills/install-guidance.js";
36
36
  import { readUserSkillState, scanLocalSkills, setLocalSkillEnabled, userSkillId, } from "../skills/user-skills.js";
37
+ import { RuntimeMcpRegistry } from "../integrations/mcp-runtime.js";
37
38
  import { defaultVerifyChecklist, loadVerifyChecklist, markVerifyItem, skipUnverifiedItems } from "../verify/checklist.js";
38
39
  import { readActiveAnalysisPlan } from "../analysis/plan.js";
39
40
  import { browserConfigForPreference, injectBrowserObservabilityConfig } from "../observability/config.js";
@@ -53,6 +54,61 @@ async function readRecommendationState(root) {
53
54
  async function writeRecommendationState(root, state) {
54
55
  await atomicWriteFile(await assertSafeProjectPath(root, ".psyclaw/recommendations.json"), `${JSON.stringify({ ...state, skills: [...new Set(state.skills)].sort(), mcp: [...new Set(state.mcp)].sort() }, null, 2)}\n`);
55
56
  }
57
+ /** Resolve Skill installed/enabled the same way the TUI /skill manager does. */
58
+ async function panelRecommendedSkillStatuses(root) {
59
+ const state = await readMergedRecommendationState(root);
60
+ const catalog = await recommendedSkills();
61
+ return Promise.all((catalog.items ?? []).map(async (item) => {
62
+ const id = normalizeRecommendedSkillId(String(item.id ?? ""));
63
+ const name = typeof item.name === "string" ? item.name : id;
64
+ const scope = state.skillScopes?.[id] ?? "project";
65
+ const desired = state.skills.includes(id);
66
+ try {
67
+ await validateModelInstalledRecommendedSkill(root, id, scope);
68
+ return { id, name, installed: true, enabled: desired, scope };
69
+ }
70
+ catch {
71
+ let onDisk = false;
72
+ try {
73
+ const target = recommendedSkillTarget(root, id, scope);
74
+ const stat = await lstat(target);
75
+ onDisk = stat.isDirectory();
76
+ }
77
+ catch { /* missing */ }
78
+ if (!onDisk && scope === "project") {
79
+ try {
80
+ const userTarget = recommendedSkillTarget(root, id, "user");
81
+ const stat = await lstat(userTarget);
82
+ if (stat.isDirectory()) {
83
+ return { id, name, installed: true, enabled: desired, scope: "user" };
84
+ }
85
+ }
86
+ catch { /* missing */ }
87
+ }
88
+ return { id, name, installed: onDisk, enabled: onDisk && desired, scope };
89
+ }
90
+ }));
91
+ }
92
+ /** Resolve MCP installed/enabled from config files + recommendation desire. */
93
+ async function panelRecommendedMcpStatuses(root) {
94
+ const state = await readMergedRecommendationState(root);
95
+ const catalog = await recommendedMcps();
96
+ const configured = await new RuntimeMcpRegistry().listUserConfigs(root);
97
+ const byId = new Map();
98
+ for (const entry of configured) {
99
+ const previous = byId.get(entry.id);
100
+ if (!previous || entry.enabled)
101
+ byId.set(entry.id, { enabled: entry.enabled });
102
+ }
103
+ return (catalog.items ?? []).map((item) => {
104
+ const id = String(item.id ?? "");
105
+ const name = typeof item.name === "string" ? item.name : id;
106
+ const installed = byId.has(id);
107
+ const desired = state.mcp.includes(id);
108
+ // Match TUI recommended rows: installed = config present; enabled = desire.
109
+ return { id, name, installed, enabled: installed && desired };
110
+ });
111
+ }
56
112
  /** Bundled capabilities, listed separately from the downloadable ecosystem. */
57
113
  const CORE_SKILLS = [
58
114
  { id: "academic-grill", name: "学术追问" },
@@ -1232,16 +1288,11 @@ export function createPanelServer(root, options = {}) {
1232
1288
  return;
1233
1289
  }
1234
1290
  if (url.pathname === "/api/enabled-capabilities") {
1235
- const state = await readRecommendationState(root);
1236
1291
  const disabled = await readDisabledCapabilities(root);
1237
- const [skills, mcps] = await Promise.all([recommendedSkills(), recommendedMcps()]);
1238
- const skillIds = new Set((skills.items ?? []).map((item) => String(item.id ?? "")));
1239
- const mcpIds = new Set((mcps.items ?? []).map((item) => String(item.id ?? "")));
1240
- const nameOf = (kind, id) => {
1241
- const catalog = kind === "skill" ? skills : mcps;
1242
- const item = (catalog.items ?? []).find((candidate) => candidate.id === id);
1243
- return typeof item?.name === "string" ? item.name : id;
1244
- };
1292
+ const [skillStatuses, mcpStatuses] = await Promise.all([
1293
+ panelRecommendedSkillStatuses(root),
1294
+ panelRecommendedMcpStatuses(root),
1295
+ ]);
1245
1296
  // User-installed (non-catalog) skills from the expanded discovery
1246
1297
  // roots, with their individual enable state.
1247
1298
  const userSkillState = await readUserSkillState(root);
@@ -1252,6 +1303,7 @@ export function createPanelServer(root, options = {}) {
1252
1303
  description: skill.description,
1253
1304
  path: skill.path,
1254
1305
  enabled: !disabledUserSkills.has(skill.name),
1306
+ installed: true,
1255
1307
  }));
1256
1308
  response.writeHead(200, { "content-type": "application/json" });
1257
1309
  response.end(JSON.stringify({
@@ -1259,9 +1311,10 @@ export function createPanelServer(root, options = {}) {
1259
1311
  coreSkills: CORE_SKILLS.map((skill) => ({
1260
1312
  ...skill,
1261
1313
  enabled: "locked" in skill && skill.locked ? true : !disabled.coreSkills.includes(skill.id),
1314
+ installed: true,
1262
1315
  })),
1263
- skills: state.skills.filter((id) => skillIds.has(id)).map((id) => ({ id, name: nameOf("skill", id), enabled: true })),
1264
- mcp: state.mcp.filter((id) => mcpIds.has(id)).map((id) => ({ id, name: nameOf("mcp", id), enabled: true })),
1316
+ skills: skillStatuses,
1317
+ mcp: mcpStatuses,
1265
1318
  localSkills,
1266
1319
  }));
1267
1320
  return;
@@ -1560,14 +1613,29 @@ export function createPanelServer(root, options = {}) {
1560
1613
  response.end(JSON.stringify({ schemaVersion: "psyclaw/local-skill-state-receipt/v1", ok: true, id, enabled: body.enabled, disabled: state.disabled }));
1561
1614
  return;
1562
1615
  }
1563
- const state = await readRecommendationState(root);
1616
+ const state = await readCanonicalRecommendationState(root);
1617
+ if (kind === "skills" && body.enabled) {
1618
+ const scope = state.skillScopes?.[id] ?? "project";
1619
+ try {
1620
+ await validateModelInstalledRecommendedSkill(root, id, scope);
1621
+ }
1622
+ catch {
1623
+ throw new Error(`Skill 尚未安装到本地,无法启用:${id}。请先安装或重新安装。`);
1624
+ }
1625
+ }
1626
+ if (kind === "mcp" && body.enabled) {
1627
+ const configs = await new RuntimeMcpRegistry().listUserConfigs(root);
1628
+ if (!configs.some((entry) => entry.id === id)) {
1629
+ throw new Error(`MCP 尚未安装到本地,无法启用:${id}。请先安装或重新安装。`);
1630
+ }
1631
+ }
1564
1632
  const values = new Set(state[kind]);
1565
1633
  if (body.enabled)
1566
1634
  values.add(id);
1567
1635
  else
1568
1636
  values.delete(id);
1569
1637
  state[kind] = [...values];
1570
- await writeRecommendationState(root, state);
1638
+ await saveRecommendationState(root, state);
1571
1639
  response.writeHead(200, { "content-type": "application/json" });
1572
1640
  response.end(JSON.stringify({ schemaVersion: "psyclaw/recommendation-state-receipt/v1", ok: true, state }));
1573
1641
  return;