research-copilot 0.2.20 → 0.2.21

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 (81) hide show
  1. package/README.md +1 -1
  2. package/app/out/main/index.mjs +2585 -48
  3. package/app/out/renderer/assets/{MilkdownMarkdownEditor-CCdZ2mtg.css → MilkdownMarkdownEditor-BW0Pt28W.css} +16 -1
  4. package/app/out/renderer/assets/{MilkdownMarkdownEditor-Bj7JSjF5.js → MilkdownMarkdownEditor-OhCrq3X0.js} +56 -51
  5. package/app/out/renderer/assets/{arc-CPL9nDFE.js → arc-DLr0RP8F.js} +1 -1
  6. package/app/out/renderer/assets/{blockDiagram-c4efeb88-BFOajDNs.js → blockDiagram-c4efeb88-XhKChw2n.js} +8 -8
  7. package/app/out/renderer/assets/{c4Diagram-c83219d4-LeqnQ2-5.js → c4Diagram-c83219d4-DDoJmoIQ.js} +3 -3
  8. package/app/out/renderer/assets/{channel-jk5Np8ud.js → channel-CJCgJSqV.js} +1 -1
  9. package/app/out/renderer/assets/{classDiagram-beda092f-CxOqB6OU.js → classDiagram-beda092f-CAmimZpz.js} +6 -6
  10. package/app/out/renderer/assets/{classDiagram-v2-2358418a-CyP_5qLa.js → classDiagram-v2-2358418a-Bma4E_Eg.js} +10 -10
  11. package/app/out/renderer/assets/{clone-PHFwh58n.js → clone-C338dmoI.js} +1 -1
  12. package/app/out/renderer/assets/{createText-1719965b-CE_0jsfj.js → createText-1719965b-_up4NJqB.js} +2 -2
  13. package/app/out/renderer/assets/{edges-96097737-DBk1JhZS.js → edges-96097737-Bpp6hVLn.js} +3 -3
  14. package/app/out/renderer/assets/{erDiagram-0228fc6a-DnR_LkSB.js → erDiagram-0228fc6a-bjTh_7ap.js} +5 -5
  15. package/app/out/renderer/assets/{flowDb-c6c81e3f-CJrZUKlS.js → flowDb-c6c81e3f-BjVV4DVk.js} +1 -1
  16. package/app/out/renderer/assets/{flowDiagram-50d868cf-CfNfrt17.js → flowDiagram-50d868cf-gmeaaZ6z.js} +12 -12
  17. package/app/out/renderer/assets/{flowDiagram-v2-4f6560a1-BGQtiK3j.js → flowDiagram-v2-4f6560a1-nem5zs2M.js} +12 -12
  18. package/app/out/renderer/assets/{flowchart-elk-definition-6af322e1-BXLraghz.js → flowchart-elk-definition-6af322e1-DPaGAYRw.js} +6 -6
  19. package/app/out/renderer/assets/{ganttDiagram-a2739b55-CAwaEMMm.js → ganttDiagram-a2739b55-CnAti19E.js} +3 -3
  20. package/app/out/renderer/assets/{gitGraphDiagram-82fe8481-vuSEC6ny.js → gitGraphDiagram-82fe8481-DQWHD3SJ.js} +2 -2
  21. package/app/out/renderer/assets/{graph-CZfltE7S.js → graph-DKiKgH8m.js} +1 -1
  22. package/app/out/renderer/assets/{index-DIZJXKQ6.js → index-4s-c5d65.js} +3 -3
  23. package/app/out/renderer/assets/{index-5325376f-DWTrHDEo.js → index-5325376f-G-0aO-2i.js} +6 -6
  24. package/app/out/renderer/assets/{index-CwPfquqm.js → index-9q_P5ULR.js} +4 -4
  25. package/app/out/renderer/assets/{index-EaGZvaBp.js → index-B1A3JxQj.js} +3 -3
  26. package/app/out/renderer/assets/{index-C2tqvXjC.js → index-BBUrmGmY.js} +6 -6
  27. package/app/out/renderer/assets/{index-D_7yOLk3.js → index-BQho5LH-.js} +6 -6
  28. package/app/out/renderer/assets/{index-B6f2bVW_.js → index-BUVlmsgO.js} +3 -3
  29. package/app/out/renderer/assets/{index-DpXI4mHb.js → index-BzEthrJ4.js} +3 -3
  30. package/app/out/renderer/assets/{index-CUsEKU8Q.js → index-C1YzkB4z.js} +93 -36
  31. package/app/out/renderer/assets/{index-CMfKxpBP.js → index-CGo665vD.js} +3 -3
  32. package/app/out/renderer/assets/{index-B5Mkpo9f.js → index-CPZaxR35.js} +3 -3
  33. package/app/out/renderer/assets/{index-BpdWQuss.js → index-CSyD1mbL.js} +3 -3
  34. package/app/out/renderer/assets/{index-DB8ImtMy.js → index-Cf7vlFSn.js} +3 -3
  35. package/app/out/renderer/assets/{index-CyDfvefg.js → index-CluH1o2q.js} +6 -6
  36. package/app/out/renderer/assets/{index-7dcVwInU.js → index-Cw1n3klA.js} +5 -5
  37. package/app/out/renderer/assets/{index-Ul-Kq9b2.js → index-DFzvntIw.js} +3 -3
  38. package/app/out/renderer/assets/{index-t0-md-MG.js → index-DHzyAhWM.js} +4 -4
  39. package/app/out/renderer/assets/{index-Cc9coKGN.js → index-DhliHfCM.js} +6 -6
  40. package/app/out/renderer/assets/{index-K0o5fHYG.js → index-DkVFbCxC.js} +3 -3
  41. package/app/out/renderer/assets/{index-DiCqe1UR.js → index-DpZJP5MT.js} +6 -6
  42. package/app/out/renderer/assets/{index-CaYWMBXT.js → index-Gfd_DiMG.js} +3 -3
  43. package/app/out/renderer/assets/{index-Di3HmXc-.js → index-jOvNAYyP.js} +3 -3
  44. package/app/out/renderer/assets/{index-B4V7cFWJ.js → index-rrJkk8KV.js} +6 -6
  45. package/app/out/renderer/assets/{index-BgAs-p8D.js → index-vfSerSmF.js} +1 -1
  46. package/app/out/renderer/assets/{infoDiagram-8eee0895-BmPESCfj.js → infoDiagram-8eee0895-BCnBkXXS.js} +2 -2
  47. package/app/out/renderer/assets/{journeyDiagram-c64418c1-BGsCbfr_.js → journeyDiagram-c64418c1-Bq2wSX3k.js} +4 -4
  48. package/app/out/renderer/assets/{layout-5MwFTPs7.js → layout-BvkumzoT.js} +2 -2
  49. package/app/out/renderer/assets/{line-D0U74KO0.js → line-eU4el-G4.js} +1 -1
  50. package/app/out/renderer/assets/{linear-BclyBoiT.js → linear-DlBjMBEa.js} +1 -1
  51. package/app/out/renderer/assets/{mindmap-definition-8da855dc-un1bPKBj.js → mindmap-definition-8da855dc-CzLBu7ao.js} +3 -3
  52. package/app/out/renderer/assets/{pieDiagram-a8764435-B7KM3duv.js → pieDiagram-a8764435--olrXFr_.js} +3 -3
  53. package/app/out/renderer/assets/{quadrantDiagram-1e28029f-C8i5m3Os.js → quadrantDiagram-1e28029f-BnpnBBgc.js} +3 -3
  54. package/app/out/renderer/assets/{requirementDiagram-08caed73-FjqENNN5.js → requirementDiagram-08caed73-6O9WS7hn.js} +5 -5
  55. package/app/out/renderer/assets/{sankeyDiagram-a04cb91d-BKV22yuJ.js → sankeyDiagram-a04cb91d-D-iJnK91.js} +2 -2
  56. package/app/out/renderer/assets/{sequenceDiagram-c5b8d532-DWO-Z2i3.js → sequenceDiagram-c5b8d532-DBlK15cV.js} +3 -3
  57. package/app/out/renderer/assets/{stateDiagram-1ecb1508-BqohgALA.js → stateDiagram-1ecb1508-DKXKPYuk.js} +6 -6
  58. package/app/out/renderer/assets/{stateDiagram-v2-c2b004d7-B3sEkrB8.js → stateDiagram-v2-c2b004d7-DY288Eo5.js} +10 -10
  59. package/app/out/renderer/assets/{styles-b4e223ce-BGytHk8n.js → styles-b4e223ce-CRJ_xgJ-.js} +1 -1
  60. package/app/out/renderer/assets/{styles-ca3715f6-B0PvBknL.js → styles-ca3715f6-Bp_k5KLD.js} +1 -1
  61. package/app/out/renderer/assets/{styles-d45a18b0-C6F384ai.js → styles-d45a18b0-DLA8Gg6D.js} +4 -4
  62. package/app/out/renderer/assets/{svgDrawCommon-b86b1483-BXgThwM_.js → svgDrawCommon-b86b1483-Dm5CK2gQ.js} +1 -1
  63. package/app/out/renderer/assets/{timeline-definition-faaaa080-iNn5igPR.js → timeline-definition-faaaa080-D-m9BHUg.js} +3 -3
  64. package/app/out/renderer/assets/{xychartDiagram-f5964ef8-oF_gxlk1.js → xychartDiagram-f5964ef8-Drn4Rqev.js} +5 -5
  65. package/app/out/renderer/index.html +1 -1
  66. package/lib/skills/builtin/academic-marp-slides/SKILL.md +933 -0
  67. package/lib/skills/builtin/research-grants/SKILL.md +15 -11
  68. package/lib/skills/builtin/scholar-evaluation/SKILL.md +12 -11
  69. package/lib/skills/builtin/scientific-schematics/SKILL.md +463 -560
  70. package/lib/skills/builtin/teaching-marp-slides/SKILL.md +1218 -0
  71. package/package.json +1 -1
  72. package/scripts/audit-diagram-prompts.mjs +67 -0
  73. package/scripts/test-skill-routing.mjs +238 -0
  74. package/lib/skills/builtin/marp-slides/SKILL.md +0 -642
  75. package/lib/skills/builtin/scientific-schematics/references/QUICK_REFERENCE.md +0 -182
  76. package/lib/skills/builtin/scientific-schematics/references/README.md +0 -292
  77. package/lib/skills/builtin/scientific-schematics/scripts/__pycache__/generate_schematic.cpython-312.pyc +0 -0
  78. package/lib/skills/builtin/scientific-schematics/scripts/__pycache__/generate_schematic_ai.cpython-312.pyc +0 -0
  79. package/lib/skills/builtin/scientific-schematics/scripts/example_usage.sh +0 -85
  80. package/lib/skills/builtin/scientific-schematics/scripts/generate_schematic.py +0 -141
  81. package/lib/skills/builtin/scientific-schematics/scripts/generate_schematic_ai.py +0 -910
@@ -1,10 +1,10 @@
1
- import { app, shell, ipcMain, BrowserWindow, dialog, protocol, Menu } from "electron";
1
+ import { app, shell, BrowserWindow, ipcMain, dialog, protocol, Menu } from "electron";
2
2
  import { setMaxListeners } from "node:events";
3
3
  import fs, { existsSync as existsSync$1 } from "node:fs";
4
4
  import fsp, { readFile } from "node:fs/promises";
5
5
  import { execFile, spawn, execSync } from "node:child_process";
6
6
  import path, { resolve, join, sep, isAbsolute, basename, extname, dirname, relative } from "path";
7
- import { existsSync, statSync, readdirSync as readdirSync$1, readFileSync, cpSync, writeFileSync, mkdirSync, appendFileSync, rmSync, unlinkSync, renameSync, openSync, constants, writeSync, closeSync, watch } from "fs";
7
+ import { existsSync, statSync, readdirSync, readFileSync, cpSync, writeFileSync, mkdirSync, appendFileSync, rmSync, unlinkSync, renameSync, openSync, constants, writeSync, closeSync, watch } from "fs";
8
8
  import os$1, { homedir } from "os";
9
9
  import { createHash, randomUUID } from "crypto";
10
10
  import { Agent } from "@mariozechner/pi-agent-core";
@@ -15,6 +15,7 @@ import { mkdir, writeFile } from "fs/promises";
15
15
  import path$1 from "node:path";
16
16
  import crypto$1, { createHash as createHash$1 } from "node:crypto";
17
17
  import { promisify } from "node:util";
18
+ import { Blob } from "node:buffer";
18
19
  import os from "node:os";
19
20
  import { fileURLToPath } from "node:url";
20
21
  import { execFile as execFile$1, execSync as execSync$1 } from "child_process";
@@ -90,7 +91,7 @@ function isIgnored(relativePath, isDirectory, rules, showIgnored) {
90
91
  }
91
92
  function hasVisibleChildren(dirPath, relativePath, rules, showIgnored) {
92
93
  try {
93
- const entries = readdirSync$1(dirPath, { withFileTypes: true });
94
+ const entries = readdirSync(dirPath, { withFileTypes: true });
94
95
  for (const entry of entries) {
95
96
  const childRelative = relativePath ? `${relativePath}/${entry.name}` : entry.name;
96
97
  if (!isIgnored(childRelative, entry.isDirectory(), rules, showIgnored)) {
@@ -107,7 +108,7 @@ function listTreeChildren(rootPath, relativePath = "", showIgnored = false, limi
107
108
  if (!isWithinRoot(rootPath, basePath)) return [];
108
109
  if (!existsSync(basePath) || !statSync(basePath).isDirectory()) return [];
109
110
  const rules = readGitIgnoreRules(rootPath);
110
- const entries = readdirSync$1(basePath, { withFileTypes: true });
111
+ const entries = readdirSync(basePath, { withFileTypes: true });
111
112
  const out = [];
112
113
  entries.sort((a, b) => {
113
114
  if (a.isDirectory() !== b.isDirectory()) return a.isDirectory() ? -1 : 1;
@@ -145,7 +146,7 @@ function searchTree(rootPath, query, showIgnored = false, maxResults = 200) {
145
146
  const node = stack.pop();
146
147
  let entries = [];
147
148
  try {
148
- entries = readdirSync$1(node.absPath, { withFileTypes: true });
149
+ entries = readdirSync(node.absPath, { withFileTypes: true });
149
150
  } catch {
150
151
  continue;
151
152
  }
@@ -180,7 +181,8 @@ function searchTree(rootPath, query, showIgnored = false, maxResults = 200) {
180
181
  const DEFAULT_SETTINGS = {
181
182
  research: { researchIntensity: "medium", webSearchDepth: "standard", autoSaveSensitivity: "balanced" },
182
183
  dataAnalysis: { executionTimeLimit: "standard" },
183
- wikiAgent: { model: "none", speed: "medium" }
184
+ wikiAgent: { model: "none", speed: "medium" },
185
+ diagram: { reviewProvider: "auto" }
184
186
  };
185
187
  const CONFIG_DIR = join(homedir(), ".research-copilot");
186
188
  const CONFIG_FILE = join(CONFIG_DIR, "config.json");
@@ -247,7 +249,8 @@ function loadSettingsFromConfig() {
247
249
  return {
248
250
  research: { ...DEFAULT_SETTINGS.research, ...config.settings.research },
249
251
  dataAnalysis: { ...DEFAULT_SETTINGS.dataAnalysis, ...config.settings.dataAnalysis },
250
- wikiAgent: { ...DEFAULT_SETTINGS.wikiAgent, ...config.settings.wikiAgent }
252
+ wikiAgent: { ...DEFAULT_SETTINGS.wikiAgent, ...config.settings.wikiAgent },
253
+ diagram: { ...DEFAULT_SETTINGS.diagram, ...config.settings.diagram }
251
254
  };
252
255
  }
253
256
  function hasLlmAuth() {
@@ -421,7 +424,7 @@ function registerFileHandlers(handle, getCtx) {
421
424
  const { projectPath } = getCtx();
422
425
  if (!projectPath) return [];
423
426
  try {
424
- const entries = readdirSync$1(projectPath);
427
+ const entries = readdirSync(projectPath);
425
428
  const files = [];
426
429
  for (const entry of entries) {
427
430
  if (entry.startsWith(".")) continue;
@@ -813,7 +816,7 @@ function registerAuthHandlers(handleRaw) {
813
816
  if (!creds) return { success: false, error: "Not logged in" };
814
817
  try {
815
818
  const { refreshOpenAICodexToken } = await import("@mariozechner/pi-ai/oauth");
816
- const newCreds = await refreshOpenAICodexToken(creds);
819
+ const newCreds = await refreshOpenAICodexToken(creds.refresh);
817
820
  saveCodexCredentials(newCreds);
818
821
  return { success: true };
819
822
  } catch (err) {
@@ -971,7 +974,7 @@ const PATHS = {
971
974
  const _warnedReaddirPaths = /* @__PURE__ */ new Set();
972
975
  function safeReaddir(dir) {
973
976
  try {
974
- return readdirSync$1(dir);
977
+ return readdirSync(dir);
975
978
  } catch (err) {
976
979
  const code = err?.code;
977
980
  if (code === "EPERM" || code === "ENOENT" || code === "ENOTDIR" || code === "EACCES") {
@@ -1720,7 +1723,7 @@ function listMemoryFiles(projectPath) {
1720
1723
  const dir = memoryDir(projectPath);
1721
1724
  if (!existsSync(dir)) return [];
1722
1725
  try {
1723
- const files = readdirSync$1(dir).filter((f) => f.endsWith(".md")).sort();
1726
+ const files = readdirSync(dir).filter((f) => f.endsWith(".md")).sort();
1724
1727
  const entries = [];
1725
1728
  for (const filename of files) {
1726
1729
  const entry = readMemoryFile(projectPath, filename);
@@ -2495,6 +2498,7 @@ Hard rules:
2495
2498
  - General web facts → brave_web_search or fetch.
2496
2499
  - If a required paper PDF/full text cannot be retrieved (paywall/auth/access blocked), do NOT infer missing content. Ask user to provide/upload the file and continue only after file is available.
2497
2500
  - Any data analysis / visualization / statistics → data-analyze (do not analyze raw data with read/grep).
2501
+ - Any scientific diagram / schematic / flowchart / architecture / pathway / circuit → generate_diagram (it runs a provider-backed generate → review → edit loop; do not draft SVG or ASCII diagrams by hand). If the scientific-schematics skill is loaded, use its guidance to shape the prompt before calling the tool.
2498
2502
  - For reusable methodology, writing scaffolding, or plot/style templates, check if a relevant skill summary is already pre-loaded below. If so, follow it; call load_skill(name) for full procedures when needed. You can also browse the Skills Catalog and load any skill on demand.
2499
2503
  - For repository/text inspection, use this order by default:
2500
2504
  1) glob/grep to locate relevant files/sections;
@@ -4583,6 +4587,2401 @@ function createDataAnalyzeTool(ctx) {
4583
4587
  }
4584
4588
  };
4585
4589
  }
4590
+ const DEFAULT_HOUSE_PROFILE = {
4591
+ id: "editorial-institutional-v1",
4592
+ themeNarrative: [
4593
+ "Editorial scientific figure with a fixed institutional identity.",
4594
+ "Off-white or warm-light background, graphite text and strokes, restrained contrast, disciplined whitespace.",
4595
+ 'No generic "AI" aesthetic (no glow, no particles, no gradient backgrounds).',
4596
+ "No startup-whitepaper look (no flat-design pastels, no rounded-pill overloads).",
4597
+ "Every figure in this system should look like it belongs to the same visual family across papers, slides, and reports."
4598
+ ].join(" "),
4599
+ typography: {
4600
+ hierarchy: {
4601
+ sectionLabel: "700 13px uppercase tracked",
4602
+ nodeLabel: "600 12px",
4603
+ edgeAnnotation: "500 10px",
4604
+ footLabel: "400 10px italic"
4605
+ },
4606
+ fontStack: ["Inter", "SF Pro Text", "Helvetica Neue", "Arial", "sans-serif"],
4607
+ voice: "Compact, disciplined, editorial. Hierarchy is fixed. No oversized headings, no micro-labels under 10px."
4608
+ },
4609
+ palette: [
4610
+ { role: "text", value: "#1F2937", description: "Graphite text and primary strokes." },
4611
+ { role: "stroke", value: "#1F2937", description: "Same graphite for structural outlines." },
4612
+ { role: "primaryStructure", value: "#2C5282", description: "Primary structural entities — main nodes, canonical path." },
4613
+ { role: "secondaryStructure", value: "#718096", description: "Secondary structural entities — supporting nodes, context." },
4614
+ { role: "contextFill", value: "#F7FAFC", description: "Off-white background fill for grouping containers." },
4615
+ { role: "resultAccent", value: "#2B6CB0", description: "Deep editorial blue, used sparingly on result/output elements." },
4616
+ { role: "warningAccent", value: "#C53030", description: "Deep editorial red, used only for warnings or peak-state emphasis." },
4617
+ { role: "grid", value: "#E2E8F0", description: "Soft graphite grid or axis line." }
4618
+ ],
4619
+ geometry: {
4620
+ strokeWidth: { primary: "1.5px", secondary: "1px" },
4621
+ cornerRadius: "8px",
4622
+ arrowheadStyle: "Filled triangle, width 8px, extending 9px along the stroke — never hollow arrows, never emoji.",
4623
+ groupContainerStyle: "Dashed 1px secondary-structure border with contextFill background; no solid coloured group panels.",
4624
+ outerMargin: "5% of the viewBox on every side",
4625
+ minGutter: "32px between adjacent boxes",
4626
+ boxHeightSteps: ["48px", "72px", "96px"]
4627
+ },
4628
+ motifs: [
4629
+ "Labels sit above or to the left of the element they describe; never inside node boxes unless the box is a labelled region.",
4630
+ "Arrow stroke matches its source node colour.",
4631
+ "Consistent gutter rhythm across panels in multi-panel figures.",
4632
+ "Section labels (panel headers, region titles) always use the same section-label typography token.",
4633
+ "No more than two accent colours visible in a single figure."
4634
+ ],
4635
+ avoid: [
4636
+ "Gradients, glow, blur, or filter effects of any kind",
4637
+ "Rounded-pill buttons, soft-shadow cards, or other app-UI tropes",
4638
+ "Neon or highly saturated colours",
4639
+ "Oversized emoji, cartoon icons, or mascot-style illustrations",
4640
+ "Display fonts, script fonts, or hand-lettering styles"
4641
+ ]
4642
+ };
4643
+ function renderPalette(tokens) {
4644
+ const lines = [
4645
+ "Semantic role → colour mapping (house palette). Use these tokens as defaults; a user prompt may override any specific role."
4646
+ ];
4647
+ for (const t of tokens) {
4648
+ lines.push(`- ${t.role}: ${t.value} — ${t.description}`);
4649
+ }
4650
+ lines.push("All category distinctions must remain readable in grayscale AND under colour-vision deficiency; fall back to an accessible substitute only if a house palette choice fails that test.");
4651
+ return lines.join("\n");
4652
+ }
4653
+ function renderGeometry(g) {
4654
+ return [
4655
+ "Geometry language (house tokens — reuse across every diagram in the system):",
4656
+ `- Stroke widths: primary ${g.strokeWidth.primary}, secondary ${g.strokeWidth.secondary}.`,
4657
+ `- Corner radius: ${g.cornerRadius} for all rounded rectangles.`,
4658
+ `- Arrowheads: ${g.arrowheadStyle}`,
4659
+ `- Group container: ${g.groupContainerStyle}`,
4660
+ `- Outer margin: ${g.outerMargin}. Min gutter between elements: ${g.minGutter}.`,
4661
+ `- Node box heights: choose one of ${g.boxHeightSteps.join(" / ")} and keep it consistent within a panel.`
4662
+ ].join("\n");
4663
+ }
4664
+ function renderTypography(t) {
4665
+ return [
4666
+ `Typographic voice: ${t.voice}`,
4667
+ `Font stack (first installed wins): ${t.fontStack.join(", ")}.`,
4668
+ `Hierarchy tokens (use these exact sizes/weights):`,
4669
+ `- Section label: ${t.hierarchy.sectionLabel}`,
4670
+ `- Node label: ${t.hierarchy.nodeLabel}`,
4671
+ `- Edge annotation: ${t.hierarchy.edgeAnnotation}`,
4672
+ `- Foot label: ${t.hierarchy.footLabel}`
4673
+ ].join("\n");
4674
+ }
4675
+ function renderMotifs(motifs) {
4676
+ if (motifs.length === 0) return "";
4677
+ return [
4678
+ 'Recurring motifs (these are the "signature" details reviewers should see every time):',
4679
+ ...motifs.map((m) => `- ${m}`)
4680
+ ].join("\n");
4681
+ }
4682
+ function renderProfile(profile = DEFAULT_HOUSE_PROFILE) {
4683
+ return {
4684
+ theme: profile.themeNarrative,
4685
+ typography: renderTypography(profile.typography),
4686
+ palette: renderPalette(profile.palette),
4687
+ geometry: renderGeometry(profile.geometry),
4688
+ motifs: renderMotifs(profile.motifs),
4689
+ avoid: profile.avoid,
4690
+ summaryForReviewer: [
4691
+ `House profile: ${profile.id}.`,
4692
+ `Theme: ${profile.themeNarrative}`,
4693
+ `Palette roles: ${profile.palette.map((p) => `${p.role}=${p.value}`).join(", ")}.`,
4694
+ `Geometry: stroke ${profile.geometry.strokeWidth.primary}/${profile.geometry.strokeWidth.secondary}, corner ${profile.geometry.cornerRadius}, gutter ${profile.geometry.minGutter}.`,
4695
+ `Typography: ${profile.typography.voice} Stack: ${profile.typography.fontStack[0]}.`
4696
+ ].join(" ")
4697
+ };
4698
+ }
4699
+ const DESIGN_PRINCIPLES = `【DESIGN PRINCIPLES】 Apply all nine. These are non-negotiable:
4700
+
4701
+ 1. SINGLE PRIMARY AXIS — Pick one dominant spatial axis (left-to-right
4702
+ flow, top-to-bottom flow, or a time axis) and make every other
4703
+ element serve it. Never mix two competing primary axes.
4704
+
4705
+ 2. NO NESTED SOLID-BORDERED BOXES — Group elements using:
4706
+ • a light background tint (fill only, no stroke)
4707
+ • a dashed border (for "optional / future / sketch" categories)
4708
+ • whitespace and alignment
4709
+ • a small header label positioned just above the group
4710
+ NEVER place a solid-bordered rect inside another solid-bordered rect.
4711
+ Small backfilled pills (fill only, no stroke) INSIDE a bordered rect
4712
+ are fine — one level of visual nesting, not two.
4713
+
4714
+ 3. TYPOGRAPHY BY CATEGORY — Match font size, weight, and style to the
4715
+ element's CATEGORY, not to its perceived "importance". Consistent
4716
+ scales:
4717
+ • section header: 14 pt bold, Title Case ("Monitoring Pipeline")
4718
+ NEVER ALL CAPS ("MONITORING PIPELINE") — ALL
4719
+ CAPS is reserved for the red "!" error prefix
4720
+ only. Section headers in caps look like
4721
+ PowerPoint slides, not publication figures.
4722
+ • node title: 12-13 pt semibold or bold
4723
+ • sub-op label: 10 pt regular
4724
+ • caption: 9-10 pt italic
4725
+ • footnote: 9 pt regular, muted grey
4726
+ Never vary font size within the same category.
4727
+
4728
+ 4. SEMANTIC COLOUR + COMPACT LEGEND — Every colour must mean something.
4729
+ If you introduce a colour, explain it in a small (≤ 15% of figure
4730
+ area) legend placed in a quiet corner. Typical semantic roles:
4731
+ • navy blue = current / in-scope / active
4732
+ • forest green = output / result
4733
+ • dashed muted amber = planned / future / optional
4734
+ • saturated red = error / problem / rejection ONLY
4735
+
4736
+ 5. REAL CONTENT, NOT PLACEHOLDERS — Boxes must contain real labels,
4737
+ real parameters, real units. "Filter" alone is weak; "Filter —
4738
+ threshold 5°, deadband, clipping" tells the reader what the box
4739
+ actually does. Prefer specifics (100 Hz, int16, 2.4 MB/s) over
4740
+ generic descriptors ("fast", "large", "Module A").
4741
+
4742
+ 6. ICONS REPLACE TEXT, NEVER DECORATE — Only draw an icon if it
4743
+ replaces a short phrase more clearly than text would. Wavy line =
4744
+ signal. Monitor glyph = display. Alert bell = notification.
4745
+ Never add icons for decoration.
4746
+
4747
+ 7. ARROWS ARE ANCHORED, LABELS RIDE THE LINE, MULTI-BEND = ONE POLYLINE
4748
+ • Every arrow must start and end precisely on a box edge (use
4749
+ rect.x, rect.y, rect.x+width, or rect.y+height).
4750
+ • Edge labels sit ON the arrow, not floating near it. Use a small
4751
+ opaque backfill rect behind the label so the line is masked.
4752
+ • L-shape, Z-shape, or any multi-bend arrow MUST be ONE
4753
+ <polyline points="x1,y1 x2,y2 x3,y3 ..."> element with all
4754
+ vertices in a single declaration. NEVER stitch together
4755
+ multiple <line> elements to approximate an L-shape — they
4756
+ render as visually disconnected segments.
4757
+
4758
+ 8. RED IS RESERVED FOR PROBLEMS — Saturated red (≈#C53030) appears
4759
+ only on error / loss / rejection callouts. Muted reds (rust,
4760
+ terracotta) may be used for categorical "sketch / proposal"
4761
+ borders, but high-contrast red must stay precious.
4762
+
4763
+ 9. COMPACT LAYOUT — Let content density drive spacing, not the
4764
+ viewBox. Typical gaps:
4765
+ • adjacent elements in the same group: 10–30 px
4766
+ • distinct sections (e.g. "Planned" vs current work): 40–60 px
4767
+ • group backdrop padding (inside tint): 15–25 px
4768
+ NEVER exceed ~80 px of uninterrupted empty space unless the gap
4769
+ is semantically meaningful. If the user-provided viewBox is
4770
+ larger than your content needs, SHRINK THE VIEWBOX — do NOT
4771
+ stretch or spread content to fill the canvas. "Below X" means a
4772
+ 20–30 px gap below X, NOT "somewhere at the bottom of the
4773
+ canvas". A figure with visible gutters of empty space looks
4774
+ unfinished regardless of how clean the content itself is.`;
4775
+ const POSITIVE_EXAMPLE_SVG = `<svg viewBox="0 0 600 440" xmlns="http://www.w3.org/2000/svg">
4776
+ <defs>
4777
+ <marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="9" markerHeight="9" orient="auto">
4778
+ <path d="M0,0 L10,5 L0,10 Z" fill="#1F2937"/>
4779
+ </marker>
4780
+ <marker id="arrowBlue" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="9" markerHeight="9" orient="auto">
4781
+ <path d="M0,0 L10,5 L0,10 Z" fill="#2C5282"/>
4782
+ </marker>
4783
+ <marker id="arrowAmber" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="9" markerHeight="9" orient="auto">
4784
+ <path d="M0,0 L10,5 L0,10 Z" fill="#9C5C00"/>
4785
+ </marker>
4786
+ <marker id="arrowRed" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="9" markerHeight="9" orient="auto">
4787
+ <path d="M0,0 L10,5 L0,10 Z" fill="#C53030"/>
4788
+ </marker>
4789
+ </defs>
4790
+
4791
+ <rect x="0" y="0" width="600" height="440" fill="#F7FAFC"/>
4792
+
4793
+ <!-- Legend: compact, top-right. Four categories. -->
4794
+ <rect x="480" y="12" width="110" height="85" rx="4" ry="4" fill="#FFFFFF" stroke="#CBD5E0" stroke-width="1"/>
4795
+ <rect x="492" y="24" width="16" height="10" rx="2" ry="2" fill="#FFFFFF" stroke="#2C5282" stroke-width="1.5"/>
4796
+ <text x="516" y="33" font-family="Inter, sans-serif" font-size="10" fill="#1F2937">current stage</text>
4797
+ <rect x="492" y="42" width="16" height="10" rx="2" ry="2" fill="#FFFFFF" stroke="#2F855A" stroke-width="1.5"/>
4798
+ <text x="516" y="51" font-family="Inter, sans-serif" font-size="10" fill="#1F2937">output</text>
4799
+ <rect x="492" y="60" width="16" height="10" rx="2" ry="2" fill="none" stroke="#9C5C00" stroke-width="1.2" stroke-dasharray="3 2"/>
4800
+ <text x="516" y="69" font-family="Inter, sans-serif" font-size="10" fill="#1F2937">planned</text>
4801
+ <text x="494" y="84" font-family="Inter, sans-serif" font-size="11" font-weight="700" fill="#C53030">!</text>
4802
+ <text x="516" y="84" font-family="Inter, sans-serif" font-size="10" fill="#1F2937">error / loss</text>
4803
+
4804
+ <!-- Section header: Title Case, bold. Above the group. -->
4805
+ <text x="15" y="35" font-family="Inter, sans-serif" font-size="14" font-weight="700" fill="#1F2937">Monitoring Pipeline</text>
4806
+
4807
+ <!-- Pipeline backdrop: light tint, no border — groups Parse/Filter/Aggregate. -->
4808
+ <rect x="155" y="40" width="325" height="170" rx="10" ry="10" fill="#EDF2F7" stroke="none"/>
4809
+
4810
+ <!-- Input: Sensor Stream. Box widened to 130 so all four text lines fit. -->
4811
+ <rect x="15" y="55" width="130" height="140" rx="6" ry="6" fill="#FFFFFF" stroke="#4A5568" stroke-width="1.5"/>
4812
+ <path d="M22,85 q3,-5 6,0 t6,0 t6,0" fill="none" stroke="#2C5282" stroke-width="1.7" stroke-linecap="round"/>
4813
+ <text x="45" y="89" font-family="Inter, sans-serif" font-size="11" font-weight="600" fill="#1F2937">Sensor Stream</text>
4814
+ <text x="25" y="112" font-family="Inter, sans-serif" font-size="9" font-style="italic" fill="#4A5568">raw telemetry • 100 Hz</text>
4815
+ <text x="25" y="132" font-family="Inter, sans-serif" font-size="10" fill="#1F2937">12 channels • int16</text>
4816
+ <text x="25" y="152" font-family="Inter, sans-serif" font-size="9" fill="#4A5568">throughput: 2.4 MB/s</text>
4817
+
4818
+ <line x1="145" y1="125" x2="160" y2="125" stroke="#1F2937" stroke-width="1.5" marker-end="url(#arrow)"/>
4819
+
4820
+ <!-- Parse: title + divider + three sub-op pills (backfill only, no stroke). -->
4821
+ <rect x="160" y="55" width="95" height="140" rx="6" ry="6" fill="#FFFFFF" stroke="#2C5282" stroke-width="1.5"/>
4822
+ <text x="207" y="78" font-family="Inter, sans-serif" font-size="12" font-weight="700" fill="#1F2937" text-anchor="middle">Parse</text>
4823
+ <line x1="170" y1="85" x2="245" y2="85" stroke="#2C5282" stroke-width="0.5" opacity="0.5"/>
4824
+ <rect x="170" y="93" width="75" height="18" rx="3" ry="3" fill="#E6F0FA"/>
4825
+ <text x="175" y="105" font-family="Inter, sans-serif" font-size="10" fill="#1F2937">schema v2</text>
4826
+ <rect x="170" y="117" width="75" height="18" rx="3" ry="3" fill="#E6F0FA"/>
4827
+ <text x="175" y="129" font-family="Inter, sans-serif" font-size="10" fill="#1F2937">decode int16</text>
4828
+ <rect x="170" y="141" width="75" height="18" rx="3" ry="3" fill="#E6F0FA"/>
4829
+ <text x="175" y="153" font-family="Inter, sans-serif" font-size="10" fill="#1F2937">timestamp</text>
4830
+
4831
+ <line x1="255" y1="125" x2="270" y2="125" stroke="#2C5282" stroke-width="1.5" marker-end="url(#arrowBlue)"/>
4832
+
4833
+ <!-- Filter -->
4834
+ <rect x="270" y="55" width="95" height="140" rx="6" ry="6" fill="#FFFFFF" stroke="#2C5282" stroke-width="1.5"/>
4835
+ <text x="317" y="78" font-family="Inter, sans-serif" font-size="12" font-weight="700" fill="#1F2937" text-anchor="middle">Filter</text>
4836
+ <line x1="280" y1="85" x2="355" y2="85" stroke="#2C5282" stroke-width="0.5" opacity="0.5"/>
4837
+ <rect x="280" y="93" width="75" height="18" rx="3" ry="3" fill="#E6F0FA"/>
4838
+ <text x="285" y="105" font-family="Inter, sans-serif" font-size="10" fill="#1F2937">threshold 5°</text>
4839
+ <rect x="280" y="117" width="75" height="18" rx="3" ry="3" fill="#E6F0FA"/>
4840
+ <text x="285" y="129" font-family="Inter, sans-serif" font-size="10" fill="#1F2937">deadband</text>
4841
+ <rect x="280" y="141" width="75" height="18" rx="3" ry="3" fill="#E6F0FA"/>
4842
+ <text x="285" y="153" font-family="Inter, sans-serif" font-size="10" fill="#1F2937">clipping</text>
4843
+
4844
+ <!-- RED error callout — saturated red reserved for problems only. -->
4845
+ <line x1="317" y1="195" x2="317" y2="213" stroke="#C53030" stroke-width="1.2" stroke-dasharray="3 2" marker-end="url(#arrowRed)"/>
4846
+ <rect x="275" y="215" width="85" height="22" rx="3" ry="3" fill="#FED7D7" stroke="#C53030" stroke-width="1"/>
4847
+ <text x="283" y="231" font-family="Inter, sans-serif" font-size="11" font-weight="700" fill="#C53030">!</text>
4848
+ <text x="295" y="231" font-family="Inter, sans-serif" font-size="10" font-weight="500" fill="#C53030">invalid: 3%</text>
4849
+
4850
+ <line x1="365" y1="125" x2="380" y2="125" stroke="#2C5282" stroke-width="1.5" marker-end="url(#arrowBlue)"/>
4851
+
4852
+ <!-- Aggregate -->
4853
+ <rect x="380" y="55" width="95" height="140" rx="6" ry="6" fill="#FFFFFF" stroke="#2C5282" stroke-width="1.5"/>
4854
+ <text x="427" y="78" font-family="Inter, sans-serif" font-size="12" font-weight="700" fill="#1F2937" text-anchor="middle">Aggregate</text>
4855
+ <line x1="390" y1="85" x2="465" y2="85" stroke="#2C5282" stroke-width="0.5" opacity="0.5"/>
4856
+ <rect x="390" y="93" width="75" height="18" rx="3" ry="3" fill="#E6F0FA"/>
4857
+ <text x="395" y="105" font-family="Inter, sans-serif" font-size="10" fill="#1F2937">1-min window</text>
4858
+ <rect x="390" y="117" width="75" height="18" rx="3" ry="3" fill="#E6F0FA"/>
4859
+ <text x="395" y="129" font-family="Inter, sans-serif" font-size="10" fill="#1F2937">mean/max/p99</text>
4860
+ <rect x="390" y="141" width="75" height="18" rx="3" ry="3" fill="#E6F0FA"/>
4861
+ <text x="395" y="153" font-family="Inter, sans-serif" font-size="10" fill="#1F2937">change rate</text>
4862
+
4863
+ <!-- Two arrows diverge from Aggregate bottom, offset horizontally so
4864
+ they don't leave the same pixel. Amber L-shape drops BELOW the
4865
+ red error callout (y=245, callout ends at y=237) to avoid overlap. -->
4866
+ <line x1="447" y1="195" x2="447" y2="252" stroke="#1F2937" stroke-width="1.5" marker-end="url(#arrow)"/>
4867
+ <polyline points="415,195 415,245 160,245 160,252" fill="none" stroke="#9C5C00" stroke-width="1.2" stroke-dasharray="4 3" marker-end="url(#arrowAmber)"/>
4868
+
4869
+ <!-- Planned extension: Title Case header above the dashed-amber box. -->
4870
+ <text x="20" y="250" font-family="Inter, sans-serif" font-size="12" font-weight="700" fill="#9C5C00">Planned</text>
4871
+ <rect x="15" y="255" width="290" height="100" rx="6" ry="6" fill="none" stroke="#9C5C00" stroke-width="1.5" stroke-dasharray="6 5"/>
4872
+ <text x="160" y="277" font-family="Inter, sans-serif" font-size="12" font-weight="600" fill="#1F2937" text-anchor="middle">Anomaly Detection</text>
4873
+ <rect x="30" y="290" width="125" height="20" rx="3" ry="3" fill="#FEF3E6"/>
4874
+ <text x="37" y="304" font-family="Inter, sans-serif" font-size="10" fill="#1F2937">Autoencoder baseline</text>
4875
+ <rect x="165" y="290" width="125" height="20" rx="3" ry="3" fill="#FEF3E6"/>
4876
+ <text x="172" y="304" font-family="Inter, sans-serif" font-size="10" fill="#1F2937">Adaptive threshold</text>
4877
+ <text x="30" y="335" font-family="Inter, sans-serif" font-size="9" font-style="italic" fill="#4A5568">Q3 rollout • replaces static threshold filter</text>
4878
+
4879
+ <!-- Dashboard: different colour family (green) signals OUTPUT category. -->
4880
+ <rect x="310" y="255" width="275" height="120" rx="6" ry="6" fill="#FFFFFF" stroke="#2F855A" stroke-width="1.5"/>
4881
+ <rect x="325" y="272" width="22" height="15" rx="2" ry="2" fill="none" stroke="#2F855A" stroke-width="1.3"/>
4882
+ <line x1="331" y1="291" x2="341" y2="291" stroke="#2F855A" stroke-width="1.3"/>
4883
+ <text x="355" y="285" font-family="Inter, sans-serif" font-size="13" font-weight="700" fill="#1F2937">Dashboard</text>
4884
+ <line x1="325" y1="299" x2="570" y2="299" stroke="#2F855A" stroke-width="0.5" opacity="0.5"/>
4885
+ <text x="325" y="316" font-family="Inter, sans-serif" font-size="10" fill="#1F2937">• live charts (5-sec refresh)</text>
4886
+ <text x="325" y="335" font-family="Inter, sans-serif" font-size="10" fill="#1F2937">• threshold alerts → email / SMS</text>
4887
+ <text x="325" y="354" font-family="Inter, sans-serif" font-size="10" fill="#1F2937">• historical query via Grafana</text>
4888
+ </svg>`;
4889
+ const POSITIVE_EXAMPLE_BLOCK = `【POSITIVE EXAMPLE】 Study and emulate the design language of this
4890
+ reference SVG. It is a yardstick for "what a publication-grade systems
4891
+ diagram looks like" — NOT a template to copy literally.
4892
+
4893
+ ${POSITIVE_EXAMPLE_SVG}
4894
+
4895
+ NOTES ON THE REFERENCE (each illustrates a specific principle):
4896
+ • Viewbox 600:440 ≈ 1.36:1 — single-column-friendly, not strongly
4897
+ landscape. Figures for a two-column paper template should usually
4898
+ stay near 1:1, 10:8, or 12:10. (Principle 1)
4899
+ • The three pipeline stages are grouped by a tinted backdrop
4900
+ (fill="#EDF2F7", stroke="none"), NOT by nesting another bordered
4901
+ rect. (Principle 2)
4902
+ • Each stage has title + a subtle divider line + three sub-op pills
4903
+ (fill only, no stroke). Pills INSIDE a bordered rect are fine
4904
+ because pills are backfills, not nested rects. (Principle 2)
4905
+ • Section header "Monitoring Pipeline" is Title Case, not ALL CAPS.
4906
+ The only ALL CAPS in the figure is the red "!" error prefix,
4907
+ where the caps-lock is deliberately emphatic. (Principles 3, 8)
4908
+ • Dashboard uses a DIFFERENT colour family (green, #2F855A) from
4909
+ pipeline stages (navy, #2C5282). The legend explains the
4910
+ categorical distinction. (Principle 4)
4911
+ • Each box contains real, specific content: "threshold 5°",
4912
+ "decode int16", "1-min window", "change rate", "2.4 MB/s". No
4913
+ generic "Module A / Stage 1" placeholders. (Principle 5)
4914
+ • The wavy-signal icon (~~) replaces the phrase "signal" for Sensor
4915
+ Stream; the monitor glyph replaces "display" for Dashboard.
4916
+ Nothing else is iconified. (Principle 6)
4917
+ • Every arrow terminates on a box edge. The "100 Hz" style edge
4918
+ labels (not present here, but the technique is in GEOMETRIC
4919
+ DISCIPLINE) sit ON the arrow with a small opaque backfill rect.
4920
+ (Principle 7)
4921
+ • Saturated red (#C53030) appears ONLY in the "! invalid: 3%"
4922
+ error callout. Muted amber is used for the planned category.
4923
+ (Principle 8)
4924
+ • Two arrows diverge from Aggregate's bottom edge at different x
4925
+ (447 and 415) so they do not leave the same pixel point. The
4926
+ amber L-shape polyline routes BELOW (y=245) the red error callout
4927
+ (which ends at y=237) rather than through it — no arrows cross
4928
+ unrelated boxes. (Principle 7 + GEOMETRIC DISCIPLINE)`;
4929
+ const GEOMETRIC_DISCIPLINE = `【GEOMETRIC DISCIPLINE】 Apply these pixel-level rules for all SVG:
4930
+
4931
+ TEXT CENTERING INSIDE A RECT
4932
+ • Vertical center: text.y = rect.y + rect.height/2 + font_size*0.35
4933
+ • Horizontal center: set text-anchor="middle" and
4934
+ text.x = rect.x + rect.width/2
4935
+ • Two-line text inside a rect:
4936
+ line 1 baseline y = rect.cy - 4
4937
+ line 2 baseline y = rect.cy + font_size + 2
4938
+
4939
+ TEXT POSITIONED ABOVE A RECT (as a title)
4940
+ • text.y must be ≤ rect.y - 4 (glyphs extend upward from baseline)
4941
+ • NEVER use text.y = rect.y + 3 style positioning — the title will
4942
+ cross through the rect's top stroke.
4943
+
4944
+ TEXT WIDTH ESTIMATION (Inter font family, approximate):
4945
+ • regular: char_count × font_size × 0.55
4946
+ • semibold/bold: char_count × font_size × 0.58
4947
+ • italic: char_count × font_size × 0.52
4948
+ A rect that contains text must satisfy:
4949
+ rect.width ≥ text_width_estimate + 16 (8 px padding each side)
4950
+ If the text overflows: SHORTEN the text OR SHRINK the font —
4951
+ NEVER let glyphs bleed past the rect's right stroke.
4952
+
4953
+ ARROWS — SOURCE AND DESTINATION ANCHORING
4954
+ • Arrow endpoint (x2, y2) must fall precisely on the destination
4955
+ rect's edge (use rect.x, rect.y, rect.x+width, or rect.y+height).
4956
+ • marker-end with orient="auto" and refX near the marker's tip
4957
+ places the arrow-head tip AT the line endpoint.
4958
+
4959
+ ARROWS CROSSING OTHER BOXES — three priorities, first wins:
4960
+ 1. ROUTE AROUND. Shift the arrow's y or x so it never overlaps an
4961
+ unrelated box. This is always preferred.
4962
+ 2. USE AN L-SHAPED POLYLINE. A right-angle bend avoids the
4963
+ obstructing box cleanly. Diagonal arrows that clip through
4964
+ other boxes are forbidden.
4965
+ 3. Z-ORDER. If an arrow MUST cross a box, declare the arrow's
4966
+ <line> or <polyline> BEFORE the box in SVG source, so the
4967
+ box paints over the arrow (arrow renders behind the box).
4968
+ This is the last-resort fix.
4969
+
4970
+ MULTI-SEGMENT ARROWS (L-shape, Z-shape, any multi-bend)
4971
+ An arrow with one or more bends MUST be a SINGLE <polyline>
4972
+ element with ALL vertices in one declaration:
4973
+ CORRECT:
4974
+ <polyline points="100,50 100,80 250,80"
4975
+ fill="none" stroke="#1F2937" stroke-width="1.5"
4976
+ marker-end="url(#arrow)"/>
4977
+ WRONG (produces visually disconnected segments):
4978
+ <line x1="100" y1="50" x2="100" y2="80" .../>
4979
+ <line x1="100" y1="80" x2="250" y2="80" .../>
4980
+ Even when the endpoints line up arithmetically, SVG renders
4981
+ consecutive <line> elements as independent strokes with no
4982
+ shared corner — the result is a broken-looking path. Always
4983
+ use <polyline points="..."> for multi-bend arrows.
4984
+
4985
+ MULTIPLE ARROWS FROM ONE BOX
4986
+ When a single box emits more than one outgoing arrow, offset the
4987
+ starting x (or y) by 20–30 px so the arrows diverge at the box's
4988
+ edge. Two arrows leaving the exact same pixel and then splaying
4989
+ outward looks chaotic.
4990
+
4991
+ EDGE LABELS ON ARROWS
4992
+ Draw a small opaque rect (fill = page background color) BEFORE the
4993
+ text, covering the arrow line at the label's y-midpoint. Then draw
4994
+ the text on top. This masks the line behind the label so the
4995
+ label is readable without floating off the arrow.`;
4996
+ const COMMON_MISTAKES = `【COMMON MISTAKES — DO NOT】
4997
+
4998
+ ✗ text.y = rect.y + 3 or text.y = rect.y + font_size*0.2 inside a
4999
+ rect → glyphs cross the rect's top stroke, creating a
5000
+ strikethrough effect. Use the centering formula from GEOMETRIC
5001
+ DISCIPLINE.
5002
+
5003
+ ✗ rect.width narrower than the text's estimated width → glyphs
5004
+ spill past the rect's right stroke. Either widen the rect or
5005
+ shorten / shrink the text.
5006
+
5007
+ ✗ solid-bordered rect inside another solid-bordered rect → visual
5008
+ noise, the "box in box in box" pathology. Use tinted backdrops or
5009
+ dashed borders for grouping instead.
5010
+
5011
+ ✗ a label floating 10–20 px away from its arrow → the reader cannot
5012
+ tell which arrow the label annotates. Place the label ON the
5013
+ arrow with a small opaque bg rect behind the text.
5014
+
5015
+ ✗ diagonal arrows that clip through unrelated boxes → use
5016
+ L-shaped polylines (right-angle bends) to route around instead.
5017
+
5018
+ ✗ two arrows leaving the same pixel of a box and then diverging →
5019
+ offset the starting x or y by 20–30 px so the arrows separate
5020
+ cleanly at the box edge.
5021
+
5022
+ ✗ saturated red used for a non-error element (a section header in
5023
+ red, a decorative border in red) → dilutes the semantic weight
5024
+ of red. Reserve saturated red for errors, losses, rejections.
5025
+
5026
+ ✗ ALL CAPS for regular section headers → dilutes emphasis. Title
5027
+ Case for normal headers; ALL CAPS only where semantic emphasis
5028
+ is intended (red error tags).
5029
+
5030
+ ✗ generic placeholder text: "stage 1", "Component A", "Module" →
5031
+ makes the figure feel empty. Use real domain content ("Filter —
5032
+ threshold 5°", "100 Hz samples", "int16 decode").
5033
+
5034
+ ✗ figures that are strongly landscape (ratio > 1.6:1) for a paper
5035
+ where the template is two-column → the figure will either span
5036
+ both columns (wasteful) or be shrunk into unreadability. Prefer
5037
+ near-square or gently landscape (1:1 to 12:8) for single-column
5038
+ placement.
5039
+
5040
+ ✗ large uninterrupted empty regions (> 80 px in any direction) in
5041
+ the viewBox. Whether you shrink the viewBox or tighten the
5042
+ spacing, a figure with visible gutters looks unfinished. If the
5043
+ request says "Planned section below Layer 1", that's a 20–30 px
5044
+ gap below Layer 1, NOT an empty band in the lower half of the
5045
+ canvas.
5046
+
5047
+ ✗ multi-bend arrow drawn as several <line> elements chained end-
5048
+ to-end → renders as visually disconnected segments. Use a single
5049
+ <polyline points="x1,y1 x2,y2 x3,y3 ..."> element instead.
5050
+
5051
+ ✗ edge label placed on an arrow whose path crosses through a
5052
+ tinted group backdrop → the backdrop, drawn last, paints over
5053
+ the label. Either route the label to lie entirely outside any
5054
+ backdrop, or draw the label AFTER the backdrop in SVG source.`;
5055
+ const DIAGRAM_TYPE_DETAILS = {
5056
+ flowchart: `Standard shapes: rectangle = process, diamond = decision, rounded rectangle = start/end. Arrows must point unambiguously from source to target. Every numeric count (n=…) and every branch label (yes/no, conditions) is MANDATORY when mentioned in the subject. Prefer vertical top-to-bottom flow unless specified.`,
5057
+ architecture: `Layered or blocked composition; each component is a labelled box. Show data flow direction with arrows; annotate protocols or interfaces on edges. Group related components (shared background tint or dashed boundary). Consistent box sizes within a layer.`,
5058
+ pathway: `Every molecule/gene/protein labelled with its exact symbol. Arrows distinguish activation (→) from inhibition (⊣ or ⊥). Preserve the exact order and directionality specified. Oval = molecule, rectangle = complex, rounded = process. Include cellular compartments (membrane, nucleus) when relevant.`,
5059
+ circuit: `Standard electronic symbols (IEEE/IEC): resistor zigzag, capacitor parallel lines, ground triangle, op-amp triangle with + and − inputs labelled. Component values carry units ("1kΩ", "10µF", "5V"). Wires: dot at crossing = connection, no dot = jump-over. Ground and supply rails explicitly marked.`,
5060
+ network: `Nodes are labelled entities; edges are relationships. Consistent node sizes unless hierarchy is intentional. For neural networks, label layer types and dimensions ("Dense 128", "Conv 3×3"). For hierarchies, root at top.`,
5061
+ // Previously this said "light graphical flourishes (icons, gradients)
5062
+ // are acceptable if they serve comprehension". That conflicted with
5063
+ // the global no-gradients rule. Conceptual diagrams now follow the
5064
+ // same geometry/colour discipline as the rest — identity is more
5065
+ // valuable than flourish.
5066
+ conceptual: `Focus on clarity. Group related ideas; use the house palette roles to communicate categories. Geometric shapes only — no decorative icons, no photographic inserts, no illustrations.`
5067
+ };
5068
+ const COMPOSITION_HINTS = {
5069
+ flowchart: "Top-to-bottom vertical flow unless the subject specifies otherwise. Generous gutters between stages.",
5070
+ architecture: "Left-to-right or layered horizontal composition. Align boxes to a common grid.",
5071
+ pathway: "Directional cascade — usually top-to-bottom for signalling, left-to-right for metabolic. Group molecules by compartment.",
5072
+ circuit: "Standard schematic layout: input on the left, output on the right, ground at the bottom.",
5073
+ network: "For neural nets, encoder-left/decoder-right or input-at-top. For trees, root-up.",
5074
+ conceptual: "Choose a composition (flow / radial / Venn / concentric) that makes the relationship obvious, then keep it regular."
5075
+ };
5076
+ const VERBATIM_TEXT_RULE = `Render every quoted string EXACTLY as given — verbatim, character for character. Do not paraphrase, auto-correct, translate, or substitute. This includes counts like "n=350", identifiers like "EGFR", units like "1kΩ", colour hex codes like "#2B6CB0", and any symbols. If the request contains unfamiliar words or abbreviations, keep them as written.`;
5077
+ const UNIVERSAL_AVOID = [
5078
+ 'Figure numbers or titles ("Figure 1:", "Fig 1.", etc.) anywhere in the image',
5079
+ "Captions or header bars that repeat information the slide/paper will add around the figure",
5080
+ "Photorealistic photographs, hand-drawn sketches, 3D shading, bevel/emboss/drop-shadow effects",
5081
+ 'Watermarks, logos, signatures, or stylistic "AI" ornaments'
5082
+ ];
5083
+ function extractLiterals(prompt) {
5084
+ const found = /* @__PURE__ */ new Set();
5085
+ for (const m of prompt.matchAll(/"([^"]+)"|'([^']+)'/g)) {
5086
+ const value = (m[1] ?? m[2] ?? "").trim();
5087
+ if (value) found.add(`"${value}"`);
5088
+ }
5089
+ for (const m of prompt.matchAll(/\bn\s*=\s*([\d,]+)/gi)) {
5090
+ found.add(`n=${m[1].replace(/,/g, "")}`);
5091
+ }
5092
+ for (const m of prompt.matchAll(/\b\d+(?:\.\d+)?\s*(?:kΩ|Ω|µF|uF|mF|nF|pF|kHz|MHz|GHz|Hz|mV|V|mA|A|pt|px|nm|µm|mm|cm|m|ns|µs|ms|s|nodes|layers)\b/gi)) {
5093
+ found.add(m[0].trim());
5094
+ }
5095
+ for (const m of prompt.matchAll(/#[0-9a-fA-F]{3}(?:[0-9a-fA-F]{3}(?:[0-9a-fA-F]{2})?)?\b/g)) {
5096
+ found.add(m[0]);
5097
+ }
5098
+ for (const m of prompt.matchAll(/\b(?:rgba?|hsla?)\([^)]+\)/gi)) {
5099
+ found.add(m[0]);
5100
+ }
5101
+ return Array.from(found);
5102
+ }
5103
+ function resolveDiagramType(type) {
5104
+ return type === "auto" ? "conceptual" : type;
5105
+ }
5106
+ function buildBrief(s) {
5107
+ const lines = [];
5108
+ const svgPath = s.format === "svg";
5109
+ const includeGuide = !s.skipDesignGuide;
5110
+ lines.push(`【SCENE / USE】 ${s.sceneUse}`);
5111
+ lines.push(`【SUBJECT】 ${s.subject}`);
5112
+ if (includeGuide) {
5113
+ lines.push("");
5114
+ lines.push(DESIGN_PRINCIPLES);
5115
+ if (svgPath) {
5116
+ lines.push("");
5117
+ lines.push(POSITIVE_EXAMPLE_BLOCK);
5118
+ }
5119
+ }
5120
+ lines.push("");
5121
+ lines.push(`【COMPOSITION】 ${s.composition}`);
5122
+ lines.push(`【KEY DETAILS】`);
5123
+ lines.push(s.keyDetails);
5124
+ if (includeGuide && svgPath) {
5125
+ lines.push("");
5126
+ lines.push(GEOMETRIC_DISCIPLINE);
5127
+ }
5128
+ if (s.motifs) {
5129
+ lines.push(`【MOTIFS】`);
5130
+ lines.push(s.motifs);
5131
+ }
5132
+ lines.push(`【TEXT】 ${s.textRules}`);
5133
+ if (s.mustKeep.length > 0) {
5134
+ lines.push(`【MUST KEEP】`);
5135
+ for (const item of s.mustKeep) lines.push(`- ${item}`);
5136
+ }
5137
+ if (includeGuide) {
5138
+ lines.push("");
5139
+ lines.push(COMMON_MISTAKES);
5140
+ }
5141
+ lines.push(`【AVOID】`);
5142
+ for (const item of s.avoid) lines.push(`- ${item}`);
5143
+ return lines.join("\n");
5144
+ }
5145
+ function buildKeyDetails(diagramTypeRules, rendered) {
5146
+ return [
5147
+ diagramTypeRules,
5148
+ "",
5149
+ "Typography",
5150
+ rendered.typography,
5151
+ "",
5152
+ "Colour palette",
5153
+ rendered.palette,
5154
+ "",
5155
+ "Geometry",
5156
+ rendered.geometry
5157
+ ].join("\n");
5158
+ }
5159
+ function buildAvoid(profile) {
5160
+ return [...UNIVERSAL_AVOID, ...profile.avoid];
5161
+ }
5162
+ function composeGenerationPrompt(userPrompt, diagramType, format, profile = DEFAULT_HOUSE_PROFILE) {
5163
+ const dt = resolveDiagramType(diagramType);
5164
+ const rendered = renderProfile(profile);
5165
+ const literals = extractLiterals(userPrompt);
5166
+ return buildBrief({
5167
+ sceneUse: `A publication-grade scientific ${dt} diagram in the house visual system. ${rendered.theme}`,
5168
+ subject: userPrompt,
5169
+ composition: COMPOSITION_HINTS[dt],
5170
+ keyDetails: buildKeyDetails(DIAGRAM_TYPE_DETAILS[dt], rendered),
5171
+ motifs: rendered.motifs,
5172
+ textRules: VERBATIM_TEXT_RULE,
5173
+ mustKeep: literals,
5174
+ avoid: buildAvoid(profile),
5175
+ format
5176
+ });
5177
+ }
5178
+ function composeEditPrompt(userPrompt, diagramType, issues, preservedFixes = [], profile = DEFAULT_HOUSE_PROFILE) {
5179
+ const dt = resolveDiagramType(diagramType);
5180
+ const rendered = renderProfile(profile);
5181
+ const literals = extractLiterals(userPrompt);
5182
+ const fixes = issues.length > 0 ? issues.map((i, idx) => `${idx + 1}. [${i.kind}] ${i.fix}`).join("\n") : "Tighten the details that needed tightening; keep the rest unchanged.";
5183
+ const keyDetailLines = [
5184
+ `TARGETED FIXES (in priority order):`,
5185
+ fixes,
5186
+ `Apply ONLY these fixes. Every other element — including existing labels, arrows, boxes, whitespace, colours, and typography — must be preserved exactly as in the attached image.`
5187
+ ];
5188
+ if (preservedFixes.length > 0) {
5189
+ const preservedLines = preservedFixes.map((p, idx) => `${idx + 1}. [${p.kind}] ${p.description} — already resolved; keep it that way`).join("\n");
5190
+ keyDetailLines.push(
5191
+ `ALREADY-RESOLVED ITEMS (DO NOT regress these — they were broken in a previous draft and fixed):`,
5192
+ preservedLines
5193
+ );
5194
+ }
5195
+ keyDetailLines.push(
5196
+ `Type-specific rules still apply: ${DIAGRAM_TYPE_DETAILS[dt]}`,
5197
+ "",
5198
+ `House-style tokens (unchanged from the original brief — do not drift from these):`,
5199
+ rendered.typography,
5200
+ "",
5201
+ rendered.palette,
5202
+ "",
5203
+ rendered.geometry
5204
+ );
5205
+ return buildBrief({
5206
+ sceneUse: `Surgical revision of an existing ${dt} diagram in the house visual system.`,
5207
+ subject: userPrompt,
5208
+ composition: `Keep the existing layout, sizing, colour palette, and element positions UNCHANGED unless a specific fix below requires otherwise. Do not redraw from scratch.`,
5209
+ keyDetails: keyDetailLines.join("\n"),
5210
+ motifs: rendered.motifs,
5211
+ textRules: VERBATIM_TEXT_RULE,
5212
+ mustKeep: literals,
5213
+ avoid: [
5214
+ ...buildAvoid(profile),
5215
+ "Any change to the overall composition, aspect ratio, or background",
5216
+ "Any change to elements that were already correct",
5217
+ "Regressing any item listed in ALREADY-RESOLVED ITEMS",
5218
+ "Drifting away from the house-style tokens (typography, palette, geometry) listed above"
5219
+ ],
5220
+ // Edit is surgical — the caller has an already-composed draft. Injecting
5221
+ // global design principles here would invite the model to "improve"
5222
+ // elements that were intentionally left alone. Suppress the guide.
5223
+ skipDesignGuide: true
5224
+ });
5225
+ }
5226
+ function composeRegenPrompt(userPrompt, diagramType, issues, format, profile = DEFAULT_HOUSE_PROFILE) {
5227
+ const base = composeGenerationPrompt(userPrompt, diagramType, format, profile);
5228
+ if (issues.length === 0) return base;
5229
+ const negatives = issues.map((i) => `- ${i.description} (fix: ${i.fix})`).join("\n");
5230
+ return `${base}
5231
+
5232
+ 【PREVIOUS ATTEMPT HAD THESE PROBLEMS — DO NOT REPEAT】
5233
+ ${negatives}`;
5234
+ }
5235
+ function composeStyleOnlyPrompt(userPrompt, diagramType, format, profile = DEFAULT_HOUSE_PROFILE) {
5236
+ const base = composeGenerationPrompt(userPrompt, diagramType, format, profile);
5237
+ return [
5238
+ "The attached image is provided as a STYLE REFERENCE ONLY.",
5239
+ "Match its visual idiom — colour palette, line weights, typography, corner radii, arrow style, spacing rhythm.",
5240
+ "Do NOT copy its layout, content, panel structure, or composition.",
5241
+ "Design a new diagram from scratch for the request below, expressed in that style.",
5242
+ "",
5243
+ base
5244
+ ].join("\n");
5245
+ }
5246
+ function composeSurgicalRevisionPrompt(userPrompt, diagramType) {
5247
+ const dt = resolveDiagramType(diagramType);
5248
+ const literals = extractLiterals(userPrompt);
5249
+ const mustKeep = literals.length > 0 ? `
5250
+
5251
+ 【MUST KEEP — verbatim from the request】
5252
+ ${literals.map((l) => `- ${l}`).join("\n")}` : "";
5253
+ return [
5254
+ "【MODE】 SURGICAL REVISION of an existing SVG.",
5255
+ "",
5256
+ `The SVG source is provided separately. It is a ${dt} diagram already in the house visual system.`,
5257
+ "Apply ONLY the changes described in the REQUEST below. Preserve",
5258
+ "every other element exactly — layout, sizing, colours, typography,",
5259
+ "arrow routing, group boundaries, viewBox, background. Do NOT redraw",
5260
+ 'from scratch. Do NOT "improve" anything the user did not mention.',
5261
+ "Do NOT introduce new design principles, legends, or colours that",
5262
+ "the existing SVG does not already use.",
5263
+ "",
5264
+ "If a requested change appears to conflict with something already in",
5265
+ "the SVG, honour the REQUEST — the user knows what they asked for.",
5266
+ "If a change is ambiguous, make the smallest edit that satisfies it.",
5267
+ "",
5268
+ "【REQUEST — apply only these changes】",
5269
+ userPrompt,
5270
+ mustKeep
5271
+ ].join("\n");
5272
+ }
5273
+ function detectDiagramType(prompt) {
5274
+ const p = prompt.toLowerCase();
5275
+ const match = (keywords) => keywords.some((k) => p.includes(k));
5276
+ if (match(["consort", "prisma", "flowchart", "flow chart", "flow diagram", "swimlane", "decision tree"])) return "flowchart";
5277
+ if (match(["architecture", "system diagram", "microservice", "pipeline", "data flow", "block diagram"])) return "architecture";
5278
+ if (match(["pathway", "signaling", "signalling", "cascade", "mapk", "egfr", "phosphoryl", "receptor", "kinase"])) return "pathway";
5279
+ if (match(["circuit", "resistor", "capacitor", "op-amp", "opamp", "transistor", "voltage", "schematic circuit"])) return "circuit";
5280
+ if (match(["neural network", "transformer", "cnn", "rnn", "lstm", "attention", "encoder-decoder", "graph", "tree", "hierarchy"])) return "network";
5281
+ return "conceptual";
5282
+ }
5283
+ const GENERATIONS_URL = "https://api.openai.com/v1/images/generations";
5284
+ const EDITS_URL = "https://api.openai.com/v1/images/edits";
5285
+ const DEFAULT_MODEL$2 = "gpt-image-2";
5286
+ const DEFAULT_SIZE = "auto";
5287
+ const REQUEST_TIMEOUT_MS$2 = 3e5;
5288
+ function extractBytes(response) {
5289
+ const choice = response.data?.[0];
5290
+ if (!choice) throw new Error("OpenAI image response had no choices");
5291
+ if (choice.b64_json) {
5292
+ return Buffer.from(choice.b64_json, "base64");
5293
+ }
5294
+ if (choice.url) {
5295
+ throw new Error(
5296
+ "OpenAI returned an image URL instead of base64 bytes. The selected model may be dall-e-*; this backend targets gpt-image-2 which returns b64_json by default."
5297
+ );
5298
+ }
5299
+ throw new Error("OpenAI image response did not contain image data");
5300
+ }
5301
+ async function postJson(url, apiKey, body) {
5302
+ const ctl = new AbortController();
5303
+ const timer = setTimeout(() => ctl.abort(), REQUEST_TIMEOUT_MS$2);
5304
+ try {
5305
+ const res = await fetch(url, {
5306
+ method: "POST",
5307
+ headers: {
5308
+ "Authorization": `Bearer ${apiKey}`,
5309
+ "Content-Type": "application/json"
5310
+ },
5311
+ body: JSON.stringify(body),
5312
+ signal: ctl.signal
5313
+ });
5314
+ const json = await res.json();
5315
+ if (!res.ok) {
5316
+ const msg = json.error?.message || `HTTP ${res.status}`;
5317
+ throw new Error(`OpenAI image API error: ${msg}`);
5318
+ }
5319
+ return json;
5320
+ } finally {
5321
+ clearTimeout(timer);
5322
+ }
5323
+ }
5324
+ async function postMultipart(url, apiKey, fields, files) {
5325
+ const form = new FormData();
5326
+ for (const [k, v] of Object.entries(fields)) {
5327
+ form.append(k, v);
5328
+ }
5329
+ for (const [k, f] of Object.entries(files)) {
5330
+ const view = new Uint8Array(f.data.buffer, f.data.byteOffset, f.data.byteLength);
5331
+ form.append(k, new Blob([view], { type: f.contentType }), f.filename);
5332
+ }
5333
+ const ctl = new AbortController();
5334
+ const timer = setTimeout(() => ctl.abort(), REQUEST_TIMEOUT_MS$2);
5335
+ try {
5336
+ const res = await fetch(url, {
5337
+ method: "POST",
5338
+ headers: { "Authorization": `Bearer ${apiKey}` },
5339
+ body: form,
5340
+ signal: ctl.signal
5341
+ });
5342
+ const json = await res.json();
5343
+ if (!res.ok) {
5344
+ const msg = json.error?.message || `HTTP ${res.status}`;
5345
+ throw new Error(`OpenAI image edit API error: ${msg}`);
5346
+ }
5347
+ return json;
5348
+ } finally {
5349
+ clearTimeout(timer);
5350
+ }
5351
+ }
5352
+ function createOpenAIImageProvider(opts = {}) {
5353
+ const apiKey = opts.apiKey ?? process.env.OPENAI_API_KEY?.trim();
5354
+ if (!apiKey) {
5355
+ throw new Error(
5356
+ "OPENAI_API_KEY is required for diagram generation. Add it under Settings → API Keys, or set OPENAI_API_KEY in your shell."
5357
+ );
5358
+ }
5359
+ const model = opts.model || DEFAULT_MODEL$2;
5360
+ const size = opts.size || DEFAULT_SIZE;
5361
+ const defaultQuality = opts.quality || "auto";
5362
+ const capabilities = /* @__PURE__ */ new Set(["text_to_image", "image_to_image"]);
5363
+ const effectiveQuality = (callOpts) => callOpts?.quality ?? defaultQuality;
5364
+ return {
5365
+ id: `openai:${model}`,
5366
+ label: `OpenAI ${model}`,
5367
+ capabilities,
5368
+ async textToImage(prompt, options) {
5369
+ const body = {
5370
+ model,
5371
+ prompt,
5372
+ size,
5373
+ n: 1,
5374
+ quality: effectiveQuality(options)
5375
+ };
5376
+ const response = await postJson(GENERATIONS_URL, apiKey, body);
5377
+ return extractBytes(response);
5378
+ },
5379
+ async imageToImage(prompt, image, options) {
5380
+ const response = await postMultipart(
5381
+ EDITS_URL,
5382
+ apiKey,
5383
+ { model, prompt, size, n: "1", quality: effectiveQuality(options) },
5384
+ { image: { data: image, filename: "image.png", contentType: "image/png" } }
5385
+ );
5386
+ return extractBytes(response);
5387
+ }
5388
+ };
5389
+ }
5390
+ const CHAT_URL = "https://api.openai.com/v1/chat/completions";
5391
+ const DEFAULT_MODEL$1 = "gpt-4o";
5392
+ const REQUEST_TIMEOUT_MS$1 = 18e4;
5393
+ const OPENAI_THRESHOLDS = {
5394
+ journal: 8.3,
5395
+ conference: 7.8,
5396
+ thesis: 7.8,
5397
+ grant: 7.8,
5398
+ preprint: 7.3,
5399
+ report: 7.3,
5400
+ poster: 6.8,
5401
+ presentation: 6.3,
5402
+ default: 7.3
5403
+ };
5404
+ const REVIEW_SYSTEM$1 = `You are a rigorous reviewer for scientific publication-grade diagrams.
5405
+ Rate on a strict scale — 9+ is reserved for camera-ready figures. Return only valid JSON matching the schema. Be specific: every blocking_issue must describe exactly what is wrong AND a concrete fix another tool can act on.`;
5406
+ function buildUserPrompt(req, threshold) {
5407
+ const houseBlock = req.houseProfileSummary ? `HOUSE STYLE (the figure must belong to this visual system):
5408
+ ${req.houseProfileSummary}
5409
+
5410
+ ` : "";
5411
+ const fifthDimension = req.houseProfileSummary ? "5. House-style adherence & consistency — matches the supplied palette, typography voice, geometry tokens, and motifs; feels like a sibling of other figures in the same system" : "5. Professional appearance — publication-ready polish";
5412
+ return `Evaluate this diagram for "${req.docType}" publication (acceptance threshold: ${threshold}/10).
5413
+
5414
+ DIAGRAM TYPE: ${req.diagramType}
5415
+ ORIGINAL REQUEST: ${req.prompt}
5416
+ ITERATION: ${req.iteration}/${req.maxIterations}
5417
+ ${houseBlock}
5418
+ Score five dimensions independently (0-2 each, total 0-10):
5419
+ 1. Scientific accuracy — concepts, relationships, notation correct
5420
+ 2. Clarity & readability — hierarchy, unambiguous at a glance
5421
+ 3. Label quality — complete, legible, consistent
5422
+ 4. Layout & composition — balanced, no overlap, logical flow
5423
+ ${fifthDimension}
5424
+
5425
+ For every issue that blocks acceptance, emit an entry in blocking_issues with:
5426
+ - kind: wrong_content | illegible_text | layout_collision | missing_element | style_mismatch
5427
+ - description: what is wrong (call out house-style deviations under style_mismatch — wrong palette role, wrong corner radius, wrong typography voice, broken motif, etc.)
5428
+ - fix: precise instruction to correct it (will be fed to an image editor)
5429
+
5430
+ Choose verdict:
5431
+ - "acceptable" if score >= ${threshold} and no blocking_issues
5432
+ - "needs_edit" if problems are localised/cosmetic (labels, overlaps, styling) — image-to-image editing can fix them
5433
+ - "needs_regen" if content is wrong or structure is broken — the image must be redrawn`;
5434
+ }
5435
+ function coerceVerdict$2(raw) {
5436
+ if (raw === "acceptable" || raw === "needs_edit" || raw === "needs_regen") return raw;
5437
+ return "needs_edit";
5438
+ }
5439
+ function clampScore$2(n) {
5440
+ const v = typeof n === "number" ? n : Number(n);
5441
+ if (Number.isNaN(v)) return 0;
5442
+ return Math.min(10, Math.max(0, v));
5443
+ }
5444
+ const RESPONSE_SCHEMA = {
5445
+ type: "object",
5446
+ additionalProperties: false,
5447
+ required: ["score", "requestAlignment", "legibility", "blockingIssues", "summary", "verdict"],
5448
+ properties: {
5449
+ score: { type: "number" },
5450
+ requestAlignment: { type: "number" },
5451
+ legibility: { type: "number" },
5452
+ blockingIssues: {
5453
+ type: "array",
5454
+ items: {
5455
+ type: "object",
5456
+ additionalProperties: false,
5457
+ required: ["kind", "description", "fix"],
5458
+ properties: {
5459
+ kind: {
5460
+ type: "string",
5461
+ enum: ["wrong_content", "illegible_text", "layout_collision", "missing_element", "style_mismatch"]
5462
+ },
5463
+ description: { type: "string" },
5464
+ fix: { type: "string" }
5465
+ }
5466
+ }
5467
+ },
5468
+ summary: { type: "string" },
5469
+ verdict: {
5470
+ type: "string",
5471
+ enum: ["acceptable", "needs_edit", "needs_regen"]
5472
+ }
5473
+ }
5474
+ };
5475
+ function createOpenAIReviewProvider(opts = {}) {
5476
+ const apiKey = opts.apiKey ?? process.env.OPENAI_API_KEY?.trim();
5477
+ if (!apiKey) {
5478
+ throw new Error("OPENAI_API_KEY is required for OpenAI-based review.");
5479
+ }
5480
+ const model = opts.model || DEFAULT_MODEL$1;
5481
+ const thresholds = opts.thresholds ?? OPENAI_THRESHOLDS;
5482
+ async function review(req) {
5483
+ const threshold = thresholds[req.docType] ?? thresholds.default;
5484
+ const b64 = req.image.toString("base64");
5485
+ const dataUri = `data:image/png;base64,${b64}`;
5486
+ const body = {
5487
+ model,
5488
+ messages: [
5489
+ { role: "system", content: REVIEW_SYSTEM$1 },
5490
+ {
5491
+ role: "user",
5492
+ content: [
5493
+ { type: "text", text: buildUserPrompt(req, threshold) },
5494
+ { type: "image_url", image_url: { url: dataUri } }
5495
+ ]
5496
+ }
5497
+ ],
5498
+ response_format: {
5499
+ type: "json_schema",
5500
+ json_schema: { name: "DiagramReview", strict: true, schema: RESPONSE_SCHEMA }
5501
+ },
5502
+ temperature: 0
5503
+ };
5504
+ const ctl = new AbortController();
5505
+ const timer = setTimeout(() => ctl.abort(), REQUEST_TIMEOUT_MS$1);
5506
+ try {
5507
+ const res = await fetch(CHAT_URL, {
5508
+ method: "POST",
5509
+ headers: {
5510
+ "Authorization": `Bearer ${apiKey}`,
5511
+ "Content-Type": "application/json"
5512
+ },
5513
+ body: JSON.stringify(body),
5514
+ signal: ctl.signal
5515
+ });
5516
+ const json = await res.json();
5517
+ if (!res.ok) {
5518
+ throw new Error(`OpenAI review API error: ${json.error?.message || `HTTP ${res.status}`}`);
5519
+ }
5520
+ const content = json.choices?.[0]?.message?.content ?? "";
5521
+ if (!content) throw new Error("OpenAI review returned empty content");
5522
+ const parsed = JSON.parse(content);
5523
+ const result = {
5524
+ score: clampScore$2(parsed.score),
5525
+ requestAlignment: clampScore$2(parsed.requestAlignment),
5526
+ legibility: clampScore$2(parsed.legibility),
5527
+ blockingIssues: Array.isArray(parsed.blockingIssues) ? parsed.blockingIssues : [],
5528
+ summary: typeof parsed.summary === "string" ? parsed.summary : "",
5529
+ verdict: coerceVerdict$2(parsed.verdict)
5530
+ };
5531
+ return result;
5532
+ } finally {
5533
+ clearTimeout(timer);
5534
+ }
5535
+ }
5536
+ return {
5537
+ id: `openai:${model}`,
5538
+ label: `OpenAI ${model}`,
5539
+ thresholds,
5540
+ review
5541
+ };
5542
+ }
5543
+ const MESSAGES_URL = "https://api.anthropic.com/v1/messages";
5544
+ const DEFAULT_MODEL = "claude-opus-4-5";
5545
+ const ANTHROPIC_VERSION = "2023-06-01";
5546
+ const REQUEST_TIMEOUT_MS = 18e4;
5547
+ const MAX_TOKENS = 2048;
5548
+ const CLAUDE_CODE_IDENTITY = "You are Claude Code, Anthropic's official CLI for Claude.";
5549
+ const BETA_API_KEY = "fine-grained-tool-streaming-2025-05-14";
5550
+ const BETA_OAUTH = `claude-code-20250219,oauth-2025-04-20,${BETA_API_KEY}`;
5551
+ const ANTHROPIC_THRESHOLDS = {
5552
+ journal: 8.5,
5553
+ conference: 8,
5554
+ thesis: 8,
5555
+ grant: 8,
5556
+ preprint: 7.5,
5557
+ report: 7.5,
5558
+ poster: 7,
5559
+ presentation: 6.5,
5560
+ default: 7.5
5561
+ };
5562
+ const REVIEW_SYSTEM = `You are a rigorous reviewer for scientific publication-grade diagrams.
5563
+ Rate on a strict scale — 9+ is reserved for camera-ready figures. Always emit your answer by calling the emit_review tool. Every blocking_issue must describe what is wrong AND a concrete fix another tool can act on.`;
5564
+ function buildUserText(req, threshold) {
5565
+ const houseBlock = req.houseProfileSummary ? `
5566
+ HOUSE STYLE (figure must belong to this visual system):
5567
+ ${req.houseProfileSummary}
5568
+ ` : "";
5569
+ const fifthDimension = req.houseProfileSummary ? "house-style adherence & consistency" : "professional appearance";
5570
+ return `Evaluate this diagram for "${req.docType}" publication (acceptance threshold: ${threshold}/10).
5571
+
5572
+ DIAGRAM TYPE: ${req.diagramType}
5573
+ ORIGINAL REQUEST: ${req.prompt}
5574
+ ITERATION: ${req.iteration}/${req.maxIterations}
5575
+ ${houseBlock}
5576
+ Score five dimensions (0-2 each, total 0-10): scientific accuracy, clarity & readability, label quality, layout & composition, ${fifthDimension}.
5577
+
5578
+ When scoring dimension 5, compare against the HOUSE STYLE block above — wrong palette roles, wrong corner radius, wrong typography voice, or broken motifs are all style_mismatch blocking issues.
5579
+
5580
+ Choose verdict:
5581
+ - "acceptable" if score >= ${threshold} and no blocking_issues
5582
+ - "needs_edit" if problems are localised (labels, overlaps, styling) — image-to-image can fix them
5583
+ - "needs_regen" if content is wrong or structure is broken — must redraw
5584
+
5585
+ Call emit_review once with your structured verdict.`;
5586
+ }
5587
+ const EMIT_REVIEW_TOOL = {
5588
+ name: "emit_review",
5589
+ description: "Emit the structured review verdict for the diagram.",
5590
+ input_schema: {
5591
+ type: "object",
5592
+ additionalProperties: false,
5593
+ required: ["score", "requestAlignment", "legibility", "blockingIssues", "summary", "verdict"],
5594
+ properties: {
5595
+ score: { type: "number" },
5596
+ requestAlignment: { type: "number" },
5597
+ legibility: { type: "number" },
5598
+ blockingIssues: {
5599
+ type: "array",
5600
+ items: {
5601
+ type: "object",
5602
+ additionalProperties: false,
5603
+ required: ["kind", "description", "fix"],
5604
+ properties: {
5605
+ kind: {
5606
+ type: "string",
5607
+ enum: ["wrong_content", "illegible_text", "layout_collision", "missing_element", "style_mismatch"]
5608
+ },
5609
+ description: { type: "string" },
5610
+ fix: { type: "string" }
5611
+ }
5612
+ }
5613
+ },
5614
+ summary: { type: "string" },
5615
+ verdict: {
5616
+ type: "string",
5617
+ enum: ["acceptable", "needs_edit", "needs_regen"]
5618
+ }
5619
+ }
5620
+ }
5621
+ };
5622
+ function coerceVerdict$1(raw) {
5623
+ if (raw === "acceptable" || raw === "needs_edit" || raw === "needs_regen") return raw;
5624
+ return "needs_edit";
5625
+ }
5626
+ function clampScore$1(n) {
5627
+ const v = typeof n === "number" ? n : Number(n);
5628
+ if (Number.isNaN(v)) return 0;
5629
+ return Math.min(10, Math.max(0, v));
5630
+ }
5631
+ function isOAuthAccessToken(token) {
5632
+ return token.startsWith("sk-ant-oat");
5633
+ }
5634
+ function buildHeaders(token, isOAuth) {
5635
+ const common = {
5636
+ "anthropic-version": ANTHROPIC_VERSION,
5637
+ "Content-Type": "application/json"
5638
+ };
5639
+ if (isOAuth) {
5640
+ return {
5641
+ ...common,
5642
+ "Authorization": `Bearer ${token}`,
5643
+ "anthropic-beta": BETA_OAUTH,
5644
+ "anthropic-dangerous-direct-browser-access": "true",
5645
+ "user-agent": "claude-cli/research-copilot",
5646
+ "x-app": "cli"
5647
+ };
5648
+ }
5649
+ return {
5650
+ ...common,
5651
+ "x-api-key": token,
5652
+ "anthropic-beta": BETA_API_KEY
5653
+ };
5654
+ }
5655
+ function buildRequestBody(req, threshold, isOAuth) {
5656
+ const b64 = req.image.toString("base64");
5657
+ const systemBlocks = [];
5658
+ if (isOAuth) {
5659
+ systemBlocks.push({ type: "text", text: CLAUDE_CODE_IDENTITY });
5660
+ }
5661
+ systemBlocks.push({ type: "text", text: REVIEW_SYSTEM });
5662
+ return {
5663
+ model: DEFAULT_MODEL,
5664
+ max_tokens: MAX_TOKENS,
5665
+ system: systemBlocks,
5666
+ messages: [
5667
+ {
5668
+ role: "user",
5669
+ content: [
5670
+ {
5671
+ type: "image",
5672
+ source: { type: "base64", media_type: "image/png", data: b64 }
5673
+ },
5674
+ { type: "text", text: buildUserText(req, threshold) }
5675
+ ]
5676
+ }
5677
+ ],
5678
+ tools: [EMIT_REVIEW_TOOL],
5679
+ tool_choice: { type: "tool", name: "emit_review" }
5680
+ };
5681
+ }
5682
+ async function doRequest(token, isOAuth, body) {
5683
+ const ctl = new AbortController();
5684
+ const timer = setTimeout(() => ctl.abort(), REQUEST_TIMEOUT_MS);
5685
+ try {
5686
+ const res = await fetch(MESSAGES_URL, {
5687
+ method: "POST",
5688
+ headers: buildHeaders(token, isOAuth),
5689
+ body: JSON.stringify(body),
5690
+ signal: ctl.signal
5691
+ });
5692
+ const json = await res.json();
5693
+ return { status: res.status, json };
5694
+ } finally {
5695
+ clearTimeout(timer);
5696
+ }
5697
+ }
5698
+ function createAnthropicReviewProvider(opts = {}) {
5699
+ const envKey = process.env.ANTHROPIC_API_KEY?.trim();
5700
+ const initialTokenRaw = opts.token ?? envKey;
5701
+ if (!initialTokenRaw) {
5702
+ throw new Error("ANTHROPIC_API_KEY or an anthropic-sub OAuth token is required for Claude-based review.");
5703
+ }
5704
+ let currentToken = initialTokenRaw;
5705
+ const isOAuth = opts.isOAuth ?? isOAuthAccessToken(currentToken);
5706
+ const refreshToken = opts.refreshToken;
5707
+ const model = opts.model || DEFAULT_MODEL;
5708
+ const thresholds = opts.thresholds ?? ANTHROPIC_THRESHOLDS;
5709
+ async function review(req) {
5710
+ const threshold = thresholds[req.docType] ?? thresholds.default;
5711
+ const body = buildRequestBody(req, threshold, isOAuth);
5712
+ if (model !== DEFAULT_MODEL) {
5713
+ body.model = model;
5714
+ }
5715
+ let { status, json } = await doRequest(currentToken, isOAuth, body);
5716
+ if (status === 401 && isOAuth && refreshToken) {
5717
+ try {
5718
+ currentToken = await refreshToken();
5719
+ ({ status, json } = await doRequest(currentToken, isOAuth, body));
5720
+ } catch (err) {
5721
+ throw new Error(`Anthropic OAuth token refresh failed: ${err.message}`);
5722
+ }
5723
+ }
5724
+ if (status < 200 || status >= 300) {
5725
+ throw new Error(`Anthropic review API error (HTTP ${status}): ${json.error?.message || "unknown"}`);
5726
+ }
5727
+ const toolUse = json.content?.find(
5728
+ (c) => c.type === "tool_use" && c.name === "emit_review"
5729
+ );
5730
+ if (!toolUse) {
5731
+ throw new Error("Claude review did not emit the expected tool_use block");
5732
+ }
5733
+ const input = toolUse.input;
5734
+ const result = {
5735
+ score: clampScore$1(input.score),
5736
+ requestAlignment: clampScore$1(input.requestAlignment),
5737
+ legibility: clampScore$1(input.legibility),
5738
+ blockingIssues: Array.isArray(input.blockingIssues) ? input.blockingIssues : [],
5739
+ summary: typeof input.summary === "string" ? input.summary : "",
5740
+ verdict: coerceVerdict$1(input.verdict)
5741
+ };
5742
+ return result;
5743
+ }
5744
+ return {
5745
+ id: `anthropic:${model}${isOAuth ? ":oauth" : ":api-key"}`,
5746
+ label: `Anthropic ${model}${isOAuth ? " (subscription)" : ""}`,
5747
+ thresholds,
5748
+ review
5749
+ };
5750
+ }
5751
+ const ASPECT_VIEWBOX = {
5752
+ auto: "0 0 1200 900",
5753
+ // 4:3 lands moderate info density
5754
+ square: "0 0 900 900",
5755
+ landscape: "0 0 1400 900",
5756
+ portrait: "0 0 900 1400"
5757
+ };
5758
+ const SVG_SYSTEM_PROMPT = `You are a diagram artist producing publication-quality SVG in the house visual system.
5759
+ Return ONLY a single valid SVG document wrapped in \`\`\`svg fences — no prose, no explanation.
5760
+
5761
+ Allowed SVG 1.1 elements:
5762
+ svg, defs, marker, g, rect, circle, ellipse, line, path, polyline, polygon, text, tspan, title, desc, clipPath, pattern
5763
+
5764
+ Hard requirements:
5765
+ - Root element has an explicit viewBox and no width/height attributes (so it scales).
5766
+ - No <script>, no <foreignObject>, no external href references, no embedded base64.
5767
+ - Font-family: use the house font stack supplied in the REQUEST; fall back to sans-serif when no listed font is available. Minimum font size 10px.
5768
+ - Every labelled element gets a <text> child with readable contents (no lorem ipsum, no placeholder text).
5769
+ - Arrows use <marker> definitions inside <defs>, referenced from <line>/<path> via marker-end — no emoji arrows, no unicode replacement characters.
5770
+ - Use the colour tokens supplied in the REQUEST — do not invent a new palette.
5771
+ - No gradients, filters, blur, or glow anywhere. No drop-shadow, bevel, or emboss effects.
5772
+ - Do NOT include figure numbers, titles, or captions inside the SVG ("Figure 1: …"). Those are added in-document.
5773
+ - Do NOT include <?xml?> declarations; start directly with <svg ...>.`;
5774
+ const SVG_REPAIR_SYSTEM_PROMPT = `You are repairing a previous SVG response that failed to parse.
5775
+ Return ONLY a single valid SVG document wrapped in \`\`\`svg fences — no prose, no commentary, no markdown headings.
5776
+ Hard requirements:
5777
+ - Start with <svg ...> and end with </svg>. No <?xml?> declaration.
5778
+ - Root element MUST have a viewBox attribute.
5779
+ - Every <marker id="X"> referenced via marker-end="url(#X)" must be defined inside <defs>.
5780
+ - All tags must be properly nested and closed.
5781
+ - No <script>, no <foreignObject>, no external href.`;
5782
+ function buildGenerationUser(prompt, aspect) {
5783
+ const viewBox = ASPECT_VIEWBOX[aspect];
5784
+ return `Produce an SVG diagram for the request below.
5785
+
5786
+ VIEWBOX HINT: ${viewBox}
5787
+ - Use this as a starting hint. If your content fits compactly in a
5788
+ smaller area, SHRINK either dimension (by up to 30%) to eliminate
5789
+ empty gutters. A figure with visible empty regions at the bottom
5790
+ or side looks unfinished.
5791
+ - Keep the overall aspect (landscape / portrait / square) close to
5792
+ the hint. Do NOT flip orientation.
5793
+ - Do NOT grow either dimension beyond the hint — that pushes the
5794
+ figure past canonical paper-column widths.
5795
+
5796
+ REQUEST:
5797
+ ${prompt}
5798
+
5799
+ Emit the SVG now.`;
5800
+ }
5801
+ function buildEditUser(prompt, previousSvg) {
5802
+ return `Revise the SVG below by applying the changes in REQUEST. Preserve everything that is already correct — do not redraw the whole diagram from scratch.
5803
+
5804
+ VIEWBOX: preserve the viewBox attribute from the PREVIOUS SVG exactly. Do NOT change its dimensions, origin, or aspect.
5805
+
5806
+ REQUEST:
5807
+ ${prompt}
5808
+
5809
+ PREVIOUS SVG:
5810
+ \`\`\`svg
5811
+ ${previousSvg}
5812
+ \`\`\`
5813
+
5814
+ Emit the revised SVG now, as a single complete <svg>…</svg> document.`;
5815
+ }
5816
+ function buildRepairUser$1(originalUserPrompt, brokenRaw, failureReason) {
5817
+ const HEAD = 1200;
5818
+ const TAIL = 400;
5819
+ const head = brokenRaw.slice(0, HEAD);
5820
+ const tail = brokenRaw.length > HEAD + TAIL ? brokenRaw.slice(-TAIL) : "";
5821
+ const excerpt = tail ? `${head}
5822
+
5823
+ [... ${brokenRaw.length - HEAD - TAIL} chars elided ...]
5824
+
5825
+ ${tail}` : head;
5826
+ return `Your previous response could not be parsed as a valid SVG document.
5827
+
5828
+ PARSE FAILURE: ${failureReason}
5829
+
5830
+ ORIGINAL REQUEST (unchanged):
5831
+ ${originalUserPrompt}
5832
+
5833
+ YOUR BROKEN RESPONSE (head + tail, ${brokenRaw.length} chars total):
5834
+ ${excerpt}
5835
+
5836
+ Emit a corrected SVG now. Output ONLY the SVG inside \`\`\`svg fences — no apology, no explanation.`;
5837
+ }
5838
+ function extractSvg(raw) {
5839
+ const fenceMatch = raw.match(/```(?:svg|xml|html)?\s*([\s\S]*?)```/i);
5840
+ const candidate = fenceMatch ? fenceMatch[1] : raw;
5841
+ const svgMatch = candidate.match(/<svg\b[\s\S]*?<\/svg>/i);
5842
+ if (!svgMatch) {
5843
+ const hasOpening = /<svg\b/i.test(candidate);
5844
+ const reason = hasOpening ? "Found <svg> opening but no matching </svg> close — response likely truncated." : "No <svg>…</svg> block found in the response.";
5845
+ return { ok: false, reason };
5846
+ }
5847
+ return { ok: true, svg: svgMatch[0].trim() };
5848
+ }
5849
+ function validateSvg(svg) {
5850
+ const rootMatch = svg.match(/<svg\b([^>]*)>/i);
5851
+ if (!rootMatch) {
5852
+ return { ok: false, reason: "No <svg> root element found after extraction (should be impossible)." };
5853
+ }
5854
+ if (!/\bviewBox\s*=/i.test(rootMatch[1])) {
5855
+ return { ok: false, reason: "Root <svg> element is missing a viewBox attribute." };
5856
+ }
5857
+ const refIds = /* @__PURE__ */ new Set();
5858
+ const refRe = /marker-(?:start|mid|end)\s*=\s*"url\(#([^)"]+)\)"/gi;
5859
+ let m;
5860
+ while ((m = refRe.exec(svg)) !== null) refIds.add(m[1]);
5861
+ if (refIds.size > 0) {
5862
+ const defIds = /* @__PURE__ */ new Set();
5863
+ const defRe = /<marker\b[^>]*\bid\s*=\s*"([^"]+)"/gi;
5864
+ while ((m = defRe.exec(svg)) !== null) defIds.add(m[1]);
5865
+ const missing = [];
5866
+ for (const id of refIds) if (!defIds.has(id)) missing.push(id);
5867
+ if (missing.length > 0) {
5868
+ return {
5869
+ ok: false,
5870
+ reason: `Marker reference(s) without matching <marker id="…">: ${missing.join(", ")}.`
5871
+ };
5872
+ }
5873
+ }
5874
+ if (!/<text\b/i.test(svg)) {
5875
+ return { ok: false, reason: "No <text> elements present — diagram has no labels." };
5876
+ }
5877
+ const tagRe = /<\s*(\/?)([a-zA-Z][a-zA-Z0-9-]*)\b[^>]*?(\/?)>/g;
5878
+ const stack = [];
5879
+ while ((m = tagRe.exec(svg)) !== null) {
5880
+ const isClose = m[1] === "/";
5881
+ const isSelfClosing = m[3] === "/";
5882
+ const name = m[2].toLowerCase();
5883
+ if (isClose) {
5884
+ const top = stack.pop();
5885
+ if (top !== name) {
5886
+ return {
5887
+ ok: false,
5888
+ reason: `Tag imbalance: </${name}> at offset ${m.index} does not match the open stack (top=${top ?? "empty"}).`
5889
+ };
5890
+ }
5891
+ } else if (!isSelfClosing) {
5892
+ stack.push(name);
5893
+ }
5894
+ }
5895
+ if (stack.length > 0) {
5896
+ return { ok: false, reason: `Unclosed tag(s) at end of document: ${stack.join(" > ")}.` };
5897
+ }
5898
+ return { ok: true, svg };
5899
+ }
5900
+ function summariseRawForError(raw) {
5901
+ const HEAD = 600;
5902
+ const TAIL = 200;
5903
+ const head = raw.slice(0, HEAD).replace(/\s+/g, " ").trim();
5904
+ if (raw.length <= HEAD + TAIL) return `${raw.length} chars: ${head}`;
5905
+ const tail = raw.slice(-TAIL).replace(/\s+/g, " ").trim();
5906
+ return `${raw.length} chars; head: ${head} … tail: ${tail}`;
5907
+ }
5908
+ function tryParseAndValidate(raw) {
5909
+ const extracted = extractSvg(raw);
5910
+ if (!extracted.ok) return extracted;
5911
+ return validateSvg(extracted.svg);
5912
+ }
5913
+ async function generateWithRepair(callLlm, systemPrompt, userPrompt) {
5914
+ const firstRaw = await callLlm(systemPrompt, userPrompt);
5915
+ const first = tryParseAndValidate(firstRaw);
5916
+ if (first.ok) return first.svg;
5917
+ const repairUser = buildRepairUser$1(userPrompt, firstRaw, first.reason);
5918
+ let secondRaw;
5919
+ try {
5920
+ secondRaw = await callLlm(SVG_REPAIR_SYSTEM_PROMPT, repairUser);
5921
+ } catch (err) {
5922
+ throw new Error(
5923
+ `SVG fallback: parse failed and repair call errored. First failure: ${first.reason}. First response: ${summariseRawForError(firstRaw)}. Repair error: ${err.message}`
5924
+ );
5925
+ }
5926
+ const second = tryParseAndValidate(secondRaw);
5927
+ if (second.ok) return second.svg;
5928
+ throw new Error(
5929
+ `SVG fallback: parse failed twice (initial + repair). First failure: ${first.reason}. Initial response: ${summariseRawForError(firstRaw)}. Repair failure: ${second.reason}. Repair response: ${summariseRawForError(secondRaw)}`
5930
+ );
5931
+ }
5932
+ function createSvgFallbackImageProvider(opts) {
5933
+ const { callLlm } = opts;
5934
+ const aspect = opts.aspect ?? "auto";
5935
+ const modelLabel = opts.modelLabel || "chat-model";
5936
+ const capabilities = /* @__PURE__ */ new Set(["text_to_image", "image_to_image"]);
5937
+ return {
5938
+ id: `svg-fallback:${modelLabel}`,
5939
+ label: `SVG fallback (${modelLabel})`,
5940
+ capabilities,
5941
+ async textToImage(prompt) {
5942
+ const userPrompt = buildGenerationUser(prompt, aspect);
5943
+ const svg = await generateWithRepair(callLlm, SVG_SYSTEM_PROMPT, userPrompt);
5944
+ return Buffer.from(svg, "utf-8");
5945
+ },
5946
+ async imageToImage(prompt, image) {
5947
+ const previousSvg = image.toString("utf-8");
5948
+ const userPrompt = buildEditUser(prompt, previousSvg);
5949
+ const svg = await generateWithRepair(callLlm, SVG_SYSTEM_PROMPT, userPrompt);
5950
+ return Buffer.from(svg, "utf-8");
5951
+ }
5952
+ };
5953
+ }
5954
+ const FALLBACK_THRESHOLDS = {
5955
+ journal: 8.7,
5956
+ conference: 8.2,
5957
+ thesis: 8.2,
5958
+ grant: 8.2,
5959
+ preprint: 7.7,
5960
+ report: 7.7,
5961
+ poster: 7.2,
5962
+ presentation: 6.7,
5963
+ default: 7.7
5964
+ };
5965
+ const REVIEW_SYSTEM_PROMPT = `You are a rigorous reviewer of SVG scientific diagrams. The SVG source is provided; read it as text to evaluate structure, labels, and layout. Use a strict scale — 9+ is reserved for camera-ready figures.
5966
+
5967
+ Always respond with exactly one JSON object wrapped in \`\`\`json fences. No prose before or after. Schema:
5968
+
5969
+ {
5970
+ "score": number, // 0-10 total
5971
+ "requestAlignment": number, // 0-10, matches user intent?
5972
+ "legibility": number, // 0-10, labels readable at intended size?
5973
+ "blockingIssues": [
5974
+ {
5975
+ "kind": "wrong_content" | "illegible_text" | "layout_collision" | "missing_element" | "style_mismatch",
5976
+ "description": string,
5977
+ "fix": string // concrete enough to feed back to the generator
5978
+ }
5979
+ ],
5980
+ "summary": string,
5981
+ "verdict": "acceptable" | "needs_edit" | "needs_regen"
5982
+ }
5983
+
5984
+ Verdict rules:
5985
+ - "acceptable" if score >= threshold AND blockingIssues is empty
5986
+ - "needs_edit" for localised problems (labels, overlaps, styling) fixable by revising the SVG in place
5987
+ - "needs_regen" for structural or content errors — the diagram must be redrawn`;
5988
+ function buildUser(req, threshold, svgSource) {
5989
+ const houseBlock = req.houseProfileSummary ? `
5990
+ HOUSE STYLE (figure must belong to this visual system):
5991
+ ${req.houseProfileSummary}
5992
+
5993
+ Under blockingIssues of kind style_mismatch, call out any deviation from this house style — wrong palette tokens, wrong stroke widths, wrong corner radii, wrong typography voice, broken motifs.
5994
+ ` : "";
5995
+ return `Evaluate this diagram for "${req.docType}" publication (acceptance threshold: ${threshold}/10).
5996
+
5997
+ DIAGRAM TYPE: ${req.diagramType}
5998
+ ORIGINAL REQUEST: ${req.prompt}
5999
+ ITERATION: ${req.iteration}/${req.maxIterations}
6000
+ ${houseBlock}
6001
+ SVG SOURCE:
6002
+ \`\`\`svg
6003
+ ${svgSource}
6004
+ \`\`\`
6005
+
6006
+ Respond with the JSON review object only.`;
6007
+ }
6008
+ function coerceVerdict(raw) {
6009
+ if (raw === "acceptable" || raw === "needs_edit" || raw === "needs_regen") return raw;
6010
+ return "needs_edit";
6011
+ }
6012
+ function clampScore(n) {
6013
+ const v = typeof n === "number" ? n : Number(n);
6014
+ if (Number.isNaN(v)) return 0;
6015
+ return Math.min(10, Math.max(0, v));
6016
+ }
6017
+ function extractJsonObject(text) {
6018
+ const fenceMatch = text.match(/```(?:json)?\s*(\{[\s\S]*?\})\s*```/i);
6019
+ if (fenceMatch) {
6020
+ try {
6021
+ return JSON.parse(fenceMatch[1]);
6022
+ } catch {
6023
+ }
6024
+ }
6025
+ const firstBrace = text.indexOf("{");
6026
+ if (firstBrace === -1) return null;
6027
+ let depth = 0;
6028
+ for (let i = firstBrace; i < text.length; i++) {
6029
+ const ch = text[i];
6030
+ if (ch === "{") depth++;
6031
+ else if (ch === "}") {
6032
+ depth--;
6033
+ if (depth === 0) {
6034
+ try {
6035
+ return JSON.parse(text.slice(firstBrace, i + 1));
6036
+ } catch {
6037
+ return null;
6038
+ }
6039
+ }
6040
+ }
6041
+ }
6042
+ return null;
6043
+ }
6044
+ function createSvgFallbackReviewProvider(opts) {
6045
+ const { callLlm } = opts;
6046
+ const modelLabel = opts.modelLabel || "chat-model";
6047
+ const thresholds = opts.thresholds ?? FALLBACK_THRESHOLDS;
6048
+ async function review(req) {
6049
+ const threshold = thresholds[req.docType] ?? thresholds.default;
6050
+ const svgSource = req.image.toString("utf-8");
6051
+ const text = await callLlm(REVIEW_SYSTEM_PROMPT, buildUser(req, threshold, svgSource));
6052
+ const parsed = extractJsonObject(text);
6053
+ if (!parsed) {
6054
+ return {
6055
+ score: Math.max(0, threshold - 0.5),
6056
+ requestAlignment: Math.max(0, threshold - 0.5),
6057
+ legibility: Math.max(0, threshold - 0.5),
6058
+ blockingIssues: [{
6059
+ kind: "style_mismatch",
6060
+ description: "Reviewer did not return a parseable structured response.",
6061
+ fix: "Regenerate with clearer composition; ensure all labels are explicit and arrows have markers."
6062
+ }],
6063
+ summary: "Review parse failed — falling back to conservative needs_edit verdict.",
6064
+ verdict: "needs_edit"
6065
+ };
6066
+ }
6067
+ return {
6068
+ score: clampScore(parsed.score),
6069
+ requestAlignment: clampScore(parsed.requestAlignment),
6070
+ legibility: clampScore(parsed.legibility),
6071
+ blockingIssues: Array.isArray(parsed.blockingIssues) ? parsed.blockingIssues : [],
6072
+ summary: typeof parsed.summary === "string" ? parsed.summary : "",
6073
+ verdict: coerceVerdict(parsed.verdict)
6074
+ };
6075
+ }
6076
+ return {
6077
+ id: `svg-fallback:${modelLabel}`,
6078
+ label: `SVG fallback review (${modelLabel})`,
6079
+ thresholds,
6080
+ review
6081
+ };
6082
+ }
6083
+ function createRasterizeThenReviewProvider(opts) {
6084
+ const { rasterizer, inner, dimensions } = opts;
6085
+ async function review(req) {
6086
+ const png = await rasterizer(req.image, dimensions);
6087
+ return inner.review({ ...req, image: png });
6088
+ }
6089
+ return {
6090
+ id: `rasterize+${inner.id}`,
6091
+ label: `SVG rasterised → ${inner.label}`,
6092
+ thresholds: inner.thresholds,
6093
+ review
6094
+ };
6095
+ }
6096
+ const TRANSCRIPTION_SYSTEM_PROMPT = `You transcribe a finalized scientific diagram (provided as a PNG image) into clean, editable SVG markup.
6097
+
6098
+ The PNG is the ground truth. Your job is to produce SVG that, when rendered, is a faithful structural copy — NOT to redesign, improve, or "polish" anything.
6099
+
6100
+ Output rules (strict):
6101
+ - Output ONLY a single SVG document wrapped in \`\`\`svg fences. No prose, no apology, no commentary.
6102
+ - Start directly with <svg viewBox="…" xmlns="http://www.w3.org/2000/svg">. No <?xml?> declaration. No width/height attributes on root.
6103
+ - Use only these elements: svg, defs, marker, g, rect, circle, ellipse, line, path, polyline, polygon, text, tspan, title, desc.
6104
+ - No <script>, no <foreignObject>, no external href, no embedded base64 images.
6105
+ - No filters, no gradients, no blur, no drop-shadow.
6106
+
6107
+ Transcription requirements:
6108
+ - For every box you see → emit one <rect> at approximately the same position and size (drift up to ~20px is acceptable).
6109
+ - For every label you see → emit one <text> with the label VERBATIM. Never paraphrase, never abbreviate, never translate.
6110
+ - For every arrow you see → emit one <line>, <polyline>, or <path> with marker-end referencing a <marker> in <defs>.
6111
+ - Sample dominant colors from the PNG and use them directly as fill/stroke values (hex codes).
6112
+ - Match the rough rounded-corner radius you observe (sharp = 0, slightly rounded = 6-8, very rounded = 12+).
6113
+ - Preserve grouping: dashed-bordered containers in the PNG → <rect> with stroke-dasharray.
6114
+
6115
+ Hard prohibitions:
6116
+ - Do NOT add labels, captions, legends, watermarks, or any element not visible in the PNG.
6117
+ - Do NOT redesign the layout, change the topology, or "fix" perceived issues.
6118
+ - Do NOT include figure numbers or titles ("Figure 1: …").
6119
+ - Do NOT use placeholder text like "Lorem ipsum" or "[Label]".
6120
+
6121
+ Position drift up to ~20px is acceptable. Missing labels, paraphrased text, or invented elements are NOT.`;
6122
+ const REPAIR_SYSTEM_PROMPT = `You are repairing a previous SVG transcription that failed to parse.
6123
+ Return ONLY a single valid SVG document wrapped in \`\`\`svg fences — no prose.
6124
+ Hard requirements:
6125
+ - Start with <svg ...> and end with </svg>. No <?xml?> declaration.
6126
+ - Root MUST have a viewBox attribute.
6127
+ - Every <marker id="X"> referenced via marker-end="url(#X)" must be defined inside <defs>.
6128
+ - All tags properly nested and closed.
6129
+ - Preserve every <text> element verbatim from the original PNG content — do not drop labels.`;
6130
+ function buildTranscriptionUser(req) {
6131
+ const lines = [];
6132
+ lines.push("Transcribe the attached PNG diagram into editable SVG.");
6133
+ lines.push("");
6134
+ if (req.viewBoxHint) {
6135
+ lines.push(`VIEWBOX HINT: ${req.viewBoxHint}`);
6136
+ lines.push(" - Match the PNG aspect ratio within ±10%.");
6137
+ lines.push(" - Do NOT flip orientation.");
6138
+ lines.push("");
6139
+ }
6140
+ if (req.originalPromptHint) {
6141
+ lines.push("ORIGINAL DIAGRAM REQUEST (for disambiguating any unclear labels):");
6142
+ lines.push(req.originalPromptHint);
6143
+ lines.push("");
6144
+ }
6145
+ lines.push("Emit the SVG now. Output ONLY the SVG inside ```svg fences.");
6146
+ return lines.join("\n");
6147
+ }
6148
+ function buildRepairUser(originalUserPrompt, brokenRaw, failureReason) {
6149
+ const HEAD = 1200;
6150
+ const TAIL = 400;
6151
+ const head = brokenRaw.slice(0, HEAD);
6152
+ const tail = brokenRaw.length > HEAD + TAIL ? brokenRaw.slice(-TAIL) : "";
6153
+ const excerpt = tail ? `${head}
6154
+
6155
+ [... ${brokenRaw.length - HEAD - TAIL} chars elided ...]
6156
+
6157
+ ${tail}` : head;
6158
+ return `Your previous SVG transcription could not be parsed.
6159
+
6160
+ PARSE FAILURE: ${failureReason}
6161
+
6162
+ ORIGINAL REQUEST (unchanged):
6163
+ ${originalUserPrompt}
6164
+
6165
+ YOUR BROKEN RESPONSE (head + tail, ${brokenRaw.length} chars total):
6166
+ ${excerpt}
6167
+
6168
+ Re-transcribe the attached PNG into a corrected SVG. The PNG is unchanged — produce a faithful structural copy. Output ONLY the SVG inside \`\`\`svg fences.`;
6169
+ }
6170
+ async function transcribePngToSvg(opts, req) {
6171
+ const image = {
6172
+ base64: req.png.toString("base64"),
6173
+ mimeType: "image/png"
6174
+ };
6175
+ const userPrompt = buildTranscriptionUser(req);
6176
+ const firstRaw = await opts.callLlmVision(
6177
+ TRANSCRIPTION_SYSTEM_PROMPT,
6178
+ userPrompt,
6179
+ [image]
6180
+ );
6181
+ const first = tryParseAndValidate(firstRaw);
6182
+ if (first.ok) {
6183
+ return { svg: first.svg, repaired: false };
6184
+ }
6185
+ const repairUser = buildRepairUser(userPrompt, firstRaw, first.reason);
6186
+ let secondRaw;
6187
+ try {
6188
+ secondRaw = await opts.callLlmVision(
6189
+ REPAIR_SYSTEM_PROMPT,
6190
+ repairUser,
6191
+ [image]
6192
+ );
6193
+ } catch (err) {
6194
+ throw new Error(
6195
+ `PNG-to-SVG transcription: parse failed and repair call errored. First failure: ${first.reason}. First response: ${summariseRawForError(firstRaw)}. Repair error: ${err.message}`
6196
+ );
6197
+ }
6198
+ const second = tryParseAndValidate(secondRaw);
6199
+ if (second.ok) {
6200
+ return { svg: second.svg, repaired: true, firstAttemptFailure: first.reason };
6201
+ }
6202
+ throw new Error(
6203
+ `PNG-to-SVG transcription: parse failed twice (initial + repair). First failure: ${first.reason}. Initial response: ${summariseRawForError(firstRaw)}. Repair failure: ${second.reason}. Repair response: ${summariseRawForError(secondRaw)}`
6204
+ );
6205
+ }
6206
+ function resolveAuth(auth) {
6207
+ if (auth) {
6208
+ return {
6209
+ openaiKey: auth.openaiKey ?? null,
6210
+ anthropic: auth.anthropic ?? null
6211
+ };
6212
+ }
6213
+ const envOpenAI = process.env.OPENAI_API_KEY?.trim() || null;
6214
+ const envAnthropic = process.env.ANTHROPIC_API_KEY?.trim();
6215
+ return {
6216
+ openaiKey: envOpenAI,
6217
+ anthropic: envAnthropic ? { token: envAnthropic, isOAuth: false } : null
6218
+ };
6219
+ }
6220
+ const ASPECT_TO_VIEWBOX = {
6221
+ auto: "0 0 1200 900",
6222
+ square: "0 0 900 900",
6223
+ landscape: "0 0 1400 900",
6224
+ portrait: "0 0 900 1400"
6225
+ };
6226
+ function buildPngToSvgTranscriber(callLlmVision, visionModelLabel, viewBoxHint) {
6227
+ return async (png, originalPromptHint) => {
6228
+ const result = await transcribePngToSvg(
6229
+ { callLlmVision, modelLabel: visionModelLabel },
6230
+ { png, viewBoxHint, originalPromptHint }
6231
+ );
6232
+ return {
6233
+ svg: result.svg,
6234
+ repaired: result.repaired,
6235
+ firstAttemptFailure: result.firstAttemptFailure,
6236
+ visionModelLabel
6237
+ };
6238
+ };
6239
+ }
6240
+ function pickImageProvider(prefs, auth, fallback) {
6241
+ const choice = prefs.generation ?? "openai";
6242
+ if (prefs.forceSvg) {
6243
+ if (auth.openaiKey) {
6244
+ if (fallback?.callLlmVision && fallback.visionCapable !== false) {
6245
+ const provider = createOpenAIImageProvider({
6246
+ apiKey: auth.openaiKey,
6247
+ model: prefs.imageModel,
6248
+ size: prefs.imageSize,
6249
+ quality: prefs.imageQuality
6250
+ });
6251
+ const transcriber = buildPngToSvgTranscriber(
6252
+ fallback.callLlmVision,
6253
+ fallback.modelLabel || "chat-model",
6254
+ ASPECT_TO_VIEWBOX[prefs.svgAspect ?? "auto"]
6255
+ );
6256
+ return { provider, pngToSvgTranscriber: transcriber, svgPath: "png_anchored" };
6257
+ }
6258
+ throw new Error(
6259
+ "SVG_REQUIRES_VISION_MODEL: Editable SVG output requires a vision-capable chat model (e.g. GPT-4o, Claude Opus, Gemini 2.5) to transcribe the rendered PNG into SVG markup. Either switch to a vision-capable model in Settings, or request a raster output (.png)."
6260
+ );
6261
+ }
6262
+ if (fallback) {
6263
+ const provider = createSvgFallbackImageProvider({
6264
+ callLlm: fallback.callLlm,
6265
+ modelLabel: fallback.modelLabel,
6266
+ aspect: prefs.svgAspect
6267
+ });
6268
+ return { provider, svgPath: "chat_model_only" };
6269
+ }
6270
+ throw new Error(
6271
+ "SVG output requested but neither an OpenAI API key (for the PNG-anchored path) nor a chat model (for the safety-net path) is configured."
6272
+ );
6273
+ }
6274
+ if (choice === "openai") {
6275
+ if (auth.openaiKey) {
6276
+ return {
6277
+ provider: createOpenAIImageProvider({
6278
+ apiKey: auth.openaiKey,
6279
+ model: prefs.imageModel,
6280
+ size: prefs.imageSize,
6281
+ quality: prefs.imageQuality
6282
+ }),
6283
+ svgPath: null
6284
+ };
6285
+ }
6286
+ if (fallback) {
6287
+ return {
6288
+ provider: createSvgFallbackImageProvider({
6289
+ callLlm: fallback.callLlm,
6290
+ modelLabel: fallback.modelLabel,
6291
+ aspect: prefs.svgAspect
6292
+ }),
6293
+ svgPath: "chat_model_only"
6294
+ };
6295
+ }
6296
+ throw new Error(
6297
+ "Diagram generation requires either OPENAI_API_KEY or a callLlm fallback (which the coordinator supplies from the current chat model). Neither is available here."
6298
+ );
6299
+ }
6300
+ throw new Error(`Unknown generation provider: ${choice}`);
6301
+ }
6302
+ function buildRealReviewProvider(choice, auth, reviewModel) {
6303
+ if (choice === "openai") {
6304
+ if (!auth.openaiKey) return null;
6305
+ return createOpenAIReviewProvider({ apiKey: auth.openaiKey, model: reviewModel });
6306
+ }
6307
+ if (choice === "anthropic") {
6308
+ if (!auth.anthropic) return null;
6309
+ return createAnthropicReviewProvider({
6310
+ token: auth.anthropic.token,
6311
+ isOAuth: auth.anthropic.isOAuth,
6312
+ refreshToken: auth.anthropic.refresh,
6313
+ model: reviewModel
6314
+ });
6315
+ }
6316
+ if (auth.anthropic) {
6317
+ return createAnthropicReviewProvider({
6318
+ token: auth.anthropic.token,
6319
+ isOAuth: auth.anthropic.isOAuth,
6320
+ refreshToken: auth.anthropic.refresh,
6321
+ model: reviewModel
6322
+ });
6323
+ }
6324
+ if (auth.openaiKey) {
6325
+ return createOpenAIReviewProvider({ apiKey: auth.openaiKey, model: reviewModel });
6326
+ }
6327
+ return null;
6328
+ }
6329
+ function pickReviewProvider(prefs, auth, fallback, usingSvgGen = false) {
6330
+ const choice = prefs.review ?? "auto";
6331
+ if (usingSvgGen) {
6332
+ if (!fallback) {
6333
+ throw new Error("SVG review fallback requires a callLlm fallback.");
6334
+ }
6335
+ if (fallback.rasterizeSvg) {
6336
+ const visionReviewer = buildRealReviewProvider(choice, auth, prefs.reviewModel);
6337
+ if (visionReviewer) {
6338
+ return createRasterizeThenReviewProvider({
6339
+ rasterizer: fallback.rasterizeSvg,
6340
+ inner: visionReviewer
6341
+ });
6342
+ }
6343
+ }
6344
+ return createSvgFallbackReviewProvider({
6345
+ callLlm: fallback.callLlm,
6346
+ modelLabel: fallback.modelLabel
6347
+ });
6348
+ }
6349
+ if (choice === "openai") {
6350
+ if (!auth.openaiKey) {
6351
+ throw new Error("Review provider set to OpenAI but no OpenAI API key is configured.");
6352
+ }
6353
+ return createOpenAIReviewProvider({ apiKey: auth.openaiKey, model: prefs.reviewModel });
6354
+ }
6355
+ if (choice === "anthropic") {
6356
+ if (!auth.anthropic) {
6357
+ throw new Error(
6358
+ "Review provider set to Anthropic but no Claude credentials are available. Either set ANTHROPIC_API_KEY under Settings → API Keys, or sign in via Claude subscription login."
6359
+ );
6360
+ }
6361
+ return createAnthropicReviewProvider({
6362
+ token: auth.anthropic.token,
6363
+ isOAuth: auth.anthropic.isOAuth,
6364
+ refreshToken: auth.anthropic.refresh,
6365
+ model: prefs.reviewModel
6366
+ });
6367
+ }
6368
+ if (auth.anthropic) {
6369
+ return createAnthropicReviewProvider({
6370
+ token: auth.anthropic.token,
6371
+ isOAuth: auth.anthropic.isOAuth,
6372
+ refreshToken: auth.anthropic.refresh,
6373
+ model: prefs.reviewModel
6374
+ });
6375
+ }
6376
+ if (auth.openaiKey) {
6377
+ return createOpenAIReviewProvider({ apiKey: auth.openaiKey, model: prefs.reviewModel });
6378
+ }
6379
+ throw new Error("No review provider is configured. Add OPENAI_API_KEY or sign in to Claude.");
6380
+ }
6381
+ function resolveProviders(prefs = {}, auth, fallback) {
6382
+ const resolved = resolveAuth(auth);
6383
+ const picked = pickImageProvider(prefs, resolved, fallback);
6384
+ const usingSvgGen = picked.svgPath === "chat_model_only";
6385
+ const review = pickReviewProvider(prefs, resolved, fallback, usingSvgGen);
6386
+ return {
6387
+ image: picked.provider,
6388
+ review,
6389
+ svgFallback: usingSvgGen,
6390
+ pngToSvgTranscriber: picked.pngToSvgTranscriber,
6391
+ svgPath: picked.svgPath ?? null
6392
+ };
6393
+ }
6394
+ const KIND_BUCKET = {
6395
+ layout_collision: "visual",
6396
+ style_mismatch: "visual",
6397
+ illegible_text: "visual",
6398
+ missing_element: "content",
6399
+ wrong_content: "content"
6400
+ };
6401
+ const PREFIX_LEN = 20;
6402
+ const MIN_SHARED_TOKENS = 5;
6403
+ const MIN_JACCARD = 0.2;
6404
+ function normalise(s) {
6405
+ return s.toLowerCase().replace(/\s+/g, " ").trim();
6406
+ }
6407
+ function panelNumbers(s) {
6408
+ const out = /* @__PURE__ */ new Set();
6409
+ for (const m of normalise(s).matchAll(/panel\s+(\d+)/g)) out.add(m[1]);
6410
+ return out;
6411
+ }
6412
+ function distinctiveTokens(s) {
6413
+ return new Set(
6414
+ normalise(s).replace(/[^a-z0-9\s]/g, " ").split(/\s+/).filter((w) => w.length >= 4)
6415
+ );
6416
+ }
6417
+ function issuesMatch(a, b) {
6418
+ if (KIND_BUCKET[a.kind] !== KIND_BUCKET[b.kind]) return false;
6419
+ const panelsA = panelNumbers(a.description);
6420
+ const panelsB = panelNumbers(b.description);
6421
+ if (panelsA.size > 0 && panelsB.size > 0) {
6422
+ let hasShared = false;
6423
+ for (const p of panelsA) {
6424
+ if (panelsB.has(p)) {
6425
+ hasShared = true;
6426
+ break;
6427
+ }
6428
+ }
6429
+ if (!hasShared) return false;
6430
+ }
6431
+ const aDesc = normalise(a.description);
6432
+ const bDesc = normalise(b.description);
6433
+ if (!aDesc || !bDesc) return false;
6434
+ const prefixLen = Math.min(PREFIX_LEN, aDesc.length, bDesc.length);
6435
+ if (prefixLen >= 10 && aDesc.slice(0, prefixLen) === bDesc.slice(0, prefixLen)) {
6436
+ return true;
6437
+ }
6438
+ const aTokens = distinctiveTokens(a.description);
6439
+ const bTokens = distinctiveTokens(b.description);
6440
+ let shared = 0;
6441
+ for (const t of aTokens) if (bTokens.has(t)) shared++;
6442
+ if (shared < MIN_SHARED_TOKENS) return false;
6443
+ const union = aTokens.size + bTokens.size - shared;
6444
+ if (union === 0) return false;
6445
+ return shared / union >= MIN_JACCARD;
6446
+ }
6447
+ function findMatch(target, pool) {
6448
+ return pool.find((candidate) => issuesMatch(target, candidate));
6449
+ }
6450
+ function fixedBetween(previous, current) {
6451
+ return previous.filter((p) => !findMatch(p, current));
6452
+ }
6453
+ function regressionsAgainst(fixedHistory, current) {
6454
+ return current.filter((c) => findMatch(c, fixedHistory));
6455
+ }
6456
+ const VALID_DOC_TYPES = [
6457
+ "journal",
6458
+ "conference",
6459
+ "thesis",
6460
+ "grant",
6461
+ "preprint",
6462
+ "report",
6463
+ "poster",
6464
+ "presentation",
6465
+ "default"
6466
+ ];
6467
+ const VALID_DIAGRAM_TYPES = [
6468
+ "flowchart",
6469
+ "architecture",
6470
+ "pathway",
6471
+ "circuit",
6472
+ "network",
6473
+ "conceptual",
6474
+ "auto"
6475
+ ];
6476
+ const VALID_REFERENCE_MODES = [
6477
+ "revise_layout",
6478
+ "style_only",
6479
+ "local_edit"
6480
+ ];
6481
+ const SUPPORTED_REFERENCE_MODES = ["revise_layout", "style_only"];
6482
+ const VALID_ASPECTS = ["auto", "square", "landscape", "portrait"];
6483
+ function aspectToSize(aspect) {
6484
+ switch (aspect) {
6485
+ case "square":
6486
+ return "1024x1024";
6487
+ case "landscape":
6488
+ return "1536x1024";
6489
+ case "portrait":
6490
+ return "1024x1536";
6491
+ case "auto":
6492
+ return "auto";
6493
+ }
6494
+ }
6495
+ function sanitizeAspect(value) {
6496
+ const v = typeof value === "string" ? value.trim().toLowerCase() : "auto";
6497
+ return VALID_ASPECTS.includes(v) ? v : "auto";
6498
+ }
6499
+ const VALID_FORMATS = ["auto", "png", "svg"];
6500
+ function sanitizeFormat(value) {
6501
+ const v = typeof value === "string" ? value.trim().toLowerCase() : "auto";
6502
+ return VALID_FORMATS.includes(v) ? v : "auto";
6503
+ }
6504
+ function resolveEffectiveFormat(format, extension) {
6505
+ if (format === "svg") return "svg";
6506
+ if (format === "png") return "png";
6507
+ return extension.toLowerCase() === ".svg" ? "svg" : "png";
6508
+ }
6509
+ const VALID_QUALITIES = ["low", "medium", "high", "auto"];
6510
+ const QUALITY_ORDER = ["low", "medium", "high"];
6511
+ function defaultQualityForDocType(docType) {
6512
+ switch (docType) {
6513
+ case "journal":
6514
+ case "conference":
6515
+ case "thesis":
6516
+ case "grant":
6517
+ return "high";
6518
+ case "preprint":
6519
+ case "report":
6520
+ case "poster":
6521
+ return "medium";
6522
+ case "presentation":
6523
+ return "low";
6524
+ default:
6525
+ return "medium";
6526
+ }
6527
+ }
6528
+ function sanitizeQuality(value) {
6529
+ if (value === void 0 || value === null) return void 0;
6530
+ const v = typeof value === "string" ? value.trim().toLowerCase() : "";
6531
+ return VALID_QUALITIES.includes(v) ? v : void 0;
6532
+ }
6533
+ function bumpedQuality(current) {
6534
+ const anchor = current === "auto" ? "medium" : current;
6535
+ const idx = QUALITY_ORDER.indexOf(anchor);
6536
+ if (idx < 0 || idx >= QUALITY_ORDER.length - 1) return current;
6537
+ return QUALITY_ORDER[idx + 1];
6538
+ }
6539
+ const GenerateDiagramSchema = Type.Object({
6540
+ prompt: Type.String({
6541
+ description: "Natural-language description of the diagram to generate. Be specific about components, labels, layout, and quantities."
6542
+ }),
6543
+ output: Type.String({
6544
+ description: 'Workspace-relative output path (e.g. "figures/consort.png"). Parent directories are created if missing.'
6545
+ }),
6546
+ doc_type: Type.Optional(Type.String({
6547
+ description: `Target publication venue. One of: ${VALID_DOC_TYPES.join(" | ")}. Controls quality threshold.`
6548
+ })),
6549
+ diagram_type: Type.Optional(Type.String({
6550
+ description: `Diagram category. One of: ${VALID_DIAGRAM_TYPES.join(" | ")}. Defaults to auto (keyword-detected).`
6551
+ })),
6552
+ iterations: Type.Optional(Type.Number({
6553
+ description: "Maximum refinement iterations (1-3). Each costs one generation + one review. Default: 2."
6554
+ })),
6555
+ aspect: Type.Optional(Type.String({
6556
+ description: 'Output aspect ratio. One of: auto | square | landscape | portrait. Default: auto (model picks from the prompt). Use "landscape" for wide architecture / flow diagrams with >3 horizontal panels, "portrait" for top-to-bottom CONSORT / PRISMA flows, "square" for single-concept schematics.'
6557
+ })),
6558
+ format: Type.Optional(Type.String({
6559
+ description: 'Desired output format: auto | png | svg. Default: auto (inferred from the output extension). Pass "svg" when the user says "SVG / 矢量图 / vector / 向量图", pass "png" when they say "PNG / 图片 / raster". Explicit format beats the extension: if format="svg" but output="foo.png", the file is renamed to foo.svg and extensionChanged is reported.'
6560
+ })),
6561
+ quality: Type.Optional(Type.String({
6562
+ description: "gpt-image-2 rendering quality: low | medium | high | auto. When omitted, defaults from doc_type — high for journal/conference/thesis/grant, medium for preprint/report/poster, low for presentation. Start at low for cheap exploration; the tool automatically bumps one tier on a needs_edit verdict in later iterations."
6563
+ })),
6564
+ reference_path: Type.Optional(Type.String({
6565
+ description: "Optional workspace-relative path to a reference image the first iteration edits instead of drawing from scratch. Requires reference_mode: revise_layout (other modes are not yet implemented)."
6566
+ })),
6567
+ reference_mode: Type.Optional(Type.String({
6568
+ description: `How to use the reference image. Only "revise_layout" is implemented in this version; "style_only" and "local_edit" are reserved and currently rejected. Default: revise_layout.`
6569
+ }))
6570
+ });
6571
+ function sanitizeDocType(value) {
6572
+ const v = typeof value === "string" ? value.trim().toLowerCase() : "";
6573
+ return VALID_DOC_TYPES.includes(v) ? v : "default";
6574
+ }
6575
+ function sanitizeDiagramType(value, prompt) {
6576
+ const v = typeof value === "string" ? value.trim().toLowerCase() : "auto";
6577
+ if (VALID_DIAGRAM_TYPES.includes(v)) {
6578
+ return v === "auto" ? detectDiagramType(prompt) : v;
6579
+ }
6580
+ return detectDiagramType(prompt);
6581
+ }
6582
+ function sanitizeIterations(value) {
6583
+ const n = typeof value === "number" ? value : Number(value);
6584
+ if (!Number.isFinite(n)) return 2;
6585
+ return Math.max(1, Math.min(3, Math.round(n)));
6586
+ }
6587
+ function sanitizeReferenceMode(value) {
6588
+ if (value === void 0 || value === null || typeof value === "string" && !value.trim()) {
6589
+ return { ok: true, mode: "revise_layout" };
6590
+ }
6591
+ const v = typeof value === "string" ? value.trim().toLowerCase() : "";
6592
+ if (!VALID_REFERENCE_MODES.includes(v)) {
6593
+ return {
6594
+ ok: false,
6595
+ value: typeof value === "string" ? value : String(value),
6596
+ reason: `Unknown reference_mode. Must be one of: ${VALID_REFERENCE_MODES.join(" | ")}.`
6597
+ };
6598
+ }
6599
+ if (!SUPPORTED_REFERENCE_MODES.includes(v)) {
6600
+ return {
6601
+ ok: false,
6602
+ value: v,
6603
+ reason: `reference_mode "${v}" is reserved but not yet implemented. Only ${SUPPORTED_REFERENCE_MODES.join(", ")} is available in this version.`
6604
+ };
6605
+ }
6606
+ return { ok: true, mode: v };
6607
+ }
6608
+ const CRITICAL_ISSUE_KINDS = [
6609
+ "wrong_content",
6610
+ "missing_element",
6611
+ "illegible_text"
6612
+ ];
6613
+ function hasCriticalIssue(issues) {
6614
+ return issues.some((i) => CRITICAL_ISSUE_KINDS.includes(i.kind));
6615
+ }
6616
+ function reconcileVerdict(review, threshold) {
6617
+ if (review.verdict === "acceptable") {
6618
+ if (review.score < threshold) {
6619
+ return {
6620
+ final: { ...review, verdict: "needs_edit" },
6621
+ override: `reviewer said acceptable but score ${review.score} < threshold ${threshold}`
6622
+ };
6623
+ }
6624
+ if (review.blockingIssues.length > 0) {
6625
+ const hasStructural = hasCriticalIssue(review.blockingIssues);
6626
+ return {
6627
+ final: { ...review, verdict: hasStructural ? "needs_regen" : "needs_edit" },
6628
+ override: `reviewer said acceptable but listed ${review.blockingIssues.length} blocking issues`
6629
+ };
6630
+ }
6631
+ return { final: review };
6632
+ }
6633
+ if (review.verdict === "needs_edit" && review.score >= threshold + 1 && !hasCriticalIssue(review.blockingIssues)) {
6634
+ return {
6635
+ final: { ...review, verdict: "acceptable" },
6636
+ override: `score ${review.score} ≥ threshold ${threshold} + 1.0 with no critical issues — accepting despite ${review.blockingIssues.length} minor note(s)`
6637
+ };
6638
+ }
6639
+ return { final: review };
6640
+ }
6641
+ function ensureInsideWorkspace(workspace, target) {
6642
+ const abs = path$1.resolve(workspace, target);
6643
+ const rel = path$1.relative(workspace, abs);
6644
+ if (rel.startsWith("..") || path$1.isAbsolute(rel)) {
6645
+ throw new Error(`Output path must live inside the workspace: ${target}`);
6646
+ }
6647
+ return abs;
6648
+ }
6649
+ function createGenerateDiagramTool(ctx) {
6650
+ return {
6651
+ name: "generate_diagram",
6652
+ label: "Generate Diagram",
6653
+ description: "Generate a publication-quality scientific diagram from a natural-language description. The tool runs a verdict-driven generate → review → (optional) edit loop using the configured image provider (OpenAI) and review provider (OpenAI or Anthropic). Supports flowcharts, architecture, pathways, circuits, networks, and conceptual frameworks. Prompt guidance: be specific about components, labels, exact counts/values, and layout direction. Decomposition: if the diagram has more than ~6 top-level components or crosses more than 2 logical regions/phases (e.g. offline+online, frontend+backend+storage, multi-stage pipelines with nested loops), prefer producing 2–3 linked diagrams (one per region/phase, plus an optional overview) over a single dense figure — gpt-image-2 reliability drops sharply on crowded geometry, and reviewer scores plateau around 7/10 for monolithic architectures.",
6654
+ parameters: GenerateDiagramSchema,
6655
+ execute: async (_toolCallId, rawParams) => {
6656
+ const params = rawParams;
6657
+ const userPrompt = typeof params.prompt === "string" ? params.prompt.trim() : "";
6658
+ const outputArg = typeof params.output === "string" ? params.output.trim() : "";
6659
+ if (!userPrompt) {
6660
+ return toAgentResult("generate_diagram", toolError("MISSING_PARAMETER", "Missing prompt.", {
6661
+ suggestions: ["Describe the diagram: type, components, labels, layout."]
6662
+ }));
6663
+ }
6664
+ if (!outputArg) {
6665
+ return toAgentResult("generate_diagram", toolError("MISSING_PARAMETER", "Missing output.", {
6666
+ suggestions: ["Provide a workspace-relative output path, e.g. figures/diagram.png."]
6667
+ }));
6668
+ }
6669
+ const docType = sanitizeDocType(params.doc_type);
6670
+ const diagramType = sanitizeDiagramType(params.diagram_type, userPrompt);
6671
+ const iterations = sanitizeIterations(params.iterations);
6672
+ const aspect = sanitizeAspect(params.aspect);
6673
+ const explicitQuality = sanitizeQuality(params.quality);
6674
+ const defaultQuality = defaultQualityForDocType(docType);
6675
+ const initialQuality = explicitQuality ?? defaultQuality;
6676
+ const hasReference = typeof params.reference_path === "string" && !!params.reference_path.trim();
6677
+ let referenceMode = "revise_layout";
6678
+ if (hasReference || params.reference_mode !== void 0) {
6679
+ const parsed = sanitizeReferenceMode(params.reference_mode);
6680
+ if (!parsed.ok) {
6681
+ return toAgentResult("generate_diagram", toolError("INVALID_PARAMETER", parsed.reason, {
6682
+ suggestions: [
6683
+ `Use reference_mode: ${SUPPORTED_REFERENCE_MODES.join(" or ")}.`,
6684
+ "Omit reference_path to draw without a reference image."
6685
+ ],
6686
+ context: { provided: parsed.value }
6687
+ }));
6688
+ }
6689
+ referenceMode = parsed.mode;
6690
+ }
6691
+ let absOutput;
6692
+ try {
6693
+ absOutput = ensureInsideWorkspace(ctx.workspacePath, outputArg);
6694
+ } catch (err) {
6695
+ return toAgentResult("generate_diagram", toolError("PATH_OUTSIDE_WORKSPACE", err.message, {
6696
+ suggestions: ["Use a path relative to the workspace root."]
6697
+ }));
6698
+ }
6699
+ const outDir = path$1.dirname(absOutput);
6700
+ const baseName = path$1.basename(absOutput, path$1.extname(absOutput));
6701
+ const originalExtension = path$1.extname(absOutput) || ".png";
6702
+ fs.mkdirSync(outDir, { recursive: true });
6703
+ const formatPref = sanitizeFormat(params.format);
6704
+ const effectiveFormat = resolveEffectiveFormat(formatPref, originalExtension);
6705
+ const userRequestedSvg = effectiveFormat === "svg";
6706
+ const liveSettings = ctx.getSettings?.() ?? ctx.settings;
6707
+ const reviewPref = liveSettings?.diagram?.reviewProvider ?? "auto";
6708
+ const prefs = {
6709
+ generation: "openai",
6710
+ review: reviewPref,
6711
+ imageSize: aspectToSize(aspect),
6712
+ imageQuality: initialQuality,
6713
+ svgAspect: aspect,
6714
+ forceSvg: userRequestedSvg
6715
+ };
6716
+ const auth = ctx.getDiagramAuth?.();
6717
+ const fallback = ctx.callLlm ? {
6718
+ callLlm: ctx.callLlm,
6719
+ rasterizeSvg: ctx.rasterizeSvg,
6720
+ // Vision channel powers Path A (PNG-anchored SVG transcription).
6721
+ // When absent, registry chooses Path B (hard error if user has
6722
+ // OpenAI key) or Path C (chat-model-only safety net).
6723
+ callLlmVision: ctx.callLlmVision,
6724
+ visionCapable: ctx.visionCapable
6725
+ } : void 0;
6726
+ let providers;
6727
+ try {
6728
+ providers = resolveProviders(prefs, auth, fallback);
6729
+ } catch (err) {
6730
+ const msg = err.message;
6731
+ if (msg.startsWith("SVG_REQUIRES_VISION_MODEL:")) {
6732
+ return toAgentResult("generate_diagram", toolError("SVG_REQUIRES_VISION_MODEL", msg.slice("SVG_REQUIRES_VISION_MODEL:".length).trim(), {
6733
+ suggestions: [
6734
+ "Switch the chat model to a vision-capable one (GPT-4o, Claude Opus 4.5, Gemini 2.5) under Settings → Model.",
6735
+ 'Or, request a raster output by using a .png extension instead of .svg, or by setting format: "png".'
6736
+ ]
6737
+ }));
6738
+ }
6739
+ return toAgentResult("generate_diagram", toolError("LLM_UNAVAILABLE", msg, {
6740
+ suggestions: [
6741
+ "Ask the user to add OPENAI_API_KEY under Settings → API Keys for native image generation (gpt-image-2). ChatGPT / Codex subscription tokens are scoped to the Codex endpoint and cannot call the Images API.",
6742
+ 'Alternatively, leave a "figure TBD" caption with a textual description so the user can regenerate later without re-explaining the intent.'
6743
+ ]
6744
+ }));
6745
+ }
6746
+ const pathAActive = providers.svgPath === "png_anchored" && !!providers.pngToSvgTranscriber;
6747
+ const providerFormat = providers.svgFallback ? "svg" : "png";
6748
+ const extension = providerFormat === "svg" ? ".svg" : ".png";
6749
+ const finalExtension = pathAActive ? ".svg" : extension;
6750
+ const finalAbsOutput = finalExtension !== originalExtension ? path$1.join(outDir, `${baseName}${finalExtension}`) : absOutput;
6751
+ const promptFormat = providerFormat === "svg" ? "svg" : "raster";
6752
+ let referenceBytes = null;
6753
+ if (hasReference) {
6754
+ try {
6755
+ const refAbs = ensureInsideWorkspace(ctx.workspacePath, params.reference_path.trim());
6756
+ if (!fs.existsSync(refAbs)) {
6757
+ return toAgentResult("generate_diagram", toolError("FILE_NOT_FOUND", `Reference image not found: ${params.reference_path}`, {
6758
+ suggestions: ["Check the reference_path is relative to the workspace root."]
6759
+ }));
6760
+ }
6761
+ referenceBytes = fs.readFileSync(refAbs);
6762
+ } catch (err) {
6763
+ return toAgentResult("generate_diagram", toolError("PATH_OUTSIDE_WORKSPACE", err.message));
6764
+ }
6765
+ }
6766
+ const threshold = providers.review.thresholds[docType] ?? providers.review.thresholds.default;
6767
+ const houseProfileSummary = renderProfile(DEFAULT_HOUSE_PROFILE).summaryForReviewer;
6768
+ const history = [];
6769
+ let prevImage = referenceBytes && referenceMode !== "style_only" ? referenceBytes : null;
6770
+ let stoppedEarly = false;
6771
+ let stoppedReason;
6772
+ const fixedSoFar = [];
6773
+ let currentQuality = initialQuality;
6774
+ for (let i = 1; i <= iterations; i++) {
6775
+ const canEdit = !!providers.image.imageToImage && providers.image.capabilities.has("image_to_image");
6776
+ const lastReview = history[history.length - 1]?.review;
6777
+ let image;
6778
+ let usedEdit = false;
6779
+ let promptForThisIter;
6780
+ if (i === 1) {
6781
+ if (referenceBytes && referenceMode === "style_only" && canEdit) {
6782
+ promptForThisIter = composeStyleOnlyPrompt(userPrompt, diagramType, promptFormat);
6783
+ image = await providers.image.imageToImage(
6784
+ promptForThisIter,
6785
+ referenceBytes,
6786
+ { quality: currentQuality }
6787
+ );
6788
+ usedEdit = true;
6789
+ } else if (referenceBytes && referenceMode === "revise_layout" && canEdit) {
6790
+ promptForThisIter = composeSurgicalRevisionPrompt(userPrompt, diagramType);
6791
+ image = await providers.image.imageToImage(
6792
+ promptForThisIter,
6793
+ referenceBytes,
6794
+ { quality: currentQuality }
6795
+ );
6796
+ usedEdit = true;
6797
+ } else {
6798
+ promptForThisIter = composeGenerationPrompt(userPrompt, diagramType, promptFormat);
6799
+ image = await providers.image.textToImage(promptForThisIter, { quality: currentQuality });
6800
+ }
6801
+ } else if (lastReview?.verdict === "needs_edit" && prevImage && canEdit) {
6802
+ currentQuality = bumpedQuality(currentQuality);
6803
+ promptForThisIter = composeEditPrompt(
6804
+ userPrompt,
6805
+ diagramType,
6806
+ lastReview.blockingIssues,
6807
+ fixedSoFar
6808
+ );
6809
+ image = await providers.image.imageToImage(promptForThisIter, prevImage, { quality: currentQuality });
6810
+ usedEdit = true;
6811
+ } else {
6812
+ promptForThisIter = composeRegenPrompt(userPrompt, diagramType, lastReview?.blockingIssues ?? [], promptFormat);
6813
+ image = await providers.image.textToImage(promptForThisIter, { quality: currentQuality });
6814
+ }
6815
+ const iterPath = path$1.join(outDir, `${baseName}_v${i}${extension}`);
6816
+ fs.writeFileSync(iterPath, image);
6817
+ let rawReview;
6818
+ try {
6819
+ rawReview = await providers.review.review({
6820
+ image,
6821
+ prompt: userPrompt,
6822
+ docType,
6823
+ diagramType,
6824
+ iteration: i,
6825
+ maxIterations: iterations,
6826
+ houseProfileSummary
6827
+ });
6828
+ } catch (err) {
6829
+ return toAgentResult("generate_diagram", toolError("API_ERROR", `Review failed: ${err.message}`, {
6830
+ retryable: true,
6831
+ suggestions: ["Retry the tool call.", "Switch the review provider in Settings."],
6832
+ context: { imagePath: path$1.relative(ctx.workspacePath, iterPath) },
6833
+ data: { partialPath: path$1.relative(ctx.workspacePath, iterPath) }
6834
+ }));
6835
+ }
6836
+ const reconciled = reconcileVerdict(rawReview, threshold);
6837
+ const review = reconciled.final;
6838
+ const regressed = regressionsAgainst(fixedSoFar, review.blockingIssues);
6839
+ history.push({
6840
+ iteration: i,
6841
+ imagePath: path$1.relative(ctx.workspacePath, iterPath),
6842
+ usedEdit,
6843
+ quality: currentQuality,
6844
+ review,
6845
+ rawVerdict: rawReview.verdict,
6846
+ verdictOverrideReason: reconciled.override,
6847
+ regressedIssues: regressed.length > 0 ? regressed : void 0,
6848
+ promptSnippet: promptForThisIter.slice(0, 400)
6849
+ });
6850
+ prevImage = image;
6851
+ if (lastReview) {
6852
+ const newlyFixed = fixedBetween(lastReview.blockingIssues, review.blockingIssues);
6853
+ for (const f of newlyFixed) fixedSoFar.push(f);
6854
+ }
6855
+ if (review.verdict === "acceptable") {
6856
+ stoppedEarly = i < iterations;
6857
+ stoppedReason = "acceptable";
6858
+ break;
6859
+ }
6860
+ if (regressed.length > 0 && i < iterations) {
6861
+ stoppedEarly = true;
6862
+ stoppedReason = `regression_detected: ${regressed.length} previously-fixed issue(s) returned`;
6863
+ break;
6864
+ }
6865
+ }
6866
+ const last = history[history.length - 1];
6867
+ if (!last) {
6868
+ return toAgentResult("generate_diagram", toolError("EXECUTION_FAILED", "No iterations completed.", {
6869
+ retryable: true
6870
+ }));
6871
+ }
6872
+ const finalAbsIter = path$1.resolve(ctx.workspacePath, last.imagePath);
6873
+ let transcriptionRecord;
6874
+ if (pathAActive && providers.pngToSvgTranscriber) {
6875
+ const pngBytes = fs.readFileSync(finalAbsIter);
6876
+ let transcribed;
6877
+ try {
6878
+ transcribed = await providers.pngToSvgTranscriber(pngBytes, userPrompt);
6879
+ } catch (err) {
6880
+ return toAgentResult("generate_diagram", toolError("SVG_TRANSCRIPTION_FAILED", err.message, {
6881
+ retryable: true,
6882
+ suggestions: [
6883
+ 'Retry the tool call with format: "png" — the PNG anchor was already generated successfully.',
6884
+ "Or, switch to a different vision-capable chat model and retry."
6885
+ ],
6886
+ context: { pngAnchorPath: path$1.relative(ctx.workspacePath, finalAbsIter) }
6887
+ }));
6888
+ }
6889
+ const anchorAbs = path$1.join(outDir, `${baseName}.png`);
6890
+ if (history.length === 1 && finalAbsIter !== anchorAbs) {
6891
+ fs.renameSync(finalAbsIter, anchorAbs);
6892
+ last.imagePath = path$1.relative(ctx.workspacePath, anchorAbs);
6893
+ } else if (finalAbsIter !== anchorAbs) {
6894
+ fs.copyFileSync(finalAbsIter, anchorAbs);
6895
+ }
6896
+ fs.writeFileSync(finalAbsOutput, transcribed.svg, "utf-8");
6897
+ transcriptionRecord = {
6898
+ repaired: transcribed.repaired,
6899
+ firstAttemptFailure: transcribed.firstAttemptFailure,
6900
+ visionModelLabel: transcribed.visionModelLabel,
6901
+ anchorPngPath: path$1.relative(ctx.workspacePath, anchorAbs),
6902
+ svgBytes: Buffer.byteLength(transcribed.svg, "utf-8")
6903
+ };
6904
+ } else if (finalAbsIter !== finalAbsOutput) {
6905
+ if (history.length === 1) {
6906
+ fs.renameSync(finalAbsIter, finalAbsOutput);
6907
+ last.imagePath = path$1.relative(ctx.workspacePath, finalAbsOutput);
6908
+ } else {
6909
+ fs.copyFileSync(finalAbsIter, finalAbsOutput);
6910
+ }
6911
+ }
6912
+ const reviewLogPath = path$1.join(outDir, `${baseName}_review_log.json`);
6913
+ const logPayload = {
6914
+ prompt: userPrompt,
6915
+ docType,
6916
+ diagramType,
6917
+ aspect,
6918
+ format: {
6919
+ requested: formatPref,
6920
+ effectiveFromParams: effectiveFormat,
6921
+ actual: providerFormat,
6922
+ originalExtension,
6923
+ finalExtension
6924
+ },
6925
+ threshold,
6926
+ // mode classifications:
6927
+ // 'image' — PNG path, no SVG output requested
6928
+ // 'svg_fallback' — Path C, chat-model SVG synthesis (no OpenAI key)
6929
+ // 'png_anchored_svg' — Path A, PNG-anchored vision transcription
6930
+ mode: providers.svgFallback ? "svg_fallback" : pathAActive ? "png_anchored_svg" : "image",
6931
+ svgPath: providers.svgPath,
6932
+ transcription: transcriptionRecord ?? void 0,
6933
+ quality: {
6934
+ initial: initialQuality,
6935
+ defaultForDocType: defaultQuality,
6936
+ explicit: explicitQuality ?? null
6937
+ },
6938
+ houseProfile: DEFAULT_HOUSE_PROFILE.id,
6939
+ provider: {
6940
+ image: providers.image.id,
6941
+ review: providers.review.id
6942
+ },
6943
+ iterations: history.map((h) => ({
6944
+ iteration: h.iteration,
6945
+ imagePath: h.imagePath,
6946
+ usedEdit: h.usedEdit,
6947
+ quality: h.quality,
6948
+ review: h.review,
6949
+ rawVerdict: h.rawVerdict,
6950
+ verdictOverrideReason: h.verdictOverrideReason,
6951
+ regressedIssues: h.regressedIssues
6952
+ })),
6953
+ stoppedEarly,
6954
+ stoppedReason: stoppedReason ?? (stoppedEarly ? void 0 : "max_iterations"),
6955
+ fixedAcrossIterations: fixedSoFar
6956
+ };
6957
+ fs.writeFileSync(reviewLogPath, JSON.stringify(logPayload, null, 2), "utf-8");
6958
+ const payload = {
6959
+ outputPath: path$1.relative(ctx.workspacePath, finalAbsOutput),
6960
+ absoluteOutputPath: finalAbsOutput,
6961
+ iterations: history,
6962
+ finalScore: last.review.score,
6963
+ finalVerdict: last.review.verdict,
6964
+ threshold,
6965
+ mode: providers.svgFallback ? "svg_fallback" : pathAActive ? "png_anchored_svg" : "image",
6966
+ provider: {
6967
+ image: providers.image.id,
6968
+ review: providers.review.id
6969
+ },
6970
+ reviewLogPath: path$1.relative(ctx.workspacePath, reviewLogPath),
6971
+ stoppedEarly,
6972
+ stoppedReason: stoppedReason ?? (stoppedEarly ? void 0 : "max_iterations"),
6973
+ extensionChanged: originalExtension !== finalExtension ? { requested: originalExtension, actual: finalExtension } : void 0,
6974
+ transcription: transcriptionRecord ? {
6975
+ anchorPngPath: transcriptionRecord.anchorPngPath,
6976
+ repaired: transcriptionRecord.repaired,
6977
+ visionModelLabel: transcriptionRecord.visionModelLabel,
6978
+ svgBytes: transcriptionRecord.svgBytes
6979
+ } : void 0
6980
+ };
6981
+ return toAgentResult("generate_diagram", { success: true, data: payload });
6982
+ }
6983
+ };
6984
+ }
4586
6985
  function isTerminal(state) {
4587
6986
  return state === "completed" || state === "failed" || state === "timed_out" || state === "cancelled";
4588
6987
  }
@@ -6847,7 +9246,7 @@ function rebuildIndex() {
6847
9246
  const lines = ["# Paper Wiki Index\n"];
6848
9247
  lines.push("## Papers\n");
6849
9248
  if (existsSync(papersDir)) {
6850
- const files = readdirSync$1(papersDir).filter((f) => f.endsWith(".md")).sort();
9249
+ const files = readdirSync(papersDir).filter((f) => f.endsWith(".md")).sort();
6851
9250
  for (const file of files) {
6852
9251
  const content = safeReadFile(join(papersDir, file));
6853
9252
  if (!content) continue;
@@ -6859,7 +9258,7 @@ function rebuildIndex() {
6859
9258
  if (lines[lines.length - 1] === "## Papers\n") lines.push("_(empty)_");
6860
9259
  lines.push("\n## Concepts\n");
6861
9260
  if (existsSync(conceptsDir)) {
6862
- const files = readdirSync$1(conceptsDir).filter((f) => f.endsWith(".md")).sort();
9261
+ const files = readdirSync(conceptsDir).filter((f) => f.endsWith(".md")).sort();
6863
9262
  for (const file of files) {
6864
9263
  const content = safeReadFile(join(conceptsDir, file));
6865
9264
  if (!content) continue;
@@ -6897,19 +9296,19 @@ function readRecentLog(n = 20) {
6897
9296
  function countPaperPages() {
6898
9297
  const dir = join(getWikiRoot(), "papers");
6899
9298
  if (!existsSync(dir)) return 0;
6900
- return readdirSync$1(dir).filter((f) => f.endsWith(".md")).length;
9299
+ return readdirSync(dir).filter((f) => f.endsWith(".md")).length;
6901
9300
  }
6902
9301
  function countConceptPages() {
6903
9302
  const dir = join(getWikiRoot(), "concepts");
6904
9303
  if (!existsSync(dir)) return 0;
6905
- return readdirSync$1(dir).filter((f) => f.endsWith(".md")).length;
9304
+ return readdirSync(dir).filter((f) => f.endsWith(".md")).length;
6906
9305
  }
6907
9306
  function listWikiPages() {
6908
9307
  const root = getWikiRoot();
6909
9308
  const readEntries = (subdir, kind) => {
6910
9309
  const dir = join(root, subdir);
6911
9310
  if (!existsSync(dir)) return [];
6912
- return readdirSync$1(dir).filter((f) => f.endsWith(".md")).map((f) => {
9311
+ return readdirSync(dir).filter((f) => f.endsWith(".md")).map((f) => {
6913
9312
  const slug = f.replace(".md", "");
6914
9313
  const content = safeReadFile(join(dir, f));
6915
9314
  const titleMatch = content?.match(/^#\s+(.+)$/m);
@@ -7431,7 +9830,7 @@ function rebuildMemoryIndex() {
7431
9830
  if (!existsSync(papersDir)) {
7432
9831
  return { numPapers: 0, numTokens: 0, numAliases: 0, numEdges: 0 };
7433
9832
  }
7434
- const files = readdirSync$1(papersDir).filter((f) => f.endsWith(".md"));
9833
+ const files = readdirSync(papersDir).filter((f) => f.endsWith(".md"));
7435
9834
  const papers = [];
7436
9835
  for (const file of files) {
7437
9836
  const slug = file.replace(/\.md$/, "");
@@ -7906,7 +10305,7 @@ function listAllPaperSlugs() {
7906
10305
  const dir = join(getWikiRoot(), "papers");
7907
10306
  if (!existsSync(dir)) return [];
7908
10307
  try {
7909
- return readdirSync$1(dir).filter((f) => f.endsWith(".md")).map((f) => f.replace(/\.md$/, ""));
10308
+ return readdirSync(dir).filter((f) => f.endsWith(".md")).map((f) => f.replace(/\.md$/, ""));
7910
10309
  } catch {
7911
10310
  return [];
7912
10311
  }
@@ -8348,6 +10747,7 @@ function createResearchTools(ctx) {
8348
10747
  tools.push(createLiteratureSearchTool(ctx));
8349
10748
  tools.push(createConvertDocumentTool(ctx));
8350
10749
  tools.push(createDataAnalyzeTool(ctx));
10750
+ tools.push(createGenerateDiagramTool(ctx));
8351
10751
  const artifactTools = createResearchMemoryTools({
8352
10752
  sessionId: ctx.sessionId,
8353
10753
  projectPath: ctx.projectPath
@@ -8407,7 +10807,7 @@ function simplifyMessages(messages, maxMessages) {
8407
10807
  const text = block.text;
8408
10808
  content += text.length > 500 ? text.slice(0, 500) + "...[truncated]" : text;
8409
10809
  content += "\n";
8410
- } else if (block.type === "tool_use" && "name" in block) {
10810
+ } else if (block.type === "toolCall" && "name" in block) {
8411
10811
  content += `[Called ${block.name}]
8412
10812
  `;
8413
10813
  }
@@ -8431,7 +10831,7 @@ function agentCalledSaveMemoryThisTurn(messages) {
8431
10831
  if (msg.role === "user") break;
8432
10832
  if (msg.role === "assistant" && Array.isArray(msg.content)) {
8433
10833
  for (const block of msg.content) {
8434
- if (block && typeof block === "object" && "type" in block && block.type === "tool_use") {
10834
+ if (block && typeof block === "object" && "type" in block && block.type === "toolCall") {
8435
10835
  if (block.name === "save-memory") return true;
8436
10836
  }
8437
10837
  }
@@ -8456,6 +10856,8 @@ async function maybeExtractMemories(config, messages, turnCount, extractEveryN =
8456
10856
  });
8457
10857
  const result = await completeSimple(config.model, {
8458
10858
  systemPrompt: config.systemPrompt,
10859
+ // pi-ai's Message union requires extra fields on assistant turns, but
10860
+ // for this context-only replay the providers read role/content/timestamp.
8459
10861
  messages: simplified
8460
10862
  }, {
8461
10863
  maxTokens: 1024,
@@ -8829,11 +11231,11 @@ function detectIntentsByRules(message) {
8829
11231
  return intents;
8830
11232
  }
8831
11233
  const MAX_SKILL_PRELOAD = 5;
8832
- async function matchSkillsWithLLM(model, apiKey, message, skills) {
11234
+ async function matchSkillsWithLLM(model, apiKey, message, skills, priorTurns = []) {
8833
11235
  if (!model || skills.length === 0) return [];
8834
11236
  const skillList = skills.map((s) => `- ${s.name}: ${s.description}`).join("\n");
8835
11237
  const systemPrompt = [
8836
- "You are a skill router for a research assistant. Given a user message, select which skills should be activated.",
11238
+ "You are a skill router for a research assistant. Given a user message (and recent conversation context, if any), select which skills should be activated.",
8837
11239
  "Return ONLY a JSON array of skill names. Return [] if none are relevant.",
8838
11240
  "",
8839
11241
  "Rules:",
@@ -8841,14 +11243,22 @@ async function matchSkillsWithLLM(model, apiKey, message, skills) {
8841
11243
  "- Do not select skills speculatively",
8842
11244
  `- Maximum ${MAX_SKILL_PRELOAD} skills`,
8843
11245
  "- Consider both English and Chinese messages",
11246
+ '- If the current message is a short follow-up or confirmation (e.g. "yes", "do that", "go ahead", "好的", "继续"), infer intent from the recent context',
8844
11247
  "",
8845
11248
  "Available skills:",
8846
11249
  skillList
8847
11250
  ].join("\n");
11251
+ const contextBlock = priorTurns.length > 0 ? priorTurns.map((t) => `User: ${t.userMessage}
11252
+ Assistant: ${t.response}`).join("\n\n") : "";
11253
+ const userContent = contextBlock ? `Recent conversation:
11254
+ ${contextBlock}
11255
+
11256
+ Current user message:
11257
+ ${message}` : message;
8848
11258
  try {
8849
11259
  const result = await completeSimple(model, {
8850
11260
  systemPrompt,
8851
- messages: [{ role: "user", content: message, timestamp: Date.now() }]
11261
+ messages: [{ role: "user", content: userContent, timestamp: Date.now() }]
8852
11262
  }, {
8853
11263
  maxTokens: 100,
8854
11264
  apiKey
@@ -9035,9 +11445,41 @@ async function createCoordinator(config) {
9035
11445
  const textContent = result.content.find((c) => c.type === "text");
9036
11446
  return textContent?.text ?? "";
9037
11447
  },
11448
+ // Vision-capable sibling of callLlm. Mirrors the stateless completeSimple
11449
+ // shape above plus the ImageContent transformation used by chat() at the
11450
+ // user-attached-images path. Reused by the diagram tool's PNG-anchored
11451
+ // SVG transcription path, where a final PNG is fed back to the model to
11452
+ // be re-emitted as editable SVG markup.
11453
+ callLlmVision: async (systemPrompt, userContent, images) => {
11454
+ if (!piModel) throw new Error("No model available for sub-call");
11455
+ if (!piModel.input.includes("image")) {
11456
+ throw new Error(
11457
+ `Selected model "${piModel.id}" does not accept image input. Switch to a vision-capable model (e.g. GPT-4o, Claude Opus, Gemini 2.5).`
11458
+ );
11459
+ }
11460
+ const currentKey = await resolveApiKey();
11461
+ const content = [
11462
+ { type: "text", text: userContent },
11463
+ ...images.map((img) => ({
11464
+ type: "image",
11465
+ data: img.base64,
11466
+ mimeType: img.mimeType
11467
+ }))
11468
+ ];
11469
+ const result = await completeSimple(piModel, {
11470
+ systemPrompt,
11471
+ messages: [{ role: "user", content, timestamp: Date.now() }]
11472
+ }, { maxTokens: 8192, apiKey: currentKey });
11473
+ const textContent = result.content.find((c) => c.type === "text");
11474
+ return textContent?.text ?? "";
11475
+ },
11476
+ visionCapable: !!piModel?.input.includes("image"),
9038
11477
  onToolCall,
9039
11478
  onToolResult: wrappedOnToolResult,
9040
- settings: config.resolvedSettings
11479
+ settings: config.resolvedSettings,
11480
+ getSettings: config.getResolvedSettings,
11481
+ getDiagramAuth: config.getDiagramAuth,
11482
+ rasterizeSvg: config.rasterizeSvg
9041
11483
  };
9042
11484
  const { tools: researchAgentTools, destroy: destroyResearchTools } = createResearchTools(toolCtx);
9043
11485
  const codingTools = createCodingTools(projectPath);
@@ -9118,6 +11560,7 @@ async function createCoordinator(config) {
9118
11560
  piModel,
9119
11561
  settings.reserveTokens,
9120
11562
  currentKey,
11563
+ void 0,
9121
11564
  signal,
9122
11565
  void 0,
9123
11566
  compactionSummary
@@ -9256,8 +11699,12 @@ ${historyText}`,
9256
11699
  async chat(message, mentions, images) {
9257
11700
  try {
9258
11701
  const intents = detectIntentsByRules(message);
11702
+ const priorTurns = turnHistory.slice(-2).map((t) => ({
11703
+ userMessage: t.userMessage,
11704
+ response: t.response
11705
+ }));
9259
11706
  const currentKey = await resolveApiKey();
9260
- const matchedSkillNames = await matchSkillsWithLLM(intentRouterModel, currentKey, message, skills);
11707
+ const matchedSkillNames = await matchSkillsWithLLM(intentRouterModel, currentKey, message, skills, priorTurns);
9261
11708
  const matchedSkills = matchedSkillNames.map((name) => skills.find((s) => s.name === name)).filter((s) => s !== void 0);
9262
11709
  for (const s of matchedSkills) {
9263
11710
  onSkillLoaded?.(s.name);
@@ -9431,7 +11878,7 @@ ${message}` : message;
9431
11878
  function readLatestExplainTurn(projectPath) {
9432
11879
  const dir = join(projectPath, PATHS.explainDir);
9433
11880
  if (!existsSync(dir)) return null;
9434
- const files = readdirSync$1(dir).filter((name) => name.endsWith(".turn.json")).sort();
11881
+ const files = readdirSync(dir).filter((name) => name.endsWith(".turn.json")).sort();
9435
11882
  if (files.length === 0) return null;
9436
11883
  const latest = files[files.length - 1];
9437
11884
  return JSON.parse(readFileSync(join(dir, latest), "utf-8"));
@@ -9894,7 +12341,9 @@ function toPaperInput(paper) {
9894
12341
  };
9895
12342
  }
9896
12343
  function hasCoreMetadataDelta(before, after) {
9897
- return after.venue && after.venue !== before.venue || after.doi && after.doi !== before.doi || after.citationCount != null && after.citationCount !== before.citationCount || after.url && after.url !== before.url || after.abstract && after.abstract !== before.abstract;
12344
+ return Boolean(
12345
+ after.venue && after.venue !== before.venue || after.doi && after.doi !== before.doi || after.citationCount != null && after.citationCount !== before.citationCount || after.url && after.url !== before.url || after.abstract && after.abstract !== before.abstract
12346
+ );
9898
12347
  }
9899
12348
  async function enrichPaperArtifacts(options) {
9900
12349
  const { projectPath, sessionId, debug, paperIds, onProgress } = options;
@@ -10153,7 +12602,7 @@ function resolveEntity(ref, dir, entityType, projectPath) {
10153
12602
  if (!existsSync(dir)) {
10154
12603
  return { ref, label: ref.raw, content: "", error: `No ${entityType} directory found` };
10155
12604
  }
10156
- const files = readdirSync$1(dir).filter((f) => f.endsWith(".json"));
12605
+ const files = readdirSync(dir).filter((f) => f.endsWith(".json"));
10157
12606
  const key = ref.key.toLowerCase();
10158
12607
  for (const file of files) {
10159
12608
  try {
@@ -10367,7 +12816,7 @@ function walk(root, rel, depth, out) {
10367
12816
  const dir = rel ? join(root, rel) : root;
10368
12817
  let entries;
10369
12818
  try {
10370
- entries = readdirSync$1(dir);
12819
+ entries = readdirSync(dir);
10371
12820
  } catch {
10372
12821
  return;
10373
12822
  }
@@ -11109,7 +13558,8 @@ function resolveSettings(settings) {
11109
13558
  researchIntensity: resolveResearchIntensity(settings.research.researchIntensity),
11110
13559
  webSearch: resolveWebSearchDepth(settings.research.webSearchDepth),
11111
13560
  dataAnalysis: { timeoutMs: resolveDataAnalysisTimeout(settings.dataAnalysis.executionTimeLimit) },
11112
- autoSaveThreshold: resolveAutoSaveThreshold(settings.research.autoSaveSensitivity)
13561
+ autoSaveThreshold: resolveAutoSaveThreshold(settings.research.autoSaveSensitivity),
13562
+ diagram: { reviewProvider: settings.diagram?.reviewProvider ?? "auto" }
11113
13563
  };
11114
13564
  }
11115
13565
  let _wikiLock = Promise.resolve();
@@ -11310,7 +13760,7 @@ function applyIdentityMigration(change) {
11310
13760
  if (oldSlug !== newSlug) {
11311
13761
  const conceptsDir = join(root, "concepts");
11312
13762
  if (existsSync(conceptsDir)) {
11313
- for (const f of readdirSync$1(conceptsDir)) {
13763
+ for (const f of readdirSync(conceptsDir)) {
11314
13764
  if (!f.endsWith(".md")) continue;
11315
13765
  const path2 = join(conceptsDir, f);
11316
13766
  const content = safeReadFile(path2);
@@ -11718,7 +14168,7 @@ function listExistingConceptSlugs() {
11718
14168
  const dir = join(getWikiRoot(), "concepts");
11719
14169
  if (!existsSync(dir)) return [];
11720
14170
  try {
11721
- return readdirSync$1(dir).filter((f) => f.endsWith(".md")).map((f) => f.replace(".md", ""));
14171
+ return readdirSync(dir).filter((f) => f.endsWith(".md")).map((f) => f.replace(".md", ""));
11722
14172
  } catch {
11723
14173
  return [];
11724
14174
  }
@@ -11899,7 +14349,7 @@ function createWikiAgent(config) {
11899
14349
  }
11900
14350
  function emitStatus(pending = 0) {
11901
14351
  if (!config.onStatus) return;
11902
- const totalInWiki = existsSync(join(getWikiRoot(), "papers")) ? readdirSync$1(join(getWikiRoot(), "papers")).filter((f) => f.endsWith(".md")).length : 0;
14352
+ const totalInWiki = existsSync(join(getWikiRoot(), "papers")) ? readdirSync(join(getWikiRoot(), "papers")).filter((f) => f.endsWith(".md")).length : 0;
11903
14353
  config.onStatus({
11904
14354
  state: state === "processing" ? "processing" : state === "paused" ? "paused" : "idle",
11905
14355
  processed: processedThisSession,
@@ -12356,7 +14806,7 @@ function listWikiPaperMeta() {
12356
14806
  if (!existsSync(papersDir)) return [];
12357
14807
  const seen = /* @__PURE__ */ new Set();
12358
14808
  const results = [];
12359
- for (const file of readdirSync$1(papersDir)) {
14809
+ for (const file of readdirSync(papersDir)) {
12360
14810
  if (!file.endsWith(".md")) continue;
12361
14811
  const slug = file.slice(0, -3);
12362
14812
  const filePath = join(papersDir, file);
@@ -12423,7 +14873,7 @@ function findDriftGroups() {
12423
14873
  }
12424
14874
  }
12425
14875
  const byTitle = /* @__PURE__ */ new Map();
12426
- for (const f of readdirSync$1(papersDir)) {
14876
+ for (const f of readdirSync(papersDir)) {
12427
14877
  if (!f.endsWith(".md")) continue;
12428
14878
  const slug = f.slice(0, -3);
12429
14879
  const content = safeReadFile(join(papersDir, f));
@@ -12521,7 +14971,7 @@ async function reconcileIdentityDrift(opts = { dryRun: true }) {
12521
14971
  const papersDir = join(getWikiRoot(), "papers");
12522
14972
  const liveSlugs = /* @__PURE__ */ new Set();
12523
14973
  if (existsSync(papersDir)) {
12524
- for (const f of readdirSync$1(papersDir)) {
14974
+ for (const f of readdirSync(papersDir)) {
12525
14975
  if (f.endsWith(".md")) liveSlugs.add(f.slice(0, -3));
12526
14976
  }
12527
14977
  }
@@ -12539,6 +14989,61 @@ async function reconcileIdentityDrift(opts = { dryRun: true }) {
12539
14989
  };
12540
14990
  });
12541
14991
  }
14992
+ const DEFAULT_WIDTH = 1200;
14993
+ const DEFAULT_HEIGHT = 900;
14994
+ const SETTLE_MS = 200;
14995
+ function viewBoxDimensions(svg) {
14996
+ const match = svg.match(/viewBox\s*=\s*"([^"]+)"/i);
14997
+ if (!match) return null;
14998
+ const parts = match[1].trim().split(/[\s,]+/).map(Number);
14999
+ if (parts.length !== 4 || parts.some((n) => !Number.isFinite(n))) return null;
15000
+ const [, , w, h] = parts;
15001
+ if (w <= 0 || h <= 0) return null;
15002
+ return { width: Math.round(w), height: Math.round(h) };
15003
+ }
15004
+ function wrapHtml(svg, width, height) {
15005
+ return [
15006
+ '<!doctype html><html><head><meta charset="utf-8"><style>',
15007
+ `html, body { margin: 0; padding: 0; background: #ffffff; width: ${width}px; height: ${height}px; overflow: hidden; }`,
15008
+ "svg { display: block; width: 100%; height: 100%; }",
15009
+ "</style></head><body>",
15010
+ svg,
15011
+ "</body></html>"
15012
+ ].join("");
15013
+ }
15014
+ async function rasterizeSvg(svg, options = {}) {
15015
+ const svgStr = svg.toString("utf-8");
15016
+ const vb = viewBoxDimensions(svgStr);
15017
+ const width = options.width ?? vb?.width ?? DEFAULT_WIDTH;
15018
+ const height = options.height ?? vb?.height ?? DEFAULT_HEIGHT;
15019
+ const win = new BrowserWindow({
15020
+ width,
15021
+ height,
15022
+ show: false,
15023
+ frame: false,
15024
+ transparent: false,
15025
+ webPreferences: {
15026
+ offscreen: true,
15027
+ backgroundThrottling: false,
15028
+ nodeIntegration: false,
15029
+ contextIsolation: true,
15030
+ sandbox: true,
15031
+ // Sanity: SVG content is produced by our own prompt; nothing is user-
15032
+ // supplied from a network. Still, disable remote resources to be safe.
15033
+ webSecurity: true
15034
+ }
15035
+ });
15036
+ try {
15037
+ const html = wrapHtml(svgStr, width, height);
15038
+ const dataUrl = "data:text/html;charset=utf-8," + encodeURIComponent(html);
15039
+ await win.loadURL(dataUrl);
15040
+ await new Promise((resolve2) => setTimeout(resolve2, SETTLE_MS));
15041
+ const image = await win.webContents.capturePage();
15042
+ return image.toPNG();
15043
+ } finally {
15044
+ if (!win.isDestroyed()) win.destroy();
15045
+ }
15046
+ }
12542
15047
  const EMPTY = {
12543
15048
  version: 1,
12544
15049
  updatedAt: (/* @__PURE__ */ new Date(0)).toISOString(),
@@ -12847,7 +15352,7 @@ async function ensureCoordinator(state, win, model, options) {
12847
15352
  if (creds.expires < Date.now() + 6e4) {
12848
15353
  try {
12849
15354
  const { refreshOpenAICodexToken } = await import("@mariozechner/pi-ai/oauth");
12850
- const newCreds = await refreshOpenAICodexToken(creds);
15355
+ const newCreds = await refreshOpenAICodexToken(creds.refresh);
12851
15356
  saveCodexCredentials(newCreds);
12852
15357
  return newCreds.access;
12853
15358
  } catch {
@@ -12860,12 +15365,46 @@ async function ensureCoordinator(state, win, model, options) {
12860
15365
  const initEvent = { type: "system", summary: "Initializing agent (first run may take 1-2 minutes for document processing setup)..." };
12861
15366
  state.realtimeBuffer.pushActivity(initEvent);
12862
15367
  safeSend(win, "agent:activity", initEvent);
15368
+ const getDiagramAuth = () => {
15369
+ const openaiKey = (process.env.OPENAI_API_KEY || "").trim() || null;
15370
+ const envAnthropic = (process.env.ANTHROPIC_API_KEY || "").trim();
15371
+ if (envAnthropic) {
15372
+ return { openaiKey, anthropic: { token: envAnthropic, isOAuth: false } };
15373
+ }
15374
+ const creds = loadAnthropicSubCredentials();
15375
+ if (creds?.access) {
15376
+ const refreshTokenSymbol = creds.refresh;
15377
+ return {
15378
+ openaiKey,
15379
+ anthropic: {
15380
+ token: creds.access,
15381
+ isOAuth: true,
15382
+ refresh: async () => {
15383
+ const { refreshAnthropicToken } = await import("@mariozechner/pi-ai/oauth");
15384
+ const fresh = await refreshAnthropicToken(refreshTokenSymbol);
15385
+ saveAnthropicSubCredentials(fresh);
15386
+ return fresh.access;
15387
+ }
15388
+ }
15389
+ };
15390
+ }
15391
+ return { openaiKey, anthropic: null };
15392
+ };
12863
15393
  state.coordinator = await createCoordinator({
12864
15394
  apiKey,
12865
15395
  getApiKeyOverride,
12866
15396
  model: state.currentModel,
12867
15397
  reasoningEffort: state.currentReasoningEffort,
12868
15398
  resolvedSettings: resolveSettings(loadSettingsFromConfig()),
15399
+ // Live settings reader: re-reads ~/.research-copilot/config.json per
15400
+ // tool call so diagram review-provider choice (and similar
15401
+ // presentation-layer settings) take effect without restart.
15402
+ getResolvedSettings: () => resolveSettings(loadSettingsFromConfig()),
15403
+ getDiagramAuth,
15404
+ // Only wired in Electron's main process; pure-Node contexts (tests)
15405
+ // will leave this undefined and the tool will degrade to source-
15406
+ // level SVG review.
15407
+ rasterizeSvg,
12869
15408
  projectPath: state.projectPath,
12870
15409
  sessionId: state.sessionId,
12871
15410
  debug: !!process.env.RESEARCH_COPILOT_DEBUG,
@@ -12923,16 +15462,14 @@ async function ensureCoordinator(state, win, model, options) {
12923
15462
  const r2 = result;
12924
15463
  if (r2.success) {
12925
15464
  invalidateEntityCache(runProjectPath);
12926
- if (tool === "artifact-create") {
12927
- safeSend(win, "agent:entity-created", {
12928
- type: r2.data?.type || "artifact",
12929
- id: r2.data?.id,
12930
- title: r2.data?.title
12931
- });
12932
- if (r2.data?.filePath) {
12933
- const absPath = isAbsolute(r2.data.filePath) ? r2.data.filePath : resolve(runProjectPath, r2.data.filePath);
12934
- safeSend(win, "agent:file-created", absPath);
12935
- }
15465
+ safeSend(win, "agent:entity-created", {
15466
+ type: r2.data?.type || "artifact",
15467
+ id: r2.data?.id,
15468
+ title: r2.data?.title
15469
+ });
15470
+ if (tool === "artifact-create" && r2.data?.filePath) {
15471
+ const absPath = isAbsolute(r2.data.filePath) ? r2.data.filePath : resolve(runProjectPath, r2.data.filePath);
15472
+ safeSend(win, "agent:file-created", absPath);
12936
15473
  }
12937
15474
  }
12938
15475
  }
@@ -13195,7 +15732,7 @@ function registerIpcHandlers() {
13195
15732
  if (creds.expires < Date.now() + 6e4) {
13196
15733
  try {
13197
15734
  const { refreshOpenAICodexToken } = await import("@mariozechner/pi-ai/oauth");
13198
- const newCreds = await refreshOpenAICodexToken(creds);
15735
+ const newCreds = await refreshOpenAICodexToken(creds.refresh);
13199
15736
  saveCodexCredentials(newCreds);
13200
15737
  return newCreds.access;
13201
15738
  } catch {