psyclaw 0.27.4 → 0.27.6

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 (55) hide show
  1. package/README.md +11 -5
  2. package/dist/apps/panel/index.html +78 -3449
  3. package/dist/src/adapters/pi/extension.js +388 -77
  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 +6 -4
  8. package/dist/src/branding.js.map +1 -1
  9. package/dist/src/chat.js +2 -2
  10. package/dist/src/chat.js.map +1 -1
  11. package/dist/src/core/citations.d.ts +2 -0
  12. package/dist/src/core/citations.js +4 -1
  13. package/dist/src/core/citations.js.map +1 -1
  14. package/dist/src/index.d.ts +3 -0
  15. package/dist/src/index.js +3 -0
  16. package/dist/src/index.js.map +1 -1
  17. package/dist/src/integrations/mcp-runtime.d.ts +13 -0
  18. package/dist/src/integrations/mcp-runtime.js +106 -0
  19. package/dist/src/integrations/mcp-runtime.js.map +1 -0
  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 +0 -52
  27. package/dist/src/panel/extension.js.map +1 -1
  28. package/dist/src/panel/server.js +47 -65
  29. package/dist/src/panel/server.js.map +1 -1
  30. package/dist/src/project/hitl.js +2 -0
  31. package/dist/src/project/hitl.js.map +1 -1
  32. package/dist/src/project/paths.d.ts +2 -6
  33. package/dist/src/project/paths.js +4 -12
  34. package/dist/src/project/paths.js.map +1 -1
  35. package/dist/src/skills/recommended.d.ts +1 -0
  36. package/dist/src/skills/recommended.js +40 -10
  37. package/dist/src/skills/recommended.js.map +1 -1
  38. package/dist/src/telemetry/export.d.ts +32 -0
  39. package/dist/src/telemetry/export.js +102 -1
  40. package/dist/src/telemetry/export.js.map +1 -1
  41. package/dist/src/tui/provider-picker.d.ts +48 -0
  42. package/dist/src/tui/provider-picker.js +115 -0
  43. package/dist/src/tui/provider-picker.js.map +1 -0
  44. package/dist/src/workflows/academic-export.d.ts +14 -0
  45. package/dist/src/workflows/academic-export.js +42 -0
  46. package/dist/src/workflows/academic-export.js.map +1 -0
  47. package/dist/src/workflows/meta-analysis.d.ts +1 -0
  48. package/dist/src/workflows/meta-analysis.js +17 -9
  49. package/dist/src/workflows/meta-analysis.js.map +1 -1
  50. package/dist/src/workflows/publish.js +14 -0
  51. package/dist/src/workflows/publish.js.map +1 -1
  52. package/package.json +1 -1
  53. package/skills/core/academic-grill/SKILL.md +77 -0
  54. package/skills/core/manifest.json +3 -2
  55. package/skills/recommended/catalog.json +56 -9
@@ -1,17 +1,20 @@
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 { homedir } from "node:os";
11
+ import { PROVIDER_PRESETS, saveProviderConfig } from "../../setup.js";
11
12
  import { dirname } from "node:path";
12
13
  import { fileURLToPath } from "node:url";
13
14
  import { coreSkillNames, enabledRecommendedSkillPaths, normalizeRecommendedSkillId, readRecommendationState, readRecommendedCatalog, recommendedSkillTarget, saveRecommendationState, validateModelInstalledRecommendedSkill, } from "../../skills/recommended.js";
14
15
  import { SkillManagerComponent } from "../../tui/skill-manager.js";
16
+ import { RuntimeMcpRegistry } from "../../integrations/mcp-runtime.js";
17
+ import { ProviderPickerComponent, SecretInputComponent, } from "../../tui/provider-picker.js";
15
18
  const PARADIGMS = new Set([
16
19
  "survey-observational",
17
20
  "qualitative-thematic",
@@ -24,28 +27,28 @@ const PARADIGMS = new Set([
24
27
  "policy-legal",
25
28
  "mixed-methods",
26
29
  ]);
27
- function parseResearchArgs(args) {
30
+ function parseInitArgs(args) {
28
31
  const trimmed = args.trim();
29
32
  const match = trimmed.match(/^--paradigm(?:=|\s+)(\S+)(?:\s+([\s\S]*))?$/i);
30
33
  if (trimmed.startsWith("--")) {
31
34
  const paradigm = match?.[1];
32
35
  const goal = match?.[2]?.trim();
33
36
  if (!match || !paradigm || !goal) {
34
- throw new Error("Usage: /research --paradigm survey-observational <research goal>");
37
+ throw new Error("Usage: /init [--paradigm survey-observational] <research goal>");
35
38
  }
36
39
  if (!PARADIGMS.has(paradigm))
37
40
  throw new Error(`Unsupported paradigm: ${paradigm}`);
38
41
  return { paradigm, goal };
39
42
  }
40
43
  if (!trimmed)
41
- throw new Error("Usage: /research --paradigm survey-observational <research goal>");
44
+ throw new Error("Usage: /init [--paradigm survey-observational] <research goal>");
42
45
  return { paradigm: "survey-observational", goal: trimmed };
43
46
  }
44
47
  async function notifyError(ctx, error) {
45
48
  ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
46
49
  }
47
50
  const activeAgentRuns = new Set();
48
- const CORE_SKILLS = new Set(["research-intake", "evidence-capture", "citation-audit", "research-brief"]);
51
+ const CORE_SKILLS = new Set(["academic-grill", "research-intake", "evidence-capture", "citation-audit", "research-brief"]);
49
52
  function coreSkillPath(name) {
50
53
  return join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "..", "skills", "core", name, "SKILL.md");
51
54
  }
@@ -94,6 +97,26 @@ async function petPreference() {
94
97
  return false;
95
98
  }
96
99
  }
100
+ async function saveDefaultModel(provider, model) {
101
+ const path = join(getAgentDir(), "settings.json");
102
+ let settings = {};
103
+ try {
104
+ const parsed = JSON.parse(await readFile(path, "utf8"));
105
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed))
106
+ settings = parsed;
107
+ }
108
+ catch { /* start with a new settings file */ }
109
+ await mkdir(dirname(path), { recursive: true });
110
+ await atomicWriteFile(path, `${JSON.stringify({ ...settings, defaultProvider: provider, defaultModel: model }, null, 2)}\n`);
111
+ }
112
+ async function pickProviderItem(ctx, title, items) {
113
+ const result = await ctx.ui.custom((tui, theme, keybindings, done) => new ProviderPickerComponent(title, items, tui, theme, keybindings, done));
114
+ return result.type === "select" ? result.id : undefined;
115
+ }
116
+ async function promptProviderKey(ctx, providerName, envName) {
117
+ const result = await ctx.ui.custom((tui, theme, keybindings, done) => new SecretInputComponent(`配置 ${providerName}`, envName, tui, theme, keybindings, done));
118
+ return result.type === "submit" ? result.value.trim() : undefined;
119
+ }
97
120
  function parseModelRef(args) {
98
121
  const value = args.trim();
99
122
  const slash = value.indexOf("/");
@@ -122,6 +145,57 @@ function researchTaskPlan(runId, objective) {
122
145
  horizon: { strategy: "hierarchical-plan-act-reflect", maxIterations: 12, reflectionEvery: 1 },
123
146
  };
124
147
  }
148
+ function academicGrillRequest(subject, mode) {
149
+ return [
150
+ "先调用 psyclaw_skill 工具加载 PsyClaw 内置的 academic-grill Skill,然后严格遵循该 Skill 对我的学术研究进行追问。",
151
+ subject
152
+ ? `本次要讨论的研究主题或方案:${subject}`
153
+ : "从当前对话、当前项目文件和已有研究状态中确定要讨论的研究主题或方案;能从这些材料查到的信息不要反问我。",
154
+ "从最上游、影响最大的未决问题开始。每一轮只问一个问题,同时给出你的推荐答案或推荐决策及主要取舍。",
155
+ "明确区分探索性与确证性研究,不把规划内容当作已有证据。直到关键分支已经解决或明确交由研究者决定后,再整理研究规格。",
156
+ mode === "init"
157
+ ? "持久化模式:init。追问完成后直接更新 academic-grill Skill 规定的全部项目文档,不再询问是否更新;写入后列出实际更新的文件。"
158
+ : "持久化模式:review。追问完成后先展示拟更新摘要和受影响文件,并询问我是否写回;得到明确确认前不得修改项目文档。",
159
+ ].join("\n");
160
+ }
161
+ async function controlledRunPath(root) {
162
+ return join(projectPaths(root).root, ".psyclaw", "controlled-run.json");
163
+ }
164
+ async function readControlledRun(root) {
165
+ try {
166
+ const value = JSON.parse(await readFile(await controlledRunPath(root), "utf8"));
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
169
+ : null;
170
+ }
171
+ catch {
172
+ return null;
173
+ }
174
+ }
175
+ async function activateControlledRun(root, projectId, objective) {
176
+ const state = {
177
+ schemaVersion: "psyclaw/controlled-run/v1",
178
+ projectId,
179
+ objective,
180
+ activatedAt: new Date().toISOString(),
181
+ status: "active",
182
+ };
183
+ await atomicWriteFile(await controlledRunPath(root), `${JSON.stringify(state, null, 2)}\n`);
184
+ return state;
185
+ }
186
+ function controlledRunRequest(objective) {
187
+ return [
188
+ "PsyClaw 受控研究流程已由用户通过 /run 明确启动。先读取 .psyclaw/project.json、.psyclaw/controlled-run.json、notes/research-spec.md 和 notes/plan.md。",
189
+ `本次目标:${objective}`,
190
+ "实际推进当前尚未完成的研究任务;先形成或更新符合学术规范的 Markdown 分析报告,不能只描述计划。分析报告必须清楚区分数据来源、统计结果、文献证据、限制与尚未核验内容。",
191
+ "分析报告完成后,下一步不是直接询问是否导出 DOCX,而是询问用户是否据此撰写论文。",
192
+ "若用户选择撰写论文,再单独询问是否先进行文献调研,并说明该阶段可能花费较长时间;提示用户可以指定已启用的 Skill,或者选择 PsyClaw 默认的文献调研方式。不要在获得答复前自动开始长时调研。",
193
+ "文献调研完成并核验后,再询问是否进行全文撰写,并询问使用哪个写作 Skill;未指定时可以建议默认方案,但仍需用户确认。只有充分证据经过核验后,才能把相关主张写入论文正文。",
194
+ "同行评审由用户需要时另行运行 /review;不要在本轮自动审稿。/review 会再次询问使用哪个评审 Skill。最后才询问是否导出 DOCX,以及格式要求。",
195
+ "开放获取 PDF 自动保存到 literature/pdfs/。不得绕过付费墙;没有合法开放全文时给出 https://doi.org/<DOI> 可点击链接,提示用户通过自己的权限下载到系统给出的目标路径。论文中的每篇引用都必须完成 DOI 核验并有本地 PDF,否则列出缺失项并保持正文或发布状态为 blocked。",
196
+ "如果用户在未完成文献调研、全文写作或评审前要求导出 DOCX,允许导出当前分析报告,但必须明确标为遵循学术规范的分析报告,不得称为论文。",
197
+ ].join("\n");
198
+ }
125
199
  async function recommendedItems(kind) {
126
200
  const file = kind === "skills" ? "catalog.json" : "mcp-catalog.json";
127
201
  const moduleDir = dirname(fileURLToPath(import.meta.url));
@@ -159,6 +233,8 @@ async function skillManagerRows(root, state) {
159
233
  name: item.name,
160
234
  description: String(item.description ?? ""),
161
235
  ...(item.sourceRef === undefined ? {} : { sourceRef: item.sourceRef }),
236
+ ...(typeof item.installHint === "string" ? { installHint: item.installHint } : {}),
237
+ collection: item.skillLayout === "collection",
162
238
  installed: true,
163
239
  enabled: state.skills.includes(id),
164
240
  scope,
@@ -171,6 +247,8 @@ async function skillManagerRows(root, state) {
171
247
  name: item.name,
172
248
  description: String(item.description ?? ""),
173
249
  ...(item.sourceRef === undefined ? {} : { sourceRef: item.sourceRef }),
250
+ ...(typeof item.installHint === "string" ? { installHint: item.installHint } : {}),
251
+ collection: item.skillLayout === "collection",
174
252
  installed: false,
175
253
  enabled: false,
176
254
  scope,
@@ -187,7 +265,6 @@ async function mcpManagerRows(state) {
187
265
  return items.map((item) => {
188
266
  const id = String(item.id ?? "");
189
267
  const plan = installPrep.find((candidate) => candidate.id === id);
190
- const blocked = plan?.status === "blocked";
191
268
  const dependencies = Array.isArray(plan?.dependencies)
192
269
  ? plan.dependencies.filter((value) => typeof value === "string")
193
270
  : [];
@@ -197,12 +274,10 @@ async function mcpManagerRows(state) {
197
274
  description: String(item.description ?? ""),
198
275
  ...(typeof item.sourceRef === "string" ? { sourceRef: item.sourceRef } : {}),
199
276
  enabled: state.mcp.includes(id),
200
- blocked,
201
- ...(typeof plan?.blockedReason === "string" ? { reason: plan.blockedReason } : {}),
202
277
  details: [
203
278
  `传输:${String(item.transport ?? "未声明")} · 风险:${String(item.risk ?? "未声明")}`,
204
- `版本:${String(plan?.ref ?? "未固定")} · 许可证:${String(plan?.license ?? "未核验")}`,
205
- `依赖:${dependencies.length > 0 ? dependencies.join(", ") : "无已声明依赖"}`,
279
+ `参考版本:${String(plan?.ref ?? "由模型读取来源确定")} · 许可证:${String(plan?.license ?? "由模型读取来源确定")}`,
280
+ `可能需要:${dependencies.length > 0 ? dependencies.join(", ") : "由模型读取来源确定"}`,
206
281
  ],
207
282
  };
208
283
  });
@@ -254,10 +329,9 @@ function mcpManagerItems(rows) {
254
329
  id: row.id,
255
330
  name: row.name,
256
331
  description: row.description,
257
- status: row.blocked ? "blocked" : row.enabled ? "enabled" : "disabled",
332
+ status: row.enabled ? "enabled" : "disabled",
258
333
  configuredEnabled: row.enabled,
259
334
  ...(row.sourceRef === undefined ? {} : { sourceRef: row.sourceRef }),
260
- ...(row.reason === undefined ? {} : { reason: row.reason }),
261
335
  details: row.details,
262
336
  }));
263
337
  }
@@ -265,11 +339,10 @@ async function openMcpManager(ctx, rows) {
265
339
  return ctx.ui.custom((tui, theme, keybindings, done) => (new SkillManagerComponent(mcpManagerItems(rows), tui, theme, keybindings, done, {
266
340
  title: "MCP 管理",
267
341
  itemLabel: "MCP",
268
- footer: "↑/↓ 移动 · Space 开启/关闭项目配置 · Enter 查看预检 · Esc 关闭",
342
+ footer: "↑/↓ 移动 · Space 开启/关闭项目配置 · Enter 交给模型安装并配置 · Esc 关闭",
269
343
  enterAction: "install",
270
- enabledText: "项目配置已开启;运行时仍需通过安装、信任和工具策略检查",
344
+ enabledText: "项目配置已开启;Enter 可让当前模型重新安装或修复配置",
271
345
  disabledText: "项目配置已关闭",
272
- blockedText: "预检阻断;若配置曾开启,可按 Space 关闭",
273
346
  })));
274
347
  }
275
348
  async function setRecommendedMcpEnabled(root, id, enabled) {
@@ -278,8 +351,6 @@ async function setRecommendedMcpEnabled(root, id, enabled) {
278
351
  const row = rows.find((candidate) => candidate.id === id);
279
352
  if (!row)
280
353
  throw new Error(`未找到推荐 MCP: ${id}`);
281
- if (enabled && row.blocked)
282
- throw new Error(row.reason ?? `MCP 预检未通过: ${id}`);
283
354
  const current = new Set(state.mcp);
284
355
  if (enabled)
285
356
  current.add(id);
@@ -288,23 +359,29 @@ async function setRecommendedMcpEnabled(root, id, enabled) {
288
359
  state.mcp = [...current];
289
360
  await saveRecommendationState(root, state);
290
361
  }
291
- function mcpInstallPlan(id, items, installPrep) {
292
- const item = items.find((candidate) => candidate.id === id);
293
- if (!item)
294
- throw new Error(`未找到推荐 MCP: ${id}`);
295
- const plan = installPrep.find((candidate) => candidate.id === id);
296
- return {
297
- schemaVersion: "psyclaw/recommendation-install/v1",
298
- kind: "mcp",
299
- id,
300
- name: item.name,
301
- sourceRef: item.sourceRef,
302
- plan: plan ?? null,
303
- status: plan?.status ?? "review-required",
304
- next: "这里只展示固定来源、版本、许可证和依赖预检;完成受信任安装与注册后,MCP 才能在运行时调用。",
305
- };
362
+ function modelMcpInstallTask(root, row, plan) {
363
+ const suggestedCommand = typeof plan?.command === "string" ? plan.command : "请根据来源仓库的最新安装说明确定";
364
+ const suggestedTarget = typeof plan?.target === "string" ? plan.target : `.psyclaw/mcp/${row.id}.json`;
365
+ return [
366
+ `安装并配置推荐 MCP:${row.name} (${row.id})。`,
367
+ `来源网址:${row.sourceRef ?? "未提供;请停止并告知用户"}`,
368
+ `当前项目:${root}`,
369
+ `目录中的参考命令:${suggestedCommand}`,
370
+ `建议的项目配置记录:${suggestedTarget}`,
371
+ "用户已经通过 MCP 管理页明确授权本次安装。你可以直接联网、克隆或下载来源、使用所需包管理器安装依赖、执行安装命令,并写入与该 MCP 有关的项目或用户级运行配置;不要再次要求用户批准这些安装步骤。",
372
+ "请先阅读来源仓库的实际安装与启动说明,再根据当前操作系统和宿主环境完成可运行的 stdio/HTTP MCP 配置。目录中的版本、命令和依赖只作参考,不得因为哈希、许可证、依赖或本地数据策略检查而中止安装。",
373
+ "安装完成后实际启动一次服务器或执行等价的最小连通性检查,确认启动命令和工作目录有效。不要输出、复制或提交 API Key、令牌及其他凭据。",
374
+ `成功后确保 .psyclaw/recommendations.json 的 mcp 列表包含 ${JSON.stringify(row.id)},说明写入了哪些配置,并提醒用户执行 /reload。若安装命令本身失败,直接诊断并修复;只有遇到必须由用户提供的凭据或外部软件许可证时才向用户说明。`,
375
+ ].join("\n");
376
+ }
377
+ async function queueModelMcpInstall(pi, ctx, row, plan) {
378
+ if (!row.sourceRef)
379
+ throw new Error(`推荐 MCP 没有来源网址: ${row.id}`);
380
+ await setRecommendedMcpEnabled(ctx.cwd, row.id, true);
381
+ pi.sendUserMessage(modelMcpInstallTask(ctx.cwd, row, plan), ctx.isIdle() ? {} : { deliverAs: "followUp" });
382
+ ctx.ui.notify(`已将 ${row.name} 的下载、安装和配置任务交给当前模型。模型完成并确认可启动后,请执行 /reload。`, "info");
306
383
  }
307
- async function showMcpManager(args, ctx) {
384
+ async function showMcpManager(pi, args, ctx) {
308
385
  const [verb, id] = args.trim().split(/\s+/).filter(Boolean);
309
386
  if (verb && !["status", "enable", "disable", "install"].includes(verb)) {
310
387
  throw new Error("Usage: /mcp [status|enable <id>|disable <id>|install <id>]");
@@ -314,7 +391,11 @@ async function showMcpManager(args, ctx) {
314
391
  }
315
392
  const catalog = await recommendedItems("mcp");
316
393
  if (verb === "install") {
317
- ctx.ui.notify(JSON.stringify(mcpInstallPlan(id, catalog.items, catalog.installPrep), null, 2), "info");
394
+ const rows = await mcpManagerRows(await readRecommendationState(ctx.cwd));
395
+ const row = rows.find((candidate) => candidate.id === id);
396
+ if (!row)
397
+ throw new Error(`未找到推荐 MCP: ${id}`);
398
+ await queueModelMcpInstall(pi, ctx, row, catalog.installPrep.find((candidate) => candidate.id === row.id));
318
399
  return;
319
400
  }
320
401
  if (verb === "enable" || verb === "disable") {
@@ -324,7 +405,7 @@ async function showMcpManager(args, ctx) {
324
405
  }
325
406
  if (!ctx.hasUI || typeof ctx.ui.custom !== "function" || verb === "status") {
326
407
  const rows = await mcpManagerRows(await readRecommendationState(ctx.cwd));
327
- ctx.ui.notify(rows.map((row) => `${row.blocked ? "[!]" : row.enabled ? "[on]" : "[off]"} ${row.id} — ${row.name}${row.reason ? ` — ${row.reason}` : ""}`).join("\n"), "info");
408
+ ctx.ui.notify(rows.map((row) => `${row.enabled ? "[on]" : "[off]"} ${row.id} — ${row.name}`).join("\n"), "info");
328
409
  return;
329
410
  }
330
411
  while (true) {
@@ -336,8 +417,8 @@ async function showMcpManager(args, ctx) {
336
417
  if (!row)
337
418
  continue;
338
419
  if (action.type === "install") {
339
- ctx.ui.notify(JSON.stringify(mcpInstallPlan(row.id, catalog.items, catalog.installPrep), null, 2), "info");
340
- continue;
420
+ await queueModelMcpInstall(pi, ctx, row, catalog.installPrep.find((candidate) => candidate.id === row.id));
421
+ return;
341
422
  }
342
423
  if (action.type === "toggle") {
343
424
  await setRecommendedMcpEnabled(ctx.cwd, row.id, action.enabled);
@@ -350,10 +431,13 @@ function modelSkillInstallTask(root, row, scope) {
350
431
  return [
351
432
  `安装推荐 Skill:${row.name} (${row.id})。`,
352
433
  `来源网址:${row.sourceRef}`,
434
+ ...(row.installHint ? [`仓库入口提示:${row.installHint}`] : []),
353
435
  `安装位置:${skillScopeLabel(scope)}。`,
354
436
  `唯一允许的最终目标目录:${target}`,
355
- "请使用当前会话的工具检查来源仓库并完成安装。不要写入其他 Skill 目录,不要修改 data/raw、.git 或研究产物。",
356
- "目标目录最终必须直接包含有效 SKILL.md(YAML frontmatter 至少包含 name 和 description),不得包含 .git、符号链接、凭据或二进制大文件。",
437
+ "用户已通过 Skill 管理页授权本次安装。请使用当前会话的联网、文件和命令工具读取来源仓库,并直接下载、安装所需依赖和完成 Skill 安装;不要再次要求安装权限。不要写入其他 Skill 目录,不要修改 data/raw、.git 或研究产物。",
438
+ row.collection
439
+ ? "这是多 Skill 套件:目标目录自身无需 SKILL.md,但其子目录必须包含一个或多个有效 SKILL.md。保留套件内共享目录和相对路径,不得包含 .git、符号链接或凭据。"
440
+ : "目标目录最终必须直接包含有效 SKILL.md(YAML frontmatter 至少包含 name 和 description),不得包含 .git、符号链接、凭据或二进制大文件。",
357
441
  "如果仓库包含多个 Skill,只安装与此推荐项相符的部分;如果它不是 Skill 或无法合理适配,停止并说明原因,不要伪造 SKILL.md。",
358
442
  "安装完成后检查目标目录结构,并提醒用户执行 /skills 启用该项,再执行 /reload。",
359
443
  ].join("\n");
@@ -532,26 +616,62 @@ const WORKFLOW_RUNNERS = {
532
616
  export default function psyclawExtension(pi) {
533
617
  const developerCommands = process.env.PSYCLAW_DEVELOPER_COMMANDS === "1";
534
618
  const legacyTestApi = typeof pi.registerTool !== "function";
619
+ const runtimeMcps = new RuntimeMcpRegistry();
535
620
  if (!legacyTestApi && typeof pi.on === "function")
536
621
  pi.on("resources_discover", async (event, ctx) => {
537
622
  const enabled = await enabledRecommendedSkillPaths(event.cwd);
538
623
  for (const warning of enabled.warnings)
539
624
  ctx.ui.notify(`PsyClaw Skill: ${warning}`, "warning");
540
- return { skillPaths: enabled.paths };
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] };
541
636
  });
542
- pi.registerCommand("research", {
543
- description: "创建可追溯的研究项目",
637
+ if (!legacyTestApi && typeof pi.on === "function")
638
+ pi.on("session_shutdown", () => runtimeMcps.close());
639
+ pi.registerCommand("init", {
640
+ description: "初始化可追溯的研究项目",
544
641
  handler: async (args, ctx) => {
545
642
  try {
546
- const parsed = parseResearchArgs(args);
643
+ const parsed = parseInitArgs(args);
547
644
  const project = await bootstrapProject({ root: ctx.cwd, ...parsed });
548
- ctx.ui.notify(`Initialized ${project.id} (${project.paradigm})`, "info");
645
+ if (typeof pi.sendUserMessage === "function") {
646
+ pi.sendUserMessage(academicGrillRequest(project.goal, "init"), ctx.isIdle() ? {} : { deliverAs: "followUp" });
647
+ ctx.ui.notify(`研究项目已初始化:${project.id}(${project.paradigm})。现在开始学术追问;准备好后使用 /run 启动受控研究流程。`, "info");
648
+ }
649
+ else {
650
+ ctx.ui.notify(`研究项目已初始化:${project.id}(${project.paradigm})。使用 /run 启动受控研究流程。`, "info");
651
+ }
549
652
  }
550
653
  catch (error) {
551
654
  await notifyError(ctx, error);
552
655
  }
553
656
  },
554
657
  });
658
+ if (!legacyTestApi)
659
+ pi.registerCommand("run", {
660
+ description: "在 /init 后启动受控研究流程",
661
+ handler: async (args, ctx) => {
662
+ 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");
669
+ }
670
+ catch {
671
+ ctx.ui.notify("请先使用 /init 初始化研究项目;未运行 /init 和 /run 时保持普通对话模式。", "warning");
672
+ }
673
+ },
674
+ });
555
675
  if (developerCommands || legacyTestApi)
556
676
  pi.registerCommand("verify", {
557
677
  description: "写入机器可读的交接检查点",
@@ -589,6 +709,99 @@ export default function psyclawExtension(pi) {
589
709
  }
590
710
  },
591
711
  });
712
+ if (!legacyTestApi)
713
+ pi.registerCommand("grill", {
714
+ description: "逐题追问并压力测试学术研究方案",
715
+ handler: async (args, ctx) => {
716
+ const subject = args.trim();
717
+ pi.sendUserMessage(academicGrillRequest(subject, "review"), ctx.isIdle() ? {} : { deliverAs: "followUp" });
718
+ ctx.ui.notify("已启动学术追问。完成后会先展示文档更新建议,并询问是否写回。", "info");
719
+ },
720
+ });
721
+ if (!legacyTestApi)
722
+ pi.registerCommand("review", {
723
+ description: "并发运行多角色模拟同行评审",
724
+ handler: async (_args, ctx) => {
725
+ if (activeAgentRuns.has(ctx.cwd)) {
726
+ ctx.ui.notify("当前项目已有一个多智能体任务正在运行", "warning");
727
+ return;
728
+ }
729
+ try {
730
+ const project = await readProject(ctx.cwd);
731
+ if (!(await readControlledRun(ctx.cwd))) {
732
+ ctx.ui.notify("请先使用 /run 启动受控研究流程;普通对话模式不会自动执行同行评审。", "warning");
733
+ return;
734
+ }
735
+ const reviewSkill = ctx.hasUI
736
+ ? await ctx.ui.input("同行评审使用哪个 Skill?", "留空使用 PsyClaw 默认多角色模拟评审")
737
+ : undefined;
738
+ if (reviewSkill === undefined && ctx.hasUI) {
739
+ ctx.ui.notify("已取消同行评审", "info");
740
+ return;
741
+ }
742
+ activeAgentRuns.add(ctx.cwd);
743
+ ctx.ui.notify(`正在使用${reviewSkill?.trim() ? ` Skill“${reviewSkill.trim()}”辅助的` : "默认"} 4 角色并发模拟评审;不会自动改稿。`, "info");
744
+ const result = await runParallelPeerReview({
745
+ root: ctx.cwd,
746
+ project,
747
+ ...(ctx.model?.provider === undefined ? {} : { provider: ctx.model.provider }),
748
+ ...(ctx.model?.id === undefined ? {} : { model: ctx.model.id }),
749
+ env: providerEnvironment(ctx.model?.provider),
750
+ ...(reviewSkill?.trim() ? { skillGuidance: reviewSkill.trim() } : {}),
751
+ onEvent: async (event) => { await new RunEventLog(ctx.cwd, event.runId).append(event); },
752
+ });
753
+ pi.appendEntry("psyclaw:multi-agent-review", {
754
+ runId: result.runId,
755
+ status: result.status,
756
+ outputPath: result.outputPath,
757
+ adoption: "pending-human-decision",
758
+ recordedAt: new Date().toISOString(),
759
+ });
760
+ ctx.ui.notify(`模拟审稿${result.status === "completed" ? "已完成" : "未完整完成"}:${result.outputPath}。审稿意见仅供研究者决定是否采纳。`, result.status === "completed" ? "info" : "warning");
761
+ }
762
+ catch (error) {
763
+ await notifyError(ctx, error);
764
+ }
765
+ finally {
766
+ activeAgentRuns.delete(ctx.cwd);
767
+ }
768
+ },
769
+ });
770
+ if (!legacyTestApi)
771
+ pi.registerCommand("loop", {
772
+ description: "按计划循环推进当前研究阶段",
773
+ handler: async (args, ctx) => {
774
+ const requested = args.trim();
775
+ if (requested.toLowerCase() === "stop") {
776
+ if (ctx.isIdle())
777
+ ctx.ui.notify("当前没有正在运行的研究循环", "info");
778
+ else {
779
+ ctx.abort();
780
+ ctx.ui.notify("已请求停止当前研究循环", "warning");
781
+ }
782
+ return;
783
+ }
784
+ let project;
785
+ try {
786
+ project = await readProject(ctx.cwd);
787
+ }
788
+ catch {
789
+ ctx.ui.notify("请先使用 /init 初始化研究项目,再运行 /loop", "warning");
790
+ return;
791
+ }
792
+ const objective = requested || project.goal;
793
+ const request = [
794
+ "运行一个有边界的 PsyClaw 研究循环,采用计划、执行、检查、修正的顺序推进当前研究阶段。",
795
+ `本轮目标:${objective}`,
796
+ "开始前读取 .psyclaw/project.json、notes/goal.md 和 notes/plan.md;如果当前对话或项目中存在经研究者确认的 /grill 研究规格,以该规格细化初始计划,但不得违反系统规则、AGENTS.md 或研究门禁。",
797
+ "先识别当前最高优先级且依赖已满足的一个任务,再使用可用工具实际推进。不要只描述准备做什么。普通项目文件写入限于该任务所需范围;原始数据、凭据、外部发布、破坏性操作和方法学关键变更仍需明确批准。",
798
+ "每完成一个实质步骤就检查产物、证据和计划是否一致。遇到缺失证据、研究者决策、权限边界、不可恢复错误或当前阶段已经完成时立即停止,不得为了继续循环而虚构输入或结论。",
799
+ "本次最多推进一个可验收的研究阶段。结束时简要报告已完成、已核验、阻塞、计划变化和下一步;规划内容不得表述为已有研究证据。",
800
+ ].join("\n");
801
+ pi.sendUserMessage(request, ctx.isIdle() ? {} : { deliverAs: "followUp" });
802
+ ctx.ui.notify("已启动有界研究循环;使用 /loop stop 可请求中止", "info");
803
+ },
804
+ });
592
805
  if (!legacyTestApi)
593
806
  pi.registerCommand("skills", {
594
807
  description: "打开 Skill 安装与启用管理页",
@@ -601,12 +814,24 @@ export default function psyclawExtension(pi) {
601
814
  }
602
815
  },
603
816
  });
817
+ if (!legacyTestApi)
818
+ pi.registerCommand("skill", {
819
+ description: "调用任意已加载 Skill(/skill <name> [任务])",
820
+ handler: async (args, ctx) => {
821
+ const [name, ...rest] = args.trim().split(/\s+/).filter(Boolean);
822
+ if (!name || !/^[a-z0-9-]+$/.test(name)) {
823
+ ctx.ui.notify("Usage: /skill <name> [task];也可直接使用 /skill:<name>", "info");
824
+ return;
825
+ }
826
+ pi.sendUserMessage(`/skill:${name}${rest.length ? ` ${rest.join(" ")}` : ""}`, ctx.isIdle() ? {} : { deliverAs: "followUp" });
827
+ },
828
+ });
604
829
  if (!legacyTestApi)
605
830
  pi.registerCommand("mcp", {
606
- description: "打开 MCP 配置与预检管理页",
831
+ description: "打开 MCP 安装与配置管理页",
607
832
  handler: async (args, ctx) => {
608
833
  try {
609
- await showMcpManager(args, ctx);
834
+ await showMcpManager(pi, args, ctx);
610
835
  }
611
836
  catch (error) {
612
837
  await notifyError(ctx, error);
@@ -615,7 +840,7 @@ export default function psyclawExtension(pi) {
615
840
  });
616
841
  if (!legacyTestApi)
617
842
  pi.registerCommand("install", {
618
- description: "查看推荐 Skill/MCP,或生成安装预检计划",
843
+ description: "查看推荐 Skill/MCP,或交给当前模型安装",
619
844
  handler: async (args, ctx) => {
620
845
  try {
621
846
  const [kind, id] = args.trim().split(/\s+/, 2);
@@ -633,13 +858,13 @@ export default function psyclawExtension(pi) {
633
858
  if (kind === "skill")
634
859
  await showSkillManager(pi, "", ctx);
635
860
  else
636
- await showMcpManager("", ctx);
861
+ await showMcpManager(pi, "", ctx);
637
862
  return;
638
863
  }
639
864
  if (kind === "skill")
640
865
  await showSkillManager(pi, `install ${id}`, ctx);
641
866
  else
642
- await showMcpManager(`install ${id}`, ctx);
867
+ await showMcpManager(pi, `install ${id}`, ctx);
643
868
  }
644
869
  catch (error) {
645
870
  await notifyError(ctx, error);
@@ -651,7 +876,7 @@ export default function psyclawExtension(pi) {
651
876
  description: "查看或切换模型 Provider",
652
877
  handler: async (args, ctx) => {
653
878
  try {
654
- const requested = args.trim();
879
+ let requested = args.trim();
655
880
  const providers = new Map();
656
881
  for (const model of ctx.modelRegistry.getAll()) {
657
882
  const list = providers.get(model.provider) ?? [];
@@ -660,33 +885,69 @@ export default function psyclawExtension(pi) {
660
885
  }
661
886
  if (!requested) {
662
887
  const current = ctx.model?.provider ?? "none";
663
- const lines = [...providers].map(([id, models]) => `${id}${id === current ? " *" : ""} (${models.length} models)`);
664
- ctx.ui.notify([`当前 Provider: ${current}`, ...lines, "", "切换:/provider <id>", "模型:/model <provider/model>", "添加或更新 Key:退出后运行 psyclaw wizard"].join("\n"), "info");
665
- return;
888
+ const available = new Map(PROVIDER_PRESETS.filter((preset) => preset.models.length > 0).map((preset) => [preset.id, preset]));
889
+ for (const id of providers.keys())
890
+ if (!available.has(id))
891
+ available.set(id, {
892
+ id,
893
+ name: ctx.modelRegistry.getProviderDisplayName(id),
894
+ baseUrl: "",
895
+ api: "openai-completions",
896
+ apiKeyEnv: `${id.toUpperCase().replace(/[^A-Z0-9]/g, "_")}_API_KEY`,
897
+ models: [],
898
+ });
899
+ if (!ctx.hasUI || typeof ctx.ui.custom !== "function") {
900
+ const lines = [...available].map(([id, preset]) => `${id}${id === current ? " *" : ""} — ${preset.name}`);
901
+ ctx.ui.notify([`当前 Provider: ${current}`, ...lines, "", "切换:/provider <id>"].join("\n"), "info");
902
+ return;
903
+ }
904
+ const selectedProvider = await pickProviderItem(ctx, "选择模型 Provider", [...available].map(([id, preset]) => ({
905
+ id,
906
+ label: preset.name,
907
+ description: `${id} · ${providers.get(id)?.length ?? preset.models.length} 个模型`,
908
+ current: id === current,
909
+ })));
910
+ if (!selectedProvider)
911
+ return;
912
+ requested = selectedProvider;
666
913
  }
667
914
  if (!/^[A-Za-z0-9._:-]+$/.test(requested))
668
915
  throw new Error("Usage: /provider <provider-id>");
669
- const models = providers.get(requested);
670
- if (!models?.[0]) {
671
- const preset = PROVIDER_PRESETS.find((item) => item.id === requested);
672
- throw new Error(preset ? `Provider ${requested} 尚未加载;请运行 psyclaw wizard 完成配置` : `Unknown provider: ${requested}`);
673
- }
674
- let selectedId = models[0]?.id;
675
- if (models.length > 1) {
676
- if (!ctx.hasUI)
916
+ const preset = PROVIDER_PRESETS.find((item) => item.id === requested);
917
+ let models = providers.get(requested) ?? [];
918
+ const modelChoices = models.length > 0 ? models : (preset?.models ?? []);
919
+ if (modelChoices.length === 0)
920
+ throw new Error(`Unknown provider or no configured models: ${requested}`);
921
+ let selectedId = modelChoices[0].id;
922
+ if (modelChoices.length > 1) {
923
+ if (!ctx.hasUI || typeof ctx.ui.custom !== "function")
677
924
  throw new Error(`Provider ${requested} has multiple models; use /model ${requested}/<model-id>`);
678
- const choice = await ctx.ui.select(`选择 ${requested} 模型`, models.map((model) => model.id));
925
+ const choice = await pickProviderItem(ctx, `选择 ${preset?.name ?? requested} 模型`, modelChoices.map((model) => ({
926
+ id: model.id,
927
+ label: "name" in model && typeof model.name === "string" ? model.name : model.id,
928
+ description: model.id,
929
+ current: ctx.model?.provider === requested && ctx.model.id === model.id,
930
+ })));
679
931
  if (!choice)
680
932
  return;
681
933
  selectedId = choice;
682
934
  }
935
+ if (preset && ctx.hasUI && typeof ctx.ui.custom === "function") {
936
+ const key = await promptProviderKey(ctx, preset.name, preset.apiKeyEnv);
937
+ if (key === undefined)
938
+ return;
939
+ await saveProviderConfig({ ...preset, ...(key ? { apiKey: key } : {}) });
940
+ await ctx.modelRegistry.refresh();
941
+ models = ctx.modelRegistry.getAll().filter((model) => model.provider === requested);
942
+ }
683
943
  const selected = models.find((model) => model.id === selectedId);
684
944
  if (!selected)
685
945
  throw new Error(`No model selected for provider: ${requested}`);
686
946
  const changed = await pi.setModel(selected);
687
947
  if (!changed)
688
- throw new Error(`Provider ${requested} 的凭据尚未配置;请运行 psyclaw wizard`);
689
- ctx.ui.notify(`Provider selected: ${requested} (${selected.id})`, "info");
948
+ throw new Error(`未找到 ${requested} 的可用凭据;请重新运行 /provider 并输入 API Key`);
949
+ await saveDefaultModel(requested, selected.id);
950
+ ctx.ui.notify(`已切换并设为默认模型:${requested}/${selected.id}`, "info");
690
951
  }
691
952
  catch (error) {
692
953
  await notifyError(ctx, error);
@@ -732,12 +993,44 @@ export default function psyclawExtension(pi) {
732
993
  };
733
994
  pi.registerCommand("trace", traceCommand);
734
995
  if (typeof pi.registerTool === "function") {
996
+ pi.registerTool({
997
+ name: "psyclaw_mcp",
998
+ label: "MCP tools",
999
+ 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.",
1000
+ promptSnippet: "Discover and call enabled MCP servers such as MNE through PsyClaw.",
1001
+ parameters: Type.Object({
1002
+ action: Type.Union([Type.Literal("list"), Type.Literal("call")]),
1003
+ server: Type.Optional(Type.String({ description: "Configured MCP server id, for example mne-mcp" })),
1004
+ tool: Type.Optional(Type.String({ description: "Exact MCP tool name returned by action=list" })),
1005
+ input: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
1006
+ }),
1007
+ executionMode: "sequential",
1008
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
1009
+ try {
1010
+ if (params.action === "list") {
1011
+ const servers = await runtimeMcps.list(ctx.cwd, params.server);
1012
+ return { content: [{ type: "text", text: JSON.stringify({ servers }, null, 2) }], details: { action: "list", servers: servers.length } };
1013
+ }
1014
+ if (!params.server || !params.tool)
1015
+ throw new Error("server and tool are required for action=call");
1016
+ const result = await runtimeMcps.call(ctx.cwd, params.server, params.tool, params.input ?? {});
1017
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], details: { action: "call", server: params.server, tool: params.tool } };
1018
+ }
1019
+ catch (error) {
1020
+ return {
1021
+ content: [{ type: "text", text: `MCP call failed: ${error instanceof Error ? error.message : String(error)}` }],
1022
+ details: { action: params.action, status: "failed" },
1023
+ isError: true,
1024
+ };
1025
+ }
1026
+ },
1027
+ });
735
1028
  pi.registerTool({
736
1029
  name: "psyclaw_skill",
737
1030
  label: "Research skill",
738
1031
  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.",
739
1032
  parameters: Type.Object({
740
- name: Type.String({ description: "Core skill name: research-intake, evidence-capture, citation-audit, or research-brief" }),
1033
+ name: Type.String({ description: "Core skill name: academic-grill, research-intake, evidence-capture, citation-audit, or research-brief" }),
741
1034
  purpose: Type.Optional(Type.String({ description: "Short user-facing reason for using this skill now" })),
742
1035
  }),
743
1036
  executionMode: "sequential",
@@ -779,6 +1072,16 @@ export default function psyclawExtension(pi) {
779
1072
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
780
1073
  try {
781
1074
  const request = params.request.trim();
1075
+ if (!(await readControlledRun(ctx.cwd))) {
1076
+ return {
1077
+ content: [{ type: "text", text: JSON.stringify({
1078
+ schemaVersion: "psyclaw/workbench-routing/v1",
1079
+ status: "general-mode",
1080
+ 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.",
1081
+ }, null, 2) }],
1082
+ details: { status: "general-mode", request },
1083
+ };
1084
+ }
782
1085
  const combined = `${request} ${params.identifier ?? ""}`;
783
1086
  const explicitWorkflow = params.workflow?.trim();
784
1087
  if (explicitWorkflow) {
@@ -802,20 +1105,19 @@ export default function psyclawExtension(pi) {
802
1105
  }
803
1106
  if (publishIntent) {
804
1107
  try {
805
- const result = await publishManuscript(ctx.cwd, { name: "论文初稿", exportDocx: true });
1108
+ const result = await exportAcademicDocument(ctx.cwd);
806
1109
  return {
807
1110
  content: [{ type: "text", text: JSON.stringify({
808
1111
  schemaVersion: "psyclaw/publish-result/v1",
809
- workflow: "publish-manuscript",
1112
+ workflow: result.kind === "manuscript" ? "publish-manuscript" : "export-analysis-report",
810
1113
  status: "completed",
811
1114
  markdownPath: result.markdownPath,
812
1115
  docxPath: result.docxPath,
813
- markdownSha256: result.markdownSha256,
814
1116
  docxSha256: result.docxSha256,
815
- evidenceIds: result.evidenceIds,
816
- next: "手稿已发布到 paper/(panel 与文档清单会自动识别)。如需再修改,直接编辑 paper/论文初稿.md 或继续在 panel 手稿编辑器修改后重新发布。",
1117
+ classification: result.kind,
1118
+ ...(result.kind === "analysis-report" ? { notice: result.notice } : {}),
817
1119
  }, null, 2) }],
818
- details: { workflow: "publish-manuscript", markdownPath: result.markdownPath, docxPath: result.docxPath, evidenceIds: result.evidenceIds },
1120
+ details: { workflow: result.kind === "manuscript" ? "publish-manuscript" : "export-analysis-report", markdownPath: result.markdownPath, docxPath: result.docxPath },
819
1121
  };
820
1122
  }
821
1123
  catch (error) {
@@ -894,6 +1196,12 @@ export default function psyclawExtension(pi) {
894
1196
  executionMode: "sequential",
895
1197
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
896
1198
  try {
1199
+ if (!(await readControlledRun(ctx.cwd))) {
1200
+ return {
1201
+ content: [{ type: "text", text: "当前是普通对话模式;请先运行 /init,再运行 /run,之后才会启用引用归档与论文证据门禁。" }],
1202
+ details: { status: "general-mode" },
1203
+ };
1204
+ }
897
1205
  const result = await recordCitationUse(ctx.cwd, {
898
1206
  doi: params.doi,
899
1207
  reason: params.reason,
@@ -910,9 +1218,12 @@ export default function psyclawExtension(pi) {
910
1218
  reason: result.record.reason,
911
1219
  referenceTitle: ref?.title ?? null,
912
1220
  archived: ref !== null,
913
- next: result.record.verified
914
- ? "引用已双源核验并写入存档(含引用原因)。继续写作时保持每次引用都登记用途。"
915
- : "该 DOI 未能完成双源核验,引用用途已如实登记(verified=false);请在最终交付前补核验或替换来源。",
1221
+ fulltext: result.fulltext,
1222
+ next: result.record.verified && result.fulltext?.status === "downloaded"
1223
+ ? `引用已双源核验,开放全文已保存到 ${result.fulltext.localPath}。`
1224
+ : result.fulltext?.status === "manual-download-required"
1225
+ ? `该引用的题录已登记,但未找到开放 PDF。请通过 ${result.fulltext.doiUrl} 使用你的合法访问权限下载,并保存到 ${result.fulltext.localPath};完成前不得将依赖全文的主张写入正文。`
1226
+ : "该 DOI 或全文未能完成核验;请在最终交付前补核验或替换来源。",
916
1227
  }, null, 2) }],
917
1228
  details: { citationId: result.record.citationId, doi: result.record.doi, verified: result.record.verified },
918
1229
  };
@@ -967,13 +1278,13 @@ export default function psyclawExtension(pi) {
967
1278
  return;
968
1279
  }
969
1280
  // Do not let the runner create a partial `.psyclaw/runs` tree for an
970
- // uninitialized or corrupt project. `/research` (or `psyclaw init`) is
1281
+ // uninitialized or corrupt project. `/init` is
971
1282
  // the explicit state-creation step.
972
1283
  try {
973
1284
  await readProject(ctx.cwd);
974
1285
  }
975
1286
  catch {
976
- ctx.ui.notify("Initialize a psyclaw project with /research before running /agents", "warning");
1287
+ ctx.ui.notify("请先使用 /init 初始化研究项目,再运行 /agents", "warning");
977
1288
  return;
978
1289
  }
979
1290
  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.");