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
@@ -13,7 +13,6 @@ import { KNOWN_AGENTS } from "../agents/catalog.js";
13
13
  import { planAgentInstall } from "../install/installer.js";
14
14
  import { deepSeekProviderSpec, PiModelGateway } from "../adapters/pi/model.js";
15
15
  import { PROVIDER_PRESETS, saveProviderConfig } from "../setup.js";
16
- import { readHitlWorkspace } from "../project/hitl.js";
17
16
  import { assertSafeProjectPath, projectPaths } from "../project/paths.js";
18
17
  import { readManuscript } from "../project/manuscript.js";
19
18
  import { appendJsonlIfMissing, atomicWriteFile, readJsonl } from "../project/jsonl.js";
@@ -30,11 +29,16 @@ export { verifyDoi } from "../core/doi.js";
30
29
  import { resumePlanWithPi } from "../orchestration/pi-executor.js";
31
30
  import { RunEventLog } from "./events.js";
32
31
  import { PSYCLAW_IDENTITY_PROMPT } from "../branding.js";
32
+ import { recommendedSkillTarget } from "../skills/recommended.js";
33
+ import { projectTraceSnapshot } from "../telemetry/export.js";
33
34
  const activePanelRuns = new Set();
34
35
  async function readRecommendationState(root) {
35
36
  try {
36
37
  const value = JSON.parse(await readFile(join(root, ".psyclaw", "recommendations.json"), "utf8"));
37
- return { schemaVersion: "psyclaw/recommendation-state/v1", skills: Array.isArray(value.skills) ? value.skills.filter((id) => typeof id === "string") : [], mcp: Array.isArray(value.mcp) ? value.mcp.filter((id) => typeof id === "string") : [] };
38
+ const skillScopes = value.skillScopes && typeof value.skillScopes === "object"
39
+ ? Object.fromEntries(Object.entries(value.skillScopes).filter((entry) => entry[1] === "project" || entry[1] === "user"))
40
+ : undefined;
41
+ return { schemaVersion: "psyclaw/recommendation-state/v1", skills: Array.isArray(value.skills) ? value.skills.filter((id) => typeof id === "string") : [], mcp: Array.isArray(value.mcp) ? value.mcp.filter((id) => typeof id === "string") : [], ...(skillScopes === undefined ? {} : { skillScopes }) };
38
42
  }
39
43
  catch {
40
44
  return { schemaVersion: "psyclaw/recommendation-state/v1", skills: [], mcp: [] };
@@ -45,6 +49,7 @@ async function writeRecommendationState(root, state) {
45
49
  }
46
50
  /** The bundled core skills, always listed so the user can disable (not uninstall) them. */
47
51
  const CORE_SKILLS = [
52
+ { id: "academic-grill", name: "学术追问" },
48
53
  { id: "research-intake", name: "研究入口" },
49
54
  { id: "evidence-capture", name: "证据登记" },
50
55
  { id: "citation-audit", name: "引用审计" },
@@ -154,7 +159,7 @@ async function appendLedgerEvidence(root, body) {
154
159
  * Any root-level document (questionnaire exports etc.) is listed as well.
155
160
  */
156
161
  const DOCUMENT_EXTENSIONS = new Set(["md", "markdown", "docx", "doc", "pdf", "xlsx", "xls", "csv", "txt"]);
157
- const DOCUMENT_SKIP_DIRS = [".psyclaw", ".git", "node_modules", "analysis/scripts", "analysis/results", "analysis/configs", "artifacts", "logs", "paper/archive"];
162
+ const DOCUMENT_SKIP_DIRS = [".psyclaw", ".git", "node_modules", "analysis/scripts", "analysis/results", "analysis/configs", "logs", "paper/archive", "literature/pdfs"];
158
163
  /** Classify a document by its location and extension (shared by scan + import). */
159
164
  function classifyDocumentKind(relative, extension) {
160
165
  return relative.startsWith("paper/") || relative.startsWith("docs/")
@@ -387,12 +392,55 @@ async function panelStats(root) {
387
392
  const files = await listProjectFiles(root);
388
393
  return { schemaVersion: "psyclaw/panel-stats/v1", runs: (await listRuns(root)).length, evidence: await countLines(paths.evidence), claims: await countLines(paths.claims), auditEvents: await countLines(paths.audit), trackedFiles: files.length, outputs: files.filter((path) => path.startsWith("outputs/")).length, generatedAt: new Date().toISOString() };
389
394
  }
395
+ const PANEL_FILE_ROOTS = ["notes/", "paper/", "docs/", "outputs/", "logs/"];
396
+ const PANEL_FILE_EXTENSIONS = /\.(md|markdown|txt|json|csv)$/i;
397
+ function panelFileAllowed(relative) {
398
+ const normalized = relative.replaceAll("\\", "/");
399
+ return PANEL_FILE_ROOTS.some((prefix) => normalized.startsWith(prefix))
400
+ && PANEL_FILE_EXTENSIONS.test(normalized)
401
+ && !/credential|secret|auth\.json|\.env/i.test(normalized);
402
+ }
403
+ async function panelProjectFiles(root) {
404
+ return (await listProjectFiles(root)).filter(panelFileAllowed);
405
+ }
406
+ async function readPanelProjectFile(root, relative) {
407
+ const normalized = relative.replaceAll("\\", "/").trim();
408
+ if (!panelFileAllowed(normalized) || normalized.split("/").some((part) => part === "" || part === "..")) {
409
+ throw new Error("project file is outside the Panel read-only allowlist");
410
+ }
411
+ const target = await assertSafeProjectPath(root, normalized);
412
+ const stat = await lstat(target);
413
+ if (!stat.isFile() || stat.isSymbolicLink())
414
+ throw new Error("project file must be a regular file");
415
+ if (stat.size > 2 * 1024 * 1024)
416
+ throw new Error("project file is too large for Panel preview");
417
+ const format = normalized.toLowerCase().endsWith(".json") ? "json"
418
+ : normalized.toLowerCase().endsWith(".csv") ? "csv"
419
+ : /\.(md|markdown)$/i.test(normalized) ? "markdown" : "text";
420
+ return { path: normalized, content: await readFile(target, "utf8"), format };
421
+ }
390
422
  function publicInstallPlan(plan) {
391
423
  // `projectRoot` is an internal containment anchor. It is not needed by a
392
424
  // read-only browser and would disclose a local filesystem path.
393
425
  const { projectRoot: _projectRoot, ...publicFields } = plan;
394
426
  return publicFields;
395
427
  }
428
+ function panelSkillInstallTask(root, item, scope) {
429
+ const id = String(item.id ?? "");
430
+ const name = String(item.name ?? id);
431
+ const sourceRef = String(item.sourceRef ?? "");
432
+ const target = recommendedSkillTarget(root, id, scope);
433
+ return [
434
+ `安装推荐 Skill:${name} (${id})。`,
435
+ `来源网址:${sourceRef}`,
436
+ `安装位置:${scope === "user" ? "系统目录(所有项目)" : "项目目录(仅当前项目)"}。`,
437
+ `唯一允许的最终目标目录:${target}`,
438
+ "请使用当前会话的工具检查来源仓库并完成安装。不要写入其他 Skill 目录,不要修改 data/raw、.git 或研究产物。",
439
+ "目标目录最终必须直接包含有效 SKILL.md(YAML frontmatter 至少包含 name 和 description),不得包含 .git、符号链接、凭据或二进制大文件。",
440
+ "如果仓库包含多个 Skill,只安装与此推荐项相符的部分;如果它不是 Skill 或无法合理适配,停止并说明原因,不要伪造 SKILL.md。",
441
+ "安装完成后检查目标目录结构,并提醒用户执行 /skills 启用该项,再执行 /reload。",
442
+ ].join("\n");
443
+ }
396
444
  /** Metadata-only catalog for the optional panel. No install or credential read. */
397
445
  async function panelCatalog(root) {
398
446
  const scans = await discoverAgents();
@@ -532,7 +580,15 @@ async function recommendedSkills() {
532
580
  for (const path of candidates) {
533
581
  try {
534
582
  const catalog = JSON.parse(await readFile(path, "utf8"));
535
- return { ...catalog, items: (catalog.items ?? []).map((item) => ({ ...item, slashCommand: `/skills enable ${String(item.id ?? "")}`, installCommand: `/install skill ${String(item.id ?? "")}` })) };
583
+ return {
584
+ ...catalog,
585
+ items: (catalog.items ?? []).filter((item) => item.kind === "skill").map((item) => ({
586
+ ...item,
587
+ slashCommand: `/skills enable ${String(item.id ?? "")}`,
588
+ installCommand: `/install skill ${String(item.id ?? "")}`,
589
+ })),
590
+ externalTools: (catalog.externalTools ?? []).filter((item) => item.kind === "external-tool"),
591
+ };
536
592
  }
537
593
  catch { /* try package layout */ }
538
594
  }
@@ -701,7 +757,7 @@ export function createPanelServer(root, options = {}) {
701
757
  const url = new URL(request.url ?? "/", "http://localhost");
702
758
  // Read-only surface: any write method is rejected before routing.
703
759
  const runAction = /^\/api\/runs\/[A-Za-z0-9][A-Za-z0-9._-]{0,127}\/(pause|resume)$/.test(url.pathname);
704
- if (request.method !== "GET" && request.method !== "HEAD" && !(request.method === "POST" && (["/api/provider-config", "/api/hitl/decision", "/api/assistant", "/api/system-prompt", "/api/hooks", "/api/recommendation-state", "/api/install/execute", "/api/active-provider", "/api/artifact/save", "/api/manuscript", "/api/claim", "/api/evidence", "/api/doi/verify", "/api/documents/import", "/api/publish", "/api/references/verify", "/api/references/check", "/api/references/download", "/api/citations"].includes(url.pathname) || runAction))) {
760
+ if (request.method !== "GET" && request.method !== "HEAD" && !(request.method === "POST" && (["/api/provider-config", "/api/assistant", "/api/system-prompt", "/api/hooks", "/api/recommendation-state", "/api/install/execute", "/api/active-provider", "/api/artifact/save", "/api/manuscript", "/api/claim", "/api/evidence", "/api/doi/verify", "/api/documents/import", "/api/publish", "/api/references/verify", "/api/references/check", "/api/references/download", "/api/citations"].includes(url.pathname) || runAction))) {
705
761
  response.writeHead(405, { "content-type": "application/json", allow: "GET, HEAD" });
706
762
  response.end(JSON.stringify({ error: "method not allowed" }));
707
763
  return;
@@ -781,6 +837,22 @@ export function createPanelServer(root, options = {}) {
781
837
  response.end(JSON.stringify({ schemaVersion: "psyclaw/panel-files/v1", files: await listProjectFiles(root) }));
782
838
  return;
783
839
  }
840
+ if (url.pathname === "/api/project-files") {
841
+ response.writeHead(200, { "content-type": "application/json" });
842
+ response.end(JSON.stringify({ schemaVersion: "psyclaw/panel-project-files/v1", files: await panelProjectFiles(root) }));
843
+ return;
844
+ }
845
+ if (url.pathname === "/api/project-file") {
846
+ const relative = url.searchParams.get("path") ?? "";
847
+ response.writeHead(200, { "content-type": "application/json" });
848
+ response.end(JSON.stringify({ schemaVersion: "psyclaw/panel-project-file/v1", ...(await readPanelProjectFile(root, relative)) }));
849
+ return;
850
+ }
851
+ if (url.pathname === "/api/traces") {
852
+ response.writeHead(200, { "content-type": "application/json" });
853
+ response.end(JSON.stringify(await projectTraceSnapshot({ root })));
854
+ return;
855
+ }
784
856
  if (url.pathname === "/api/config") {
785
857
  const promptPath = await assertSafeProjectPath(root, ".psyclaw/system-prompt.md");
786
858
  let supplement = "";
@@ -817,6 +889,8 @@ export function createPanelServer(root, options = {}) {
817
889
  const state = await readRecommendationState(root);
818
890
  const disabled = await readDisabledCapabilities(root);
819
891
  const [skills, mcps] = await Promise.all([recommendedSkills(), recommendedMcps()]);
892
+ const skillIds = new Set((skills.items ?? []).map((item) => String(item.id ?? "")));
893
+ const mcpIds = new Set((mcps.items ?? []).map((item) => String(item.id ?? "")));
820
894
  const nameOf = (kind, id) => {
821
895
  const catalog = kind === "skill" ? skills : mcps;
822
896
  const item = (catalog.items ?? []).find((candidate) => candidate.id === id);
@@ -826,8 +900,8 @@ export function createPanelServer(root, options = {}) {
826
900
  response.end(JSON.stringify({
827
901
  schemaVersion: "psyclaw/enabled-capabilities/v1",
828
902
  coreSkills: CORE_SKILLS.map((skill) => ({ id: skill.id, name: skill.name, enabled: !disabled.coreSkills.includes(skill.id) })),
829
- skills: state.skills.map((id) => ({ id, name: nameOf("skill", id), enabled: true })),
830
- mcp: state.mcp.map((id) => ({ id, name: nameOf("mcp", id), enabled: true })),
903
+ skills: state.skills.filter((id) => skillIds.has(id)).map((id) => ({ id, name: nameOf("skill", id), enabled: true })),
904
+ mcp: state.mcp.filter((id) => mcpIds.has(id)).map((id) => ({ id, name: nameOf("mcp", id), enabled: true })),
831
905
  }));
832
906
  return;
833
907
  }
@@ -864,6 +938,7 @@ export function createPanelServer(root, options = {}) {
864
938
  const kind = body.kind === "skill" || body.kind === "mcp" ? body.kind : undefined;
865
939
  const id = String(body.id ?? "").trim();
866
940
  const approved = body.approved === true;
941
+ const scope = body.scope === "project" || body.scope === "user" ? body.scope : undefined;
867
942
  const actor = String(body.actor ?? "researcher").trim();
868
943
  if (!kind || !id || !approved || actor.length < 1)
869
944
  throw new Error("kind, id, approved and actor are required");
@@ -873,12 +948,37 @@ export function createPanelServer(root, options = {}) {
873
948
  response.end(JSON.stringify({ error: "unknown recommended item" }));
874
949
  return;
875
950
  }
876
- const command = typeof found.prep?.command === "string" && found.prep.command.trim() ? found.prep.command.trim() : undefined;
877
- if (!command) {
951
+ if (kind === "skill") {
952
+ if (!scope)
953
+ throw new Error("scope must be project or user for Skill installation");
954
+ if (options.installSkill === undefined) {
955
+ response.writeHead(503, { "content-type": "application/json" });
956
+ response.end(JSON.stringify({ error: "Skill 安装需要当前模型通道;请在 PsyClaw 对话中执行 /panel 后再安装。", reasonCode: "panel.skill-installer-unavailable" }));
957
+ return;
958
+ }
959
+ await options.installSkill(panelSkillInstallTask(root, found.item, scope));
960
+ const state = await readRecommendationState(root);
961
+ state.skillScopes = { ...(state.skillScopes ?? {}), [id]: scope };
962
+ await writeRecommendationState(root, state);
963
+ response.writeHead(202, { "content-type": "application/json" });
964
+ response.end(JSON.stringify({
965
+ schemaVersion: "psyclaw/model-install-task/v1",
966
+ ok: true,
967
+ queued: true,
968
+ id,
969
+ scope,
970
+ reloadHint: "/reload",
971
+ message: "安装任务已交给当前模型;模型完成后请启用 Skill 并执行 /reload。",
972
+ }));
973
+ return;
974
+ }
975
+ const shellCommand = typeof found.prep?.command === "string" && found.prep.command.trim() ? found.prep.command.trim() : undefined;
976
+ if (!shellCommand) {
878
977
  response.writeHead(400, { "content-type": "application/json" });
879
978
  response.end(JSON.stringify({ error: "no install command is available for this item", blockedReason: found.prep?.blockedReason ?? null }));
880
979
  return;
881
980
  }
981
+ const command = shellCommand;
882
982
  const startedAt = new Date().toISOString();
883
983
  const runId = `panel_${randomUUID().replaceAll("-", "")}`;
884
984
  const idempotencyKey = `panel:install:${sha256Text(`${kind}\u0000${id}\u0000${actor}`).slice(0, 24)}`;
@@ -894,7 +994,9 @@ export function createPanelServer(root, options = {}) {
894
994
  command,
895
995
  startedAt,
896
996
  };
897
- const { exitCode, output } = await runShellCommand(command, root);
997
+ let exitCode;
998
+ let output;
999
+ ({ exitCode, output } = await runShellCommand(command, root));
898
1000
  receipt.ok = exitCode === 0;
899
1001
  receipt.exitCode = exitCode;
900
1002
  receipt.finishedAt = new Date().toISOString();
@@ -902,8 +1004,7 @@ export function createPanelServer(root, options = {}) {
902
1004
  await atomicWriteFile(receiptPath, `${JSON.stringify(receipt, null, 2)}\n`);
903
1005
  if (receipt.ok) {
904
1006
  const state = await readRecommendationState(root);
905
- const key = kind === "skill" ? "skills" : "mcp";
906
- state[key] = [...new Set([...state[key], id])];
1007
+ state.mcp = [...new Set([...state.mcp, id])];
907
1008
  await writeRecommendationState(root, state);
908
1009
  }
909
1010
  await appendJsonlIfMissing(projectPaths(root).audit, {
@@ -1076,11 +1177,6 @@ export function createPanelServer(root, options = {}) {
1076
1177
  }
1077
1178
  return;
1078
1179
  }
1079
- if (url.pathname === "/api/hitl") {
1080
- response.writeHead(200, { "content-type": "application/json" });
1081
- response.end(JSON.stringify(await readHitlWorkspace(root, url.searchParams.get("contents") === "1")));
1082
- return;
1083
- }
1084
1180
  if (url.pathname === "/api/artifacts") {
1085
1181
  const indexPath = projectPaths(root).outputs + "/index.json";
1086
1182
  let indexedArtifacts = [];
@@ -1535,63 +1631,6 @@ export function createPanelServer(root, options = {}) {
1535
1631
  response.end(JSON.stringify({ schemaVersion: "psyclaw/provider-config-receipt/v1", ok: true, provider: id, modelCount: models.length, apiKeyStored: Boolean(apiKey?.trim()) }));
1536
1632
  return;
1537
1633
  }
1538
- if (url.pathname === "/api/hitl/decision") {
1539
- if (request.method !== "POST") {
1540
- response.writeHead(405, { "content-type": "application/json", allow: "POST" });
1541
- response.end(JSON.stringify({ error: "method not allowed" }));
1542
- return;
1543
- }
1544
- const body = await readJsonBody(request);
1545
- const decision = String(body.decision ?? "").trim();
1546
- const rationale = String(body.rationale ?? "").trim();
1547
- const actor = String(body.actor ?? "researcher").trim();
1548
- if (!["approved", "denied", "needs-changes"].includes(decision) || rationale.length < 3 || actor.length < 1) {
1549
- response.writeHead(400, { "content-type": "application/json" });
1550
- response.end(JSON.stringify({ error: "decision, rationale (3+ chars), and actor are required" }));
1551
- return;
1552
- }
1553
- const recordedAt = new Date().toISOString();
1554
- const idempotencyKey = `panel:hitl:${sha256Text(`${decision}\u0000${rationale}\u0000${actor}`).slice(0, 24)}`;
1555
- const decisionDocument = [
1556
- "---",
1557
- "schemaVersion: psyclaw/hitl-decision-request/v1",
1558
- "documentVersion: 1.0.0",
1559
- `recordedAt: ${recordedAt}`,
1560
- `actor: ${actor.replaceAll("\n", " ")}`,
1561
- `decision: ${decision}`,
1562
- `idempotencyKey: ${idempotencyKey}`,
1563
- "---",
1564
- "",
1565
- "# Decision Request",
1566
- "",
1567
- `Decision: ${decision}`,
1568
- "",
1569
- "## Rationale",
1570
- "",
1571
- rationale,
1572
- "",
1573
- ].join("\n");
1574
- const decisionPath = await assertSafeProjectPath(root, "notes/decision_request.md");
1575
- await atomicWriteFile(decisionPath, decisionDocument);
1576
- const receipt = {
1577
- schemaVersion: "psyclaw/tool-receipt/v1",
1578
- runId: `panel_${randomUUID().replaceAll("-", "")}`,
1579
- taskId: "hitl-decision",
1580
- tool: "panel.hitl.decision",
1581
- effect: "write",
1582
- approval: "approved",
1583
- idempotencyKey,
1584
- ok: true,
1585
- resultHash: sha256Text(decisionDocument),
1586
- startedAt: recordedAt,
1587
- finishedAt: new Date().toISOString(),
1588
- };
1589
- const receiptPath = await assertSafeProjectPath(root, `.psyclaw/manifests/${receipt.runId}.receipt.json`);
1590
- await atomicWriteFile(receiptPath, `${JSON.stringify(receipt, null, 2)}\n`);
1591
- response.writeHead(200, { "content-type": "application/json" });
1592
- response.end(JSON.stringify({ schemaVersion: "psyclaw/hitl-decision-receipt/v1", ok: true, decision, decisionPath, receiptPath, idempotencyKey }));
1593
- return;
1594
- }
1595
1634
  if (url.pathname === "/" || url.pathname === "/index.html") {
1596
1635
  const html = await readFile(panelHtmlPath, "utf8");
1597
1636
  response.writeHead(200, { "content-type": "text/html; charset=utf-8" });