psyclaw 0.27.0 → 0.27.5

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 (56) hide show
  1. package/README.md +9 -5
  2. package/dist/apps/panel/index.html +78 -3406
  3. package/dist/src/adapters/pi/extension.js +406 -107
  4. package/dist/src/adapters/pi/extension.js.map +1 -1
  5. package/dist/src/adapters/pi/rpc.js +7 -4
  6. package/dist/src/adapters/pi/rpc.js.map +1 -1
  7. package/dist/src/branding.js +7 -5
  8. package/dist/src/branding.js.map +1 -1
  9. package/dist/src/chat.js +0 -5
  10. package/dist/src/chat.js.map +1 -1
  11. package/dist/src/cli.js +0 -0
  12. package/dist/src/core/citations.d.ts +2 -0
  13. package/dist/src/core/citations.js +4 -1
  14. package/dist/src/core/citations.js.map +1 -1
  15. package/dist/src/core/docx.js +2 -2
  16. package/dist/src/core/docx.js.map +1 -1
  17. package/dist/src/index.d.ts +3 -0
  18. package/dist/src/index.js +3 -0
  19. package/dist/src/index.js.map +1 -1
  20. package/dist/src/literature/archive.d.ts +30 -0
  21. package/dist/src/literature/archive.js +133 -0
  22. package/dist/src/literature/archive.js.map +1 -0
  23. package/dist/src/orchestration/research-agents.d.ts +32 -0
  24. package/dist/src/orchestration/research-agents.js +225 -0
  25. package/dist/src/orchestration/research-agents.js.map +1 -0
  26. package/dist/src/panel/extension.js +2 -52
  27. package/dist/src/panel/extension.js.map +1 -1
  28. package/dist/src/panel/server.d.ts +2 -0
  29. package/dist/src/panel/server.js +113 -74
  30. package/dist/src/panel/server.js.map +1 -1
  31. package/dist/src/project/hitl.js +2 -0
  32. package/dist/src/project/hitl.js.map +1 -1
  33. package/dist/src/project/paths.d.ts +2 -6
  34. package/dist/src/project/paths.js +4 -12
  35. package/dist/src/project/paths.js.map +1 -1
  36. package/dist/src/skills/recommended.d.ts +10 -0
  37. package/dist/src/skills/recommended.js +85 -7
  38. package/dist/src/skills/recommended.js.map +1 -1
  39. package/dist/src/telemetry/export.d.ts +32 -0
  40. package/dist/src/telemetry/export.js +102 -1
  41. package/dist/src/telemetry/export.js.map +1 -1
  42. package/dist/src/tui/provider-picker.d.ts +48 -0
  43. package/dist/src/tui/provider-picker.js +115 -0
  44. package/dist/src/tui/provider-picker.js.map +1 -0
  45. package/dist/src/workflows/academic-export.d.ts +14 -0
  46. package/dist/src/workflows/academic-export.js +42 -0
  47. package/dist/src/workflows/academic-export.js.map +1 -0
  48. package/dist/src/workflows/meta-analysis.d.ts +1 -0
  49. package/dist/src/workflows/meta-analysis.js +17 -9
  50. package/dist/src/workflows/meta-analysis.js.map +1 -1
  51. package/dist/src/workflows/publish.js +14 -0
  52. package/dist/src/workflows/publish.js.map +1 -1
  53. package/package.json +1 -1
  54. package/skills/core/academic-grill/SKILL.md +77 -0
  55. package/skills/core/manifest.json +3 -2
  56. package/skills/recommended/catalog.json +210 -44
@@ -1,17 +1,18 @@
1
1
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
2
2
  import { Type } from "typebox";
3
- import { asProject, bootstrapProject, exportTraces, projectPaths, runOfflineBrief, runInstitutionalFulltext, runLiteratureReview, runExpertReview, runAnalysisDelegation, runWritingReview, runMetaAnalysis, createStageRunner, publishManuscript, recordCitationUse, writeHandoff } from "../../index.js";
3
+ import { asProject, bootstrapProject, exportTraces, projectPaths, runOfflineBrief, runInstitutionalFulltext, runLiteratureReview, runExpertReview, runAnalysisDelegation, runWritingReview, runMetaAnalysis, createStageRunner, exportAcademicDocument, recordCitationUse, runParallelPeerReview, writeHandoff } from "../../index.js";
4
4
  import { runPlanWithPi } from "../../orchestration/pi-executor.js";
5
5
  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
9
  import { mkdir, readFile } from "node:fs/promises";
10
- import { PROVIDER_PRESETS } from "../../setup.js";
10
+ import { PROVIDER_PRESETS, saveProviderConfig } from "../../setup.js";
11
11
  import { dirname } from "node:path";
12
12
  import { fileURLToPath } from "node:url";
13
- import { coreSkillNames, installRecommendedSkill, normalizeRecommendedSkillId, readRecommendationState, readRecommendedCatalog, saveRecommendationState, validateInstalledRecommendedSkill, } from "../../skills/recommended.js";
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 { ProviderPickerComponent, SecretInputComponent, } from "../../tui/provider-picker.js";
15
16
  const PARADIGMS = new Set([
16
17
  "survey-observational",
17
18
  "qualitative-thematic",
@@ -24,28 +25,28 @@ const PARADIGMS = new Set([
24
25
  "policy-legal",
25
26
  "mixed-methods",
26
27
  ]);
27
- function parseResearchArgs(args) {
28
+ function parseInitArgs(args) {
28
29
  const trimmed = args.trim();
29
30
  const match = trimmed.match(/^--paradigm(?:=|\s+)(\S+)(?:\s+([\s\S]*))?$/i);
30
31
  if (trimmed.startsWith("--")) {
31
32
  const paradigm = match?.[1];
32
33
  const goal = match?.[2]?.trim();
33
34
  if (!match || !paradigm || !goal) {
34
- throw new Error("Usage: /research --paradigm survey-observational <research goal>");
35
+ throw new Error("Usage: /init [--paradigm survey-observational] <research goal>");
35
36
  }
36
37
  if (!PARADIGMS.has(paradigm))
37
38
  throw new Error(`Unsupported paradigm: ${paradigm}`);
38
39
  return { paradigm, goal };
39
40
  }
40
41
  if (!trimmed)
41
- throw new Error("Usage: /research --paradigm survey-observational <research goal>");
42
+ throw new Error("Usage: /init [--paradigm survey-observational] <research goal>");
42
43
  return { paradigm: "survey-observational", goal: trimmed };
43
44
  }
44
45
  async function notifyError(ctx, error) {
45
46
  ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
46
47
  }
47
48
  const activeAgentRuns = new Set();
48
- const CORE_SKILLS = new Set(["research-intake", "evidence-capture", "citation-audit", "research-brief"]);
49
+ const CORE_SKILLS = new Set(["academic-grill", "research-intake", "evidence-capture", "citation-audit", "research-brief"]);
49
50
  function coreSkillPath(name) {
50
51
  return join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "..", "skills", "core", name, "SKILL.md");
51
52
  }
@@ -94,6 +95,26 @@ async function petPreference() {
94
95
  return false;
95
96
  }
96
97
  }
98
+ async function saveDefaultModel(provider, model) {
99
+ const path = join(getAgentDir(), "settings.json");
100
+ let settings = {};
101
+ try {
102
+ const parsed = JSON.parse(await readFile(path, "utf8"));
103
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed))
104
+ settings = parsed;
105
+ }
106
+ catch { /* start with a new settings file */ }
107
+ await mkdir(dirname(path), { recursive: true });
108
+ await atomicWriteFile(path, `${JSON.stringify({ ...settings, defaultProvider: provider, defaultModel: model }, null, 2)}\n`);
109
+ }
110
+ async function pickProviderItem(ctx, title, items) {
111
+ const result = await ctx.ui.custom((tui, theme, keybindings, done) => new ProviderPickerComponent(title, items, tui, theme, keybindings, done));
112
+ return result.type === "select" ? result.id : undefined;
113
+ }
114
+ async function promptProviderKey(ctx, providerName, envName) {
115
+ const result = await ctx.ui.custom((tui, theme, keybindings, done) => new SecretInputComponent(`配置 ${providerName}`, envName, tui, theme, keybindings, done));
116
+ return result.type === "submit" ? result.value.trim() : undefined;
117
+ }
97
118
  function parseModelRef(args) {
98
119
  const value = args.trim();
99
120
  const slash = value.indexOf("/");
@@ -122,6 +143,57 @@ function researchTaskPlan(runId, objective) {
122
143
  horizon: { strategy: "hierarchical-plan-act-reflect", maxIterations: 12, reflectionEvery: 1 },
123
144
  };
124
145
  }
146
+ function academicGrillRequest(subject, mode) {
147
+ return [
148
+ "先调用 psyclaw_skill 工具加载 PsyClaw 内置的 academic-grill Skill,然后严格遵循该 Skill 对我的学术研究进行追问。",
149
+ subject
150
+ ? `本次要讨论的研究主题或方案:${subject}`
151
+ : "从当前对话、当前项目文件和已有研究状态中确定要讨论的研究主题或方案;能从这些材料查到的信息不要反问我。",
152
+ "从最上游、影响最大的未决问题开始。每一轮只问一个问题,同时给出你的推荐答案或推荐决策及主要取舍。",
153
+ "明确区分探索性与确证性研究,不把规划内容当作已有证据。直到关键分支已经解决或明确交由研究者决定后,再整理研究规格。",
154
+ mode === "init"
155
+ ? "持久化模式:init。追问完成后直接更新 academic-grill Skill 规定的全部项目文档,不再询问是否更新;写入后列出实际更新的文件。"
156
+ : "持久化模式:review。追问完成后先展示拟更新摘要和受影响文件,并询问我是否写回;得到明确确认前不得修改项目文档。",
157
+ ].join("\n");
158
+ }
159
+ async function controlledRunPath(root) {
160
+ return join(projectPaths(root).root, ".psyclaw", "controlled-run.json");
161
+ }
162
+ async function readControlledRun(root) {
163
+ try {
164
+ const value = JSON.parse(await readFile(await controlledRunPath(root), "utf8"));
165
+ return value.schemaVersion === "psyclaw/controlled-run/v1" && value.status === "active" && typeof value.projectId === "string" && typeof value.objective === "string" && typeof value.activatedAt === "string"
166
+ ? value
167
+ : null;
168
+ }
169
+ catch {
170
+ return null;
171
+ }
172
+ }
173
+ async function activateControlledRun(root, projectId, objective) {
174
+ const state = {
175
+ schemaVersion: "psyclaw/controlled-run/v1",
176
+ projectId,
177
+ objective,
178
+ activatedAt: new Date().toISOString(),
179
+ status: "active",
180
+ };
181
+ await atomicWriteFile(await controlledRunPath(root), `${JSON.stringify(state, null, 2)}\n`);
182
+ return state;
183
+ }
184
+ function controlledRunRequest(objective) {
185
+ return [
186
+ "PsyClaw 受控研究流程已由用户通过 /run 明确启动。先读取 .psyclaw/project.json、.psyclaw/controlled-run.json、notes/research-spec.md 和 notes/plan.md。",
187
+ `本次目标:${objective}`,
188
+ "实际推进当前尚未完成的研究任务;先形成或更新符合学术规范的 Markdown 分析报告,不能只描述计划。分析报告必须清楚区分数据来源、统计结果、文献证据、限制与尚未核验内容。",
189
+ "分析报告完成后,下一步不是直接询问是否导出 DOCX,而是询问用户是否据此撰写论文。",
190
+ "若用户选择撰写论文,再单独询问是否先进行文献调研,并说明该阶段可能花费较长时间;提示用户可以指定已启用的 Skill,或者选择 PsyClaw 默认的文献调研方式。不要在获得答复前自动开始长时调研。",
191
+ "文献调研完成并核验后,再询问是否进行全文撰写,并询问使用哪个写作 Skill;未指定时可以建议默认方案,但仍需用户确认。只有充分证据经过核验后,才能把相关主张写入论文正文。",
192
+ "同行评审由用户需要时另行运行 /review;不要在本轮自动审稿。/review 会再次询问使用哪个评审 Skill。最后才询问是否导出 DOCX,以及格式要求。",
193
+ "开放获取 PDF 自动保存到 literature/pdfs/。不得绕过付费墙;没有合法开放全文时给出 https://doi.org/<DOI> 可点击链接,提示用户通过自己的权限下载到系统给出的目标路径。论文中的每篇引用都必须完成 DOI 核验并有本地 PDF,否则列出缺失项并保持正文或发布状态为 blocked。",
194
+ "如果用户在未完成文献调研、全文写作或评审前要求导出 DOCX,允许导出当前分析报告,但必须明确标为遵循学术规范的分析报告,不得称为论文。",
195
+ ].join("\n");
196
+ }
125
197
  async function recommendedItems(kind) {
126
198
  const file = kind === "skills" ? "catalog.json" : "mcp-catalog.json";
127
199
  const moduleDir = dirname(fileURLToPath(import.meta.url));
@@ -136,43 +208,52 @@ async function recommendedItems(kind) {
136
208
  const value = JSON.parse(await readFile(path, "utf8"));
137
209
  return {
138
210
  items: Array.isArray(value.items) ? value.items.filter((item) => Boolean(item && typeof item === "object")) : [],
211
+ externalTools: Array.isArray(value.externalTools) ? value.externalTools.filter((item) => Boolean(item && typeof item === "object")) : [],
139
212
  installPrep: Array.isArray(value.installPrep) ? value.installPrep.filter((item) => Boolean(item && typeof item === "object")) : [],
140
213
  };
141
214
  }
142
215
  catch { /* try next candidate */ }
143
216
  }
144
- return { items: [], installPrep: [] };
217
+ return { items: [], externalTools: [], installPrep: [] };
218
+ }
219
+ function skillScopeLabel(scope) {
220
+ return scope === "user" ? "系统目录(所有项目)" : "项目目录(仅当前项目)";
145
221
  }
146
222
  async function skillManagerRows(root, state) {
147
223
  const catalog = await readRecommendedCatalog();
148
224
  return Promise.all(catalog.items.filter((item) => item.kind === "skill").map(async (item) => {
149
225
  const id = normalizeRecommendedSkillId(item.id);
226
+ const scope = state.skillScopes?.[id] ?? "project";
150
227
  try {
151
- await validateInstalledRecommendedSkill(root, id);
228
+ await validateModelInstalledRecommendedSkill(root, id, scope);
152
229
  return {
153
230
  id,
154
231
  name: item.name,
155
232
  description: String(item.description ?? ""),
156
233
  ...(item.sourceRef === undefined ? {} : { sourceRef: item.sourceRef }),
234
+ ...(typeof item.installHint === "string" ? { installHint: item.installHint } : {}),
235
+ collection: item.skillLayout === "collection",
157
236
  installed: true,
158
237
  enabled: state.skills.includes(id),
238
+ scope,
159
239
  blocked: false,
160
240
  };
161
241
  }
162
- catch (error) {
163
- const plan = catalog.installPrep.find((candidate) => normalizeRecommendedSkillId(candidate.id) === id);
164
- const installable = plan?.sourceKind === "github" && typeof plan.sourceUrl === "string" &&
165
- typeof plan.skillPath === "string" && typeof plan.skillName === "string" &&
166
- typeof plan.ref === "string" && !["unknown", "NOASSERTION"].includes(plan.license);
242
+ catch {
167
243
  return {
168
244
  id,
169
245
  name: item.name,
170
246
  description: String(item.description ?? ""),
171
247
  ...(item.sourceRef === undefined ? {} : { sourceRef: item.sourceRef }),
248
+ ...(typeof item.installHint === "string" ? { installHint: item.installHint } : {}),
249
+ collection: item.skillLayout === "collection",
172
250
  installed: false,
173
251
  enabled: false,
174
- blocked: !installable,
175
- reason: installable ? "尚未安装" : (plan?.blockedReason ?? (error instanceof Error ? error.message : String(error))),
252
+ scope,
253
+ blocked: typeof item.sourceRef !== "string" || !/^https:\/\//.test(item.sourceRef),
254
+ reason: typeof item.sourceRef === "string" && /^https:\/\//.test(item.sourceRef)
255
+ ? "尚未安装;按 Enter 选择安装范围并交给当前模型处理"
256
+ : "没有可交给模型检查的来源网址",
176
257
  };
177
258
  }
178
259
  }));
@@ -182,7 +263,6 @@ async function mcpManagerRows(state) {
182
263
  return items.map((item) => {
183
264
  const id = String(item.id ?? "");
184
265
  const plan = installPrep.find((candidate) => candidate.id === id);
185
- const blocked = plan?.status === "blocked";
186
266
  const dependencies = Array.isArray(plan?.dependencies)
187
267
  ? plan.dependencies.filter((value) => typeof value === "string")
188
268
  : [];
@@ -192,12 +272,10 @@ async function mcpManagerRows(state) {
192
272
  description: String(item.description ?? ""),
193
273
  ...(typeof item.sourceRef === "string" ? { sourceRef: item.sourceRef } : {}),
194
274
  enabled: state.mcp.includes(id),
195
- blocked,
196
- ...(typeof plan?.blockedReason === "string" ? { reason: plan.blockedReason } : {}),
197
275
  details: [
198
276
  `传输:${String(item.transport ?? "未声明")} · 风险:${String(item.risk ?? "未声明")}`,
199
- `版本:${String(plan?.ref ?? "未固定")} · 许可证:${String(plan?.license ?? "未核验")}`,
200
- `依赖:${dependencies.length > 0 ? dependencies.join(", ") : "无已声明依赖"}`,
277
+ `参考版本:${String(plan?.ref ?? "由模型读取来源确定")} · 许可证:${String(plan?.license ?? "由模型读取来源确定")}`,
278
+ `可能需要:${dependencies.length > 0 ? dependencies.join(", ") : "由模型读取来源确定"}`,
201
279
  ],
202
280
  };
203
281
  });
@@ -206,17 +284,19 @@ function skillRowLabel(row) {
206
284
  const state = row.blocked ? "× 阻断" : row.enabled ? "● 已启用" : row.installed ? "○ 未启用" : "↓ 未安装";
207
285
  return `${state} ${row.name} [${row.id}]`;
208
286
  }
209
- async function setRecommendedSkillEnabled(root, requestedId, enabled) {
287
+ async function setRecommendedSkillEnabled(root, requestedId, enabled, requestedScope) {
210
288
  const id = normalizeRecommendedSkillId(requestedId);
211
- if (enabled)
212
- await validateInstalledRecommendedSkill(root, id);
213
289
  const state = await readRecommendationState(root);
290
+ const scope = requestedScope ?? state.skillScopes?.[id] ?? "project";
291
+ if (enabled)
292
+ await validateModelInstalledRecommendedSkill(root, id, scope);
214
293
  const current = new Set(state.skills);
215
294
  if (enabled)
216
295
  current.add(id);
217
296
  else
218
297
  current.delete(id);
219
298
  state.skills = [...current];
299
+ state.skillScopes = { ...(state.skillScopes ?? {}), [id]: scope };
220
300
  await saveRecommendationState(root, state);
221
301
  }
222
302
  function skillManagerItems(rows) {
@@ -234,6 +314,7 @@ function skillManagerItems(rows) {
234
314
  description: row.description,
235
315
  status: row.blocked ? "blocked" : row.enabled ? "enabled" : row.installed ? "disabled" : "missing",
236
316
  ...(row.sourceRef === undefined ? {} : { sourceRef: row.sourceRef }),
317
+ details: [`安装位置:${skillScopeLabel(row.scope)}`],
237
318
  ...(row.reason === undefined ? {} : { reason: row.reason }),
238
319
  })),
239
320
  ];
@@ -246,10 +327,9 @@ function mcpManagerItems(rows) {
246
327
  id: row.id,
247
328
  name: row.name,
248
329
  description: row.description,
249
- status: row.blocked ? "blocked" : row.enabled ? "enabled" : "disabled",
330
+ status: row.enabled ? "enabled" : "disabled",
250
331
  configuredEnabled: row.enabled,
251
332
  ...(row.sourceRef === undefined ? {} : { sourceRef: row.sourceRef }),
252
- ...(row.reason === undefined ? {} : { reason: row.reason }),
253
333
  details: row.details,
254
334
  }));
255
335
  }
@@ -257,11 +337,10 @@ async function openMcpManager(ctx, rows) {
257
337
  return ctx.ui.custom((tui, theme, keybindings, done) => (new SkillManagerComponent(mcpManagerItems(rows), tui, theme, keybindings, done, {
258
338
  title: "MCP 管理",
259
339
  itemLabel: "MCP",
260
- footer: "↑/↓ 移动 · Space 开启/关闭项目配置 · Enter 查看预检 · Esc 关闭",
340
+ footer: "↑/↓ 移动 · Space 开启/关闭项目配置 · Enter 交给模型安装并配置 · Esc 关闭",
261
341
  enterAction: "install",
262
- enabledText: "项目配置已开启;运行时仍需通过安装、信任和工具策略检查",
342
+ enabledText: "项目配置已开启;Enter 可让当前模型重新安装或修复配置",
263
343
  disabledText: "项目配置已关闭",
264
- blockedText: "预检阻断;若配置曾开启,可按 Space 关闭",
265
344
  })));
266
345
  }
267
346
  async function setRecommendedMcpEnabled(root, id, enabled) {
@@ -270,8 +349,6 @@ async function setRecommendedMcpEnabled(root, id, enabled) {
270
349
  const row = rows.find((candidate) => candidate.id === id);
271
350
  if (!row)
272
351
  throw new Error(`未找到推荐 MCP: ${id}`);
273
- if (enabled && row.blocked)
274
- throw new Error(row.reason ?? `MCP 预检未通过: ${id}`);
275
352
  const current = new Set(state.mcp);
276
353
  if (enabled)
277
354
  current.add(id);
@@ -280,23 +357,29 @@ async function setRecommendedMcpEnabled(root, id, enabled) {
280
357
  state.mcp = [...current];
281
358
  await saveRecommendationState(root, state);
282
359
  }
283
- function mcpInstallPlan(id, items, installPrep) {
284
- const item = items.find((candidate) => candidate.id === id);
285
- if (!item)
286
- throw new Error(`未找到推荐 MCP: ${id}`);
287
- const plan = installPrep.find((candidate) => candidate.id === id);
288
- return {
289
- schemaVersion: "psyclaw/recommendation-install/v1",
290
- kind: "mcp",
291
- id,
292
- name: item.name,
293
- sourceRef: item.sourceRef,
294
- plan: plan ?? null,
295
- status: plan?.status ?? "review-required",
296
- next: "这里只展示固定来源、版本、许可证和依赖预检;完成受信任安装与注册后,MCP 才能在运行时调用。",
297
- };
360
+ function modelMcpInstallTask(root, row, plan) {
361
+ const suggestedCommand = typeof plan?.command === "string" ? plan.command : "请根据来源仓库的最新安装说明确定";
362
+ const suggestedTarget = typeof plan?.target === "string" ? plan.target : `.psyclaw/mcp/${row.id}.json`;
363
+ return [
364
+ `安装并配置推荐 MCP:${row.name} (${row.id})。`,
365
+ `来源网址:${row.sourceRef ?? "未提供;请停止并告知用户"}`,
366
+ `当前项目:${root}`,
367
+ `目录中的参考命令:${suggestedCommand}`,
368
+ `建议的项目配置记录:${suggestedTarget}`,
369
+ "用户已经通过 MCP 管理页明确授权本次安装。你可以直接联网、克隆或下载来源、使用所需包管理器安装依赖、执行安装命令,并写入与该 MCP 有关的项目或用户级运行配置;不要再次要求用户批准这些安装步骤。",
370
+ "请先阅读来源仓库的实际安装与启动说明,再根据当前操作系统和宿主环境完成可运行的 stdio/HTTP MCP 配置。目录中的版本、命令和依赖只作参考,不得因为哈希、许可证、依赖或本地数据策略检查而中止安装。",
371
+ "安装完成后实际启动一次服务器或执行等价的最小连通性检查,确认启动命令和工作目录有效。不要输出、复制或提交 API Key、令牌及其他凭据。",
372
+ `成功后确保 .psyclaw/recommendations.json 的 mcp 列表包含 ${JSON.stringify(row.id)},说明写入了哪些配置,并提醒用户执行 /reload。若安装命令本身失败,直接诊断并修复;只有遇到必须由用户提供的凭据或外部软件许可证时才向用户说明。`,
373
+ ].join("\n");
298
374
  }
299
- async function showMcpManager(args, ctx) {
375
+ async function queueModelMcpInstall(pi, ctx, row, plan) {
376
+ if (!row.sourceRef)
377
+ throw new Error(`推荐 MCP 没有来源网址: ${row.id}`);
378
+ await setRecommendedMcpEnabled(ctx.cwd, row.id, true);
379
+ pi.sendUserMessage(modelMcpInstallTask(ctx.cwd, row, plan), ctx.isIdle() ? {} : { deliverAs: "followUp" });
380
+ ctx.ui.notify(`已将 ${row.name} 的下载、安装和配置任务交给当前模型。模型完成并确认可启动后,请执行 /reload。`, "info");
381
+ }
382
+ async function showMcpManager(pi, args, ctx) {
300
383
  const [verb, id] = args.trim().split(/\s+/).filter(Boolean);
301
384
  if (verb && !["status", "enable", "disable", "install"].includes(verb)) {
302
385
  throw new Error("Usage: /mcp [status|enable <id>|disable <id>|install <id>]");
@@ -306,7 +389,11 @@ async function showMcpManager(args, ctx) {
306
389
  }
307
390
  const catalog = await recommendedItems("mcp");
308
391
  if (verb === "install") {
309
- ctx.ui.notify(JSON.stringify(mcpInstallPlan(id, catalog.items, catalog.installPrep), null, 2), "info");
392
+ const rows = await mcpManagerRows(await readRecommendationState(ctx.cwd));
393
+ const row = rows.find((candidate) => candidate.id === id);
394
+ if (!row)
395
+ throw new Error(`未找到推荐 MCP: ${id}`);
396
+ await queueModelMcpInstall(pi, ctx, row, catalog.installPrep.find((candidate) => candidate.id === row.id));
310
397
  return;
311
398
  }
312
399
  if (verb === "enable" || verb === "disable") {
@@ -316,7 +403,7 @@ async function showMcpManager(args, ctx) {
316
403
  }
317
404
  if (!ctx.hasUI || typeof ctx.ui.custom !== "function" || verb === "status") {
318
405
  const rows = await mcpManagerRows(await readRecommendationState(ctx.cwd));
319
- ctx.ui.notify(rows.map((row) => `${row.blocked ? "[!]" : row.enabled ? "[on]" : "[off]"} ${row.id} — ${row.name}${row.reason ? ` — ${row.reason}` : ""}`).join("\n"), "info");
406
+ ctx.ui.notify(rows.map((row) => `${row.enabled ? "[on]" : "[off]"} ${row.id} — ${row.name}`).join("\n"), "info");
320
407
  return;
321
408
  }
322
409
  while (true) {
@@ -328,8 +415,8 @@ async function showMcpManager(args, ctx) {
328
415
  if (!row)
329
416
  continue;
330
417
  if (action.type === "install") {
331
- ctx.ui.notify(JSON.stringify(mcpInstallPlan(row.id, catalog.items, catalog.installPrep), null, 2), "info");
332
- continue;
418
+ await queueModelMcpInstall(pi, ctx, row, catalog.installPrep.find((candidate) => candidate.id === row.id));
419
+ return;
333
420
  }
334
421
  if (action.type === "toggle") {
335
422
  await setRecommendedMcpEnabled(ctx.cwd, row.id, action.enabled);
@@ -337,7 +424,48 @@ async function showMcpManager(args, ctx) {
337
424
  }
338
425
  }
339
426
  }
340
- async function showSkillManager(args, ctx) {
427
+ function modelSkillInstallTask(root, row, scope) {
428
+ const target = recommendedSkillTarget(root, row.id, scope);
429
+ return [
430
+ `安装推荐 Skill:${row.name} (${row.id})。`,
431
+ `来源网址:${row.sourceRef}`,
432
+ ...(row.installHint ? [`仓库入口提示:${row.installHint}`] : []),
433
+ `安装位置:${skillScopeLabel(scope)}。`,
434
+ `唯一允许的最终目标目录:${target}`,
435
+ "用户已通过 Skill 管理页授权本次安装。请使用当前会话的联网、文件和命令工具读取来源仓库,并直接下载、安装所需依赖和完成 Skill 安装;不要再次要求安装权限。不要写入其他 Skill 目录,不要修改 data/raw、.git 或研究产物。",
436
+ row.collection
437
+ ? "这是多 Skill 套件:目标目录自身无需 SKILL.md,但其子目录必须包含一个或多个有效 SKILL.md。保留套件内共享目录和相对路径,不得包含 .git、符号链接或凭据。"
438
+ : "目标目录最终必须直接包含有效 SKILL.md(YAML frontmatter 至少包含 name 和 description),不得包含 .git、符号链接、凭据或二进制大文件。",
439
+ "如果仓库包含多个 Skill,只安装与此推荐项相符的部分;如果它不是 Skill 或无法合理适配,停止并说明原因,不要伪造 SKILL.md。",
440
+ "安装完成后检查目标目录结构,并提醒用户执行 /skills 启用该项,再执行 /reload。",
441
+ ].join("\n");
442
+ }
443
+ async function chooseSkillScope(ctx) {
444
+ const projectLabel = skillScopeLabel("project");
445
+ const systemLabel = skillScopeLabel("user");
446
+ const selected = await ctx.ui.select("选择 Skill 安装位置", [projectLabel, systemLabel]);
447
+ if (selected === projectLabel)
448
+ return "project";
449
+ if (selected === systemLabel)
450
+ return "user";
451
+ return undefined;
452
+ }
453
+ async function queueModelSkillInstall(pi, ctx, row) {
454
+ if (!row.sourceRef)
455
+ throw new Error(`推荐 Skill 没有来源网址: ${row.id}`);
456
+ const scope = await chooseSkillScope(ctx);
457
+ if (!scope)
458
+ return;
459
+ const approved = await ctx.ui.confirm("交给当前模型安装 Skill?", `${row.name}\n来源:${row.sourceRef}\n安装位置:${skillScopeLabel(scope)}\n目标目录:${recommendedSkillTarget(ctx.cwd, row.id, scope)}\n模型将检查仓库并使用文件与命令工具完成安装。`);
460
+ if (!approved)
461
+ return;
462
+ const state = await readRecommendationState(ctx.cwd);
463
+ state.skillScopes = { ...(state.skillScopes ?? {}), [row.id]: scope };
464
+ await saveRecommendationState(ctx.cwd, state);
465
+ pi.sendUserMessage(modelSkillInstallTask(ctx.cwd, row, scope), ctx.isIdle() ? {} : { deliverAs: "followUp" });
466
+ ctx.ui.notify(`已将 ${row.name} 的安装任务交给当前模型,目标为${skillScopeLabel(scope)}。安装完成后请在 /skills 中启用,再执行 /reload。`, "info");
467
+ }
468
+ async function showSkillManager(pi, args, ctx) {
341
469
  const action = args.trim().split(/\s+/).filter(Boolean);
342
470
  const verb = action[0];
343
471
  const id = action[1];
@@ -348,13 +476,15 @@ async function showSkillManager(args, ctx) {
348
476
  throw new Error(`Usage: /skills ${verb} <id>`);
349
477
  }
350
478
  if (verb === "install") {
351
- const result = await installRecommendedSkill(ctx.cwd, id);
352
- ctx.ui.notify(`${result.installed ? "已安装" : "已经安装"} ${result.skillName}\n${result.path}\n安装不会自动启用,请返回 /skills 开启。`, "info");
479
+ const row = (await skillManagerRows(ctx.cwd, await readRecommendationState(ctx.cwd))).find((candidate) => candidate.id === normalizeRecommendedSkillId(id));
480
+ if (!row)
481
+ throw new Error(`未找到推荐 Skill: ${id}`);
482
+ await queueModelSkillInstall(pi, ctx, row);
353
483
  return;
354
484
  }
355
485
  if (verb === "enable" || verb === "disable") {
356
486
  await setRecommendedSkillEnabled(ctx.cwd, id, verb === "enable");
357
- ctx.ui.notify(`${verb === "enable" ? "已启用" : "已停用"} ${normalizeRecommendedSkillId(id)},下次启动 PsyClaw 生效`, "info");
487
+ ctx.ui.notify(`${verb === "enable" ? "已启用" : "已停用"} ${normalizeRecommendedSkillId(id)}。请运行 /reload 重新加载。`, "info");
358
488
  return;
359
489
  }
360
490
  if (!ctx.hasUI || typeof ctx.ui.custom !== "function" || verb === "status") {
@@ -377,21 +507,12 @@ async function showSkillManager(args, ctx) {
377
507
  if (!row)
378
508
  continue;
379
509
  if (action.type === "install") {
380
- const approved = await ctx.ui.confirm("安装推荐 Skill?", `${row.name}\n固定来源:${row.sourceRef ?? row.id}\n安装到当前项目,安装完成后仍由你决定是否启用。`);
381
- if (!approved)
382
- continue;
383
- try {
384
- const result = await installRecommendedSkill(ctx.cwd, row.id);
385
- ctx.ui.notify(`${result.installed ? "安装完成" : "已经安装"}:${result.skillName}`, "info");
386
- }
387
- catch (error) {
388
- await notifyError(ctx, error);
389
- }
390
- continue;
510
+ await queueModelSkillInstall(pi, ctx, row);
511
+ return;
391
512
  }
392
513
  if (action.type === "toggle") {
393
514
  await setRecommendedSkillEnabled(ctx.cwd, row.id, action.enabled);
394
- ctx.ui.notify(`已${action.enabled ? "启用" : "停用"} ${row.name},重启 PsyClaw 后生效`, "info");
515
+ ctx.ui.notify(`已${action.enabled ? "启用" : "停用"} ${row.name}。请运行 /reload 重新加载。`, "info");
395
516
  }
396
517
  }
397
518
  }
@@ -493,19 +614,49 @@ const WORKFLOW_RUNNERS = {
493
614
  export default function psyclawExtension(pi) {
494
615
  const developerCommands = process.env.PSYCLAW_DEVELOPER_COMMANDS === "1";
495
616
  const legacyTestApi = typeof pi.registerTool !== "function";
496
- pi.registerCommand("research", {
497
- description: "创建可追溯的研究项目",
617
+ if (!legacyTestApi && typeof pi.on === "function")
618
+ pi.on("resources_discover", async (event, ctx) => {
619
+ const enabled = await enabledRecommendedSkillPaths(event.cwd);
620
+ for (const warning of enabled.warnings)
621
+ ctx.ui.notify(`PsyClaw Skill: ${warning}`, "warning");
622
+ return { skillPaths: enabled.paths };
623
+ });
624
+ pi.registerCommand("init", {
625
+ description: "初始化可追溯的研究项目",
498
626
  handler: async (args, ctx) => {
499
627
  try {
500
- const parsed = parseResearchArgs(args);
628
+ const parsed = parseInitArgs(args);
501
629
  const project = await bootstrapProject({ root: ctx.cwd, ...parsed });
502
- ctx.ui.notify(`Initialized ${project.id} (${project.paradigm})`, "info");
630
+ if (typeof pi.sendUserMessage === "function") {
631
+ pi.sendUserMessage(academicGrillRequest(project.goal, "init"), ctx.isIdle() ? {} : { deliverAs: "followUp" });
632
+ ctx.ui.notify(`研究项目已初始化:${project.id}(${project.paradigm})。现在开始学术追问;准备好后使用 /run 启动受控研究流程。`, "info");
633
+ }
634
+ else {
635
+ ctx.ui.notify(`研究项目已初始化:${project.id}(${project.paradigm})。使用 /run 启动受控研究流程。`, "info");
636
+ }
503
637
  }
504
638
  catch (error) {
505
639
  await notifyError(ctx, error);
506
640
  }
507
641
  },
508
642
  });
643
+ if (!legacyTestApi)
644
+ pi.registerCommand("run", {
645
+ description: "在 /init 后启动受控研究流程",
646
+ handler: async (args, ctx) => {
647
+ 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");
654
+ }
655
+ catch {
656
+ ctx.ui.notify("请先使用 /init 初始化研究项目;未运行 /init 和 /run 时保持普通对话模式。", "warning");
657
+ }
658
+ },
659
+ });
509
660
  if (developerCommands || legacyTestApi)
510
661
  pi.registerCommand("verify", {
511
662
  description: "写入机器可读的交接检查点",
@@ -543,12 +694,105 @@ export default function psyclawExtension(pi) {
543
694
  }
544
695
  },
545
696
  });
697
+ if (!legacyTestApi)
698
+ pi.registerCommand("grill", {
699
+ description: "逐题追问并压力测试学术研究方案",
700
+ handler: async (args, ctx) => {
701
+ const subject = args.trim();
702
+ pi.sendUserMessage(academicGrillRequest(subject, "review"), ctx.isIdle() ? {} : { deliverAs: "followUp" });
703
+ ctx.ui.notify("已启动学术追问。完成后会先展示文档更新建议,并询问是否写回。", "info");
704
+ },
705
+ });
706
+ if (!legacyTestApi)
707
+ pi.registerCommand("review", {
708
+ description: "并发运行多角色模拟同行评审",
709
+ handler: async (_args, ctx) => {
710
+ if (activeAgentRuns.has(ctx.cwd)) {
711
+ ctx.ui.notify("当前项目已有一个多智能体任务正在运行", "warning");
712
+ return;
713
+ }
714
+ try {
715
+ const project = await readProject(ctx.cwd);
716
+ if (!(await readControlledRun(ctx.cwd))) {
717
+ ctx.ui.notify("请先使用 /run 启动受控研究流程;普通对话模式不会自动执行同行评审。", "warning");
718
+ return;
719
+ }
720
+ const reviewSkill = ctx.hasUI
721
+ ? await ctx.ui.input("同行评审使用哪个 Skill?", "留空使用 PsyClaw 默认多角色模拟评审")
722
+ : undefined;
723
+ if (reviewSkill === undefined && ctx.hasUI) {
724
+ ctx.ui.notify("已取消同行评审", "info");
725
+ return;
726
+ }
727
+ activeAgentRuns.add(ctx.cwd);
728
+ ctx.ui.notify(`正在使用${reviewSkill?.trim() ? ` Skill“${reviewSkill.trim()}”辅助的` : "默认"} 4 角色并发模拟评审;不会自动改稿。`, "info");
729
+ const result = await runParallelPeerReview({
730
+ root: ctx.cwd,
731
+ project,
732
+ ...(ctx.model?.provider === undefined ? {} : { provider: ctx.model.provider }),
733
+ ...(ctx.model?.id === undefined ? {} : { model: ctx.model.id }),
734
+ env: providerEnvironment(ctx.model?.provider),
735
+ ...(reviewSkill?.trim() ? { skillGuidance: reviewSkill.trim() } : {}),
736
+ onEvent: async (event) => { await new RunEventLog(ctx.cwd, event.runId).append(event); },
737
+ });
738
+ pi.appendEntry("psyclaw:multi-agent-review", {
739
+ runId: result.runId,
740
+ status: result.status,
741
+ outputPath: result.outputPath,
742
+ adoption: "pending-human-decision",
743
+ recordedAt: new Date().toISOString(),
744
+ });
745
+ ctx.ui.notify(`模拟审稿${result.status === "completed" ? "已完成" : "未完整完成"}:${result.outputPath}。审稿意见仅供研究者决定是否采纳。`, result.status === "completed" ? "info" : "warning");
746
+ }
747
+ catch (error) {
748
+ await notifyError(ctx, error);
749
+ }
750
+ finally {
751
+ activeAgentRuns.delete(ctx.cwd);
752
+ }
753
+ },
754
+ });
755
+ if (!legacyTestApi)
756
+ pi.registerCommand("loop", {
757
+ description: "按计划循环推进当前研究阶段",
758
+ handler: async (args, ctx) => {
759
+ const requested = args.trim();
760
+ if (requested.toLowerCase() === "stop") {
761
+ if (ctx.isIdle())
762
+ ctx.ui.notify("当前没有正在运行的研究循环", "info");
763
+ else {
764
+ ctx.abort();
765
+ ctx.ui.notify("已请求停止当前研究循环", "warning");
766
+ }
767
+ return;
768
+ }
769
+ let project;
770
+ try {
771
+ project = await readProject(ctx.cwd);
772
+ }
773
+ catch {
774
+ ctx.ui.notify("请先使用 /init 初始化研究项目,再运行 /loop", "warning");
775
+ return;
776
+ }
777
+ const objective = requested || project.goal;
778
+ const request = [
779
+ "运行一个有边界的 PsyClaw 研究循环,采用计划、执行、检查、修正的顺序推进当前研究阶段。",
780
+ `本轮目标:${objective}`,
781
+ "开始前读取 .psyclaw/project.json、notes/goal.md 和 notes/plan.md;如果当前对话或项目中存在经研究者确认的 /grill 研究规格,以该规格细化初始计划,但不得违反系统规则、AGENTS.md 或研究门禁。",
782
+ "先识别当前最高优先级且依赖已满足的一个任务,再使用可用工具实际推进。不要只描述准备做什么。普通项目文件写入限于该任务所需范围;原始数据、凭据、外部发布、破坏性操作和方法学关键变更仍需明确批准。",
783
+ "每完成一个实质步骤就检查产物、证据和计划是否一致。遇到缺失证据、研究者决策、权限边界、不可恢复错误或当前阶段已经完成时立即停止,不得为了继续循环而虚构输入或结论。",
784
+ "本次最多推进一个可验收的研究阶段。结束时简要报告已完成、已核验、阻塞、计划变化和下一步;规划内容不得表述为已有研究证据。",
785
+ ].join("\n");
786
+ pi.sendUserMessage(request, ctx.isIdle() ? {} : { deliverAs: "followUp" });
787
+ ctx.ui.notify("已启动有界研究循环;使用 /loop stop 可请求中止", "info");
788
+ },
789
+ });
546
790
  if (!legacyTestApi)
547
791
  pi.registerCommand("skills", {
548
792
  description: "打开 Skill 安装与启用管理页",
549
793
  handler: async (args, ctx) => {
550
794
  try {
551
- await showSkillManager(args, ctx);
795
+ await showSkillManager(pi, args, ctx);
552
796
  }
553
797
  catch (error) {
554
798
  await notifyError(ctx, error);
@@ -557,10 +801,10 @@ export default function psyclawExtension(pi) {
557
801
  });
558
802
  if (!legacyTestApi)
559
803
  pi.registerCommand("mcp", {
560
- description: "打开 MCP 配置与预检管理页",
804
+ description: "打开 MCP 安装与配置管理页",
561
805
  handler: async (args, ctx) => {
562
806
  try {
563
- await showMcpManager(args, ctx);
807
+ await showMcpManager(pi, args, ctx);
564
808
  }
565
809
  catch (error) {
566
810
  await notifyError(ctx, error);
@@ -569,7 +813,7 @@ export default function psyclawExtension(pi) {
569
813
  });
570
814
  if (!legacyTestApi)
571
815
  pi.registerCommand("install", {
572
- description: "查看推荐 Skill/MCP,或生成安装预检计划",
816
+ description: "查看推荐 Skill/MCP,或交给当前模型安装",
573
817
  handler: async (args, ctx) => {
574
818
  try {
575
819
  const [kind, id] = args.trim().split(/\s+/, 2);
@@ -577,22 +821,23 @@ export default function psyclawExtension(pi) {
577
821
  const [skills, mcps] = await Promise.all([recommendedItems("skills"), recommendedItems("mcp")]);
578
822
  const skillLines = skills.items.slice(0, 8).map((item) => `Skill: ${String(item.id)} — ${String(item.name)}`);
579
823
  const mcpLines = mcps.items.slice(0, 8).map((item) => `MCP: ${String(item.id)} — ${String(item.name)}`);
580
- ctx.ui.notify(["推荐安装入口", "", ...skillLines, ...mcpLines, "", "安装:/install skill|mcp <id>", "管理:/skills 或 /mcp", "也可以打开 /panel 查看推荐页面"].join("\n"), "info");
824
+ const toolLines = skills.externalTools.slice(0, 8).map((item) => `外部工具: ${String(item.name)} — ${String(item.sourceRef ?? "请查看项目文档")}`);
825
+ ctx.ui.notify(["推荐安装入口", "", ...skillLines, ...mcpLines, "", ...toolLines, "", "安装:/install skill|mcp <id>", "管理:/skills 或 /mcp", "外部工具不通过 Skill 安装器安装", "也可以打开 /panel 查看推荐页面"].join("\n"), "info");
581
826
  return;
582
827
  }
583
828
  if (kind !== "skill" && kind !== "mcp")
584
829
  throw new Error("Usage: /install skill|mcp <id>");
585
830
  if (!id) {
586
831
  if (kind === "skill")
587
- await showSkillManager("", ctx);
832
+ await showSkillManager(pi, "", ctx);
588
833
  else
589
- await showMcpManager("", ctx);
834
+ await showMcpManager(pi, "", ctx);
590
835
  return;
591
836
  }
592
837
  if (kind === "skill")
593
- await showSkillManager(`install ${id}`, ctx);
838
+ await showSkillManager(pi, `install ${id}`, ctx);
594
839
  else
595
- await showMcpManager(`install ${id}`, ctx);
840
+ await showMcpManager(pi, `install ${id}`, ctx);
596
841
  }
597
842
  catch (error) {
598
843
  await notifyError(ctx, error);
@@ -604,7 +849,7 @@ export default function psyclawExtension(pi) {
604
849
  description: "查看或切换模型 Provider",
605
850
  handler: async (args, ctx) => {
606
851
  try {
607
- const requested = args.trim();
852
+ let requested = args.trim();
608
853
  const providers = new Map();
609
854
  for (const model of ctx.modelRegistry.getAll()) {
610
855
  const list = providers.get(model.provider) ?? [];
@@ -613,33 +858,69 @@ export default function psyclawExtension(pi) {
613
858
  }
614
859
  if (!requested) {
615
860
  const current = ctx.model?.provider ?? "none";
616
- const lines = [...providers].map(([id, models]) => `${id}${id === current ? " *" : ""} (${models.length} models)`);
617
- ctx.ui.notify([`当前 Provider: ${current}`, ...lines, "", "切换:/provider <id>", "模型:/model <provider/model>", "添加或更新 Key:退出后运行 psyclaw wizard"].join("\n"), "info");
618
- return;
861
+ const available = new Map(PROVIDER_PRESETS.filter((preset) => preset.models.length > 0).map((preset) => [preset.id, preset]));
862
+ for (const id of providers.keys())
863
+ if (!available.has(id))
864
+ available.set(id, {
865
+ id,
866
+ name: ctx.modelRegistry.getProviderDisplayName(id),
867
+ baseUrl: "",
868
+ api: "openai-completions",
869
+ apiKeyEnv: `${id.toUpperCase().replace(/[^A-Z0-9]/g, "_")}_API_KEY`,
870
+ models: [],
871
+ });
872
+ if (!ctx.hasUI || typeof ctx.ui.custom !== "function") {
873
+ const lines = [...available].map(([id, preset]) => `${id}${id === current ? " *" : ""} — ${preset.name}`);
874
+ ctx.ui.notify([`当前 Provider: ${current}`, ...lines, "", "切换:/provider <id>"].join("\n"), "info");
875
+ return;
876
+ }
877
+ const selectedProvider = await pickProviderItem(ctx, "选择模型 Provider", [...available].map(([id, preset]) => ({
878
+ id,
879
+ label: preset.name,
880
+ description: `${id} · ${providers.get(id)?.length ?? preset.models.length} 个模型`,
881
+ current: id === current,
882
+ })));
883
+ if (!selectedProvider)
884
+ return;
885
+ requested = selectedProvider;
619
886
  }
620
887
  if (!/^[A-Za-z0-9._:-]+$/.test(requested))
621
888
  throw new Error("Usage: /provider <provider-id>");
622
- const models = providers.get(requested);
623
- if (!models?.[0]) {
624
- const preset = PROVIDER_PRESETS.find((item) => item.id === requested);
625
- throw new Error(preset ? `Provider ${requested} 尚未加载;请运行 psyclaw wizard 完成配置` : `Unknown provider: ${requested}`);
626
- }
627
- let selectedId = models[0]?.id;
628
- if (models.length > 1) {
629
- if (!ctx.hasUI)
889
+ const preset = PROVIDER_PRESETS.find((item) => item.id === requested);
890
+ let models = providers.get(requested) ?? [];
891
+ const modelChoices = models.length > 0 ? models : (preset?.models ?? []);
892
+ if (modelChoices.length === 0)
893
+ throw new Error(`Unknown provider or no configured models: ${requested}`);
894
+ let selectedId = modelChoices[0].id;
895
+ if (modelChoices.length > 1) {
896
+ if (!ctx.hasUI || typeof ctx.ui.custom !== "function")
630
897
  throw new Error(`Provider ${requested} has multiple models; use /model ${requested}/<model-id>`);
631
- const choice = await ctx.ui.select(`选择 ${requested} 模型`, models.map((model) => model.id));
898
+ const choice = await pickProviderItem(ctx, `选择 ${preset?.name ?? requested} 模型`, modelChoices.map((model) => ({
899
+ id: model.id,
900
+ label: "name" in model && typeof model.name === "string" ? model.name : model.id,
901
+ description: model.id,
902
+ current: ctx.model?.provider === requested && ctx.model.id === model.id,
903
+ })));
632
904
  if (!choice)
633
905
  return;
634
906
  selectedId = choice;
635
907
  }
908
+ if (preset && ctx.hasUI && typeof ctx.ui.custom === "function") {
909
+ const key = await promptProviderKey(ctx, preset.name, preset.apiKeyEnv);
910
+ if (key === undefined)
911
+ return;
912
+ await saveProviderConfig({ ...preset, ...(key ? { apiKey: key } : {}) });
913
+ await ctx.modelRegistry.refresh();
914
+ models = ctx.modelRegistry.getAll().filter((model) => model.provider === requested);
915
+ }
636
916
  const selected = models.find((model) => model.id === selectedId);
637
917
  if (!selected)
638
918
  throw new Error(`No model selected for provider: ${requested}`);
639
919
  const changed = await pi.setModel(selected);
640
920
  if (!changed)
641
- throw new Error(`Provider ${requested} 的凭据尚未配置;请运行 psyclaw wizard`);
642
- ctx.ui.notify(`Provider selected: ${requested} (${selected.id})`, "info");
921
+ throw new Error(`未找到 ${requested} 的可用凭据;请重新运行 /provider 并输入 API Key`);
922
+ await saveDefaultModel(requested, selected.id);
923
+ ctx.ui.notify(`已切换并设为默认模型:${requested}/${selected.id}`, "info");
643
924
  }
644
925
  catch (error) {
645
926
  await notifyError(ctx, error);
@@ -690,7 +971,7 @@ export default function psyclawExtension(pi) {
690
971
  label: "Research skill",
691
972
  description: "Load one trusted bundled psyclaw core skill and make its use visible to the user. Use this instead of directly reading a core SKILL.md file.",
692
973
  parameters: Type.Object({
693
- name: Type.String({ description: "Core skill name: research-intake, evidence-capture, citation-audit, or research-brief" }),
974
+ name: Type.String({ description: "Core skill name: academic-grill, research-intake, evidence-capture, citation-audit, or research-brief" }),
694
975
  purpose: Type.Optional(Type.String({ description: "Short user-facing reason for using this skill now" })),
695
976
  }),
696
977
  executionMode: "sequential",
@@ -732,6 +1013,16 @@ export default function psyclawExtension(pi) {
732
1013
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
733
1014
  try {
734
1015
  const request = params.request.trim();
1016
+ if (!(await readControlledRun(ctx.cwd))) {
1017
+ return {
1018
+ content: [{ type: "text", text: JSON.stringify({
1019
+ schemaVersion: "psyclaw/workbench-routing/v1",
1020
+ status: "general-mode",
1021
+ message: "PsyClaw controlled research mode is inactive. Continue as a normal conversational agent; do not impose research workflow gates. The user can run /init and then /run when they want the controlled workflow.",
1022
+ }, null, 2) }],
1023
+ details: { status: "general-mode", request },
1024
+ };
1025
+ }
735
1026
  const combined = `${request} ${params.identifier ?? ""}`;
736
1027
  const explicitWorkflow = params.workflow?.trim();
737
1028
  if (explicitWorkflow) {
@@ -755,20 +1046,19 @@ export default function psyclawExtension(pi) {
755
1046
  }
756
1047
  if (publishIntent) {
757
1048
  try {
758
- const result = await publishManuscript(ctx.cwd, { name: "论文初稿", exportDocx: true });
1049
+ const result = await exportAcademicDocument(ctx.cwd);
759
1050
  return {
760
1051
  content: [{ type: "text", text: JSON.stringify({
761
1052
  schemaVersion: "psyclaw/publish-result/v1",
762
- workflow: "publish-manuscript",
1053
+ workflow: result.kind === "manuscript" ? "publish-manuscript" : "export-analysis-report",
763
1054
  status: "completed",
764
1055
  markdownPath: result.markdownPath,
765
1056
  docxPath: result.docxPath,
766
- markdownSha256: result.markdownSha256,
767
1057
  docxSha256: result.docxSha256,
768
- evidenceIds: result.evidenceIds,
769
- next: "手稿已发布到 paper/(panel 与文档清单会自动识别)。如需再修改,直接编辑 paper/论文初稿.md 或继续在 panel 手稿编辑器修改后重新发布。",
1058
+ classification: result.kind,
1059
+ ...(result.kind === "analysis-report" ? { notice: result.notice } : {}),
770
1060
  }, null, 2) }],
771
- details: { workflow: "publish-manuscript", markdownPath: result.markdownPath, docxPath: result.docxPath, evidenceIds: result.evidenceIds },
1061
+ details: { workflow: result.kind === "manuscript" ? "publish-manuscript" : "export-analysis-report", markdownPath: result.markdownPath, docxPath: result.docxPath },
772
1062
  };
773
1063
  }
774
1064
  catch (error) {
@@ -847,6 +1137,12 @@ export default function psyclawExtension(pi) {
847
1137
  executionMode: "sequential",
848
1138
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
849
1139
  try {
1140
+ if (!(await readControlledRun(ctx.cwd))) {
1141
+ return {
1142
+ content: [{ type: "text", text: "当前是普通对话模式;请先运行 /init,再运行 /run,之后才会启用引用归档与论文证据门禁。" }],
1143
+ details: { status: "general-mode" },
1144
+ };
1145
+ }
850
1146
  const result = await recordCitationUse(ctx.cwd, {
851
1147
  doi: params.doi,
852
1148
  reason: params.reason,
@@ -863,9 +1159,12 @@ export default function psyclawExtension(pi) {
863
1159
  reason: result.record.reason,
864
1160
  referenceTitle: ref?.title ?? null,
865
1161
  archived: ref !== null,
866
- next: result.record.verified
867
- ? "引用已双源核验并写入存档(含引用原因)。继续写作时保持每次引用都登记用途。"
868
- : "该 DOI 未能完成双源核验,引用用途已如实登记(verified=false);请在最终交付前补核验或替换来源。",
1162
+ fulltext: result.fulltext,
1163
+ next: result.record.verified && result.fulltext?.status === "downloaded"
1164
+ ? `引用已双源核验,开放全文已保存到 ${result.fulltext.localPath}。`
1165
+ : result.fulltext?.status === "manual-download-required"
1166
+ ? `该引用的题录已登记,但未找到开放 PDF。请通过 ${result.fulltext.doiUrl} 使用你的合法访问权限下载,并保存到 ${result.fulltext.localPath};完成前不得将依赖全文的主张写入正文。`
1167
+ : "该 DOI 或全文未能完成核验;请在最终交付前补核验或替换来源。",
869
1168
  }, null, 2) }],
870
1169
  details: { citationId: result.record.citationId, doi: result.record.doi, verified: result.record.verified },
871
1170
  };
@@ -920,13 +1219,13 @@ export default function psyclawExtension(pi) {
920
1219
  return;
921
1220
  }
922
1221
  // Do not let the runner create a partial `.psyclaw/runs` tree for an
923
- // uninitialized or corrupt project. `/research` (or `psyclaw init`) is
1222
+ // uninitialized or corrupt project. `/init` is
924
1223
  // the explicit state-creation step.
925
1224
  try {
926
1225
  await readProject(ctx.cwd);
927
1226
  }
928
1227
  catch {
929
- ctx.ui.notify("Initialize a psyclaw project with /research before running /agents", "warning");
1228
+ ctx.ui.notify("请先使用 /init 初始化研究项目,再运行 /agents", "warning");
930
1229
  return;
931
1230
  }
932
1231
  const approved = await ctx.ui.confirm("Run read-only research worker?", "The worker runs in a separate Pi process with extensions, skills, context files, and mutating tools disabled.");