@openvulcan/dsh-vulcancode 0.0.0-stage → 0.4.3

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 (176) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +126 -2
  3. package/assets/openvulcan-icon.svg +33 -0
  4. package/cordis.patch.yml +19 -0
  5. package/node_modules/@openvulcan/dsh-decision-guard/LICENSE +21 -0
  6. package/node_modules/@openvulcan/dsh-decision-guard/README.md +14 -0
  7. package/node_modules/@openvulcan/dsh-decision-guard/assets/decision-guard-icon.png +0 -0
  8. package/node_modules/@openvulcan/dsh-decision-guard/lib/calls.d.ts +40 -0
  9. package/node_modules/@openvulcan/dsh-decision-guard/lib/calls.js +150 -0
  10. package/node_modules/@openvulcan/dsh-decision-guard/lib/client.js +2250 -0
  11. package/node_modules/@openvulcan/dsh-decision-guard/lib/decide.d.ts +90 -0
  12. package/node_modules/@openvulcan/dsh-decision-guard/lib/decide.js +108 -0
  13. package/node_modules/@openvulcan/dsh-decision-guard/lib/index.d.ts +129 -0
  14. package/node_modules/@openvulcan/dsh-decision-guard/lib/index.js +572 -0
  15. package/node_modules/@openvulcan/dsh-decision-guard/lib/intent.d.ts +22 -0
  16. package/node_modules/@openvulcan/dsh-decision-guard/lib/intent.js +77 -0
  17. package/node_modules/@openvulcan/dsh-decision-guard/lib/panel.d.ts +158 -0
  18. package/node_modules/@openvulcan/dsh-decision-guard/lib/panel.js +340 -0
  19. package/node_modules/@openvulcan/dsh-decision-guard/lib/records.d.ts +133 -0
  20. package/node_modules/@openvulcan/dsh-decision-guard/lib/records.js +186 -0
  21. package/node_modules/@openvulcan/dsh-decision-guard/lib/review.d.ts +55 -0
  22. package/node_modules/@openvulcan/dsh-decision-guard/lib/review.js +202 -0
  23. package/node_modules/@openvulcan/dsh-decision-guard/lib/session-state.d.ts +62 -0
  24. package/node_modules/@openvulcan/dsh-decision-guard/lib/session-state.js +155 -0
  25. package/node_modules/@openvulcan/dsh-decision-guard/lib/version.d.ts +2 -0
  26. package/node_modules/@openvulcan/dsh-decision-guard/lib/version.js +4 -0
  27. package/node_modules/@openvulcan/dsh-decision-guard/package.json +68 -0
  28. package/node_modules/@openvulcan/dsh-vulcan-account/LICENSE +21 -0
  29. package/node_modules/@openvulcan/dsh-vulcan-account/assets/account-icon.png +0 -0
  30. package/node_modules/@openvulcan/dsh-vulcan-account/lib/client.js +573 -0
  31. package/node_modules/@openvulcan/dsh-vulcan-account/lib/index.d.ts +92 -0
  32. package/node_modules/@openvulcan/dsh-vulcan-account/lib/index.js +194 -0
  33. package/node_modules/@openvulcan/dsh-vulcan-account/package.json +57 -0
  34. package/node_modules/@openvulcan/dsh-vulcan-answers/LICENSE +21 -0
  35. package/node_modules/@openvulcan/dsh-vulcan-answers/assets/answers-icon.png +0 -0
  36. package/node_modules/@openvulcan/dsh-vulcan-answers/assets/prompts/answer-mode-interactive.md +79 -0
  37. package/node_modules/@openvulcan/dsh-vulcan-answers/assets/prompts/answer-mode-standard.md +1 -0
  38. package/node_modules/@openvulcan/dsh-vulcan-answers/lib/answer-model.d.ts +258 -0
  39. package/node_modules/@openvulcan/dsh-vulcan-answers/lib/answer-model.js +698 -0
  40. package/node_modules/@openvulcan/dsh-vulcan-answers/lib/client.js +671 -0
  41. package/node_modules/@openvulcan/dsh-vulcan-answers/lib/fences.d.ts +29 -0
  42. package/node_modules/@openvulcan/dsh-vulcan-answers/lib/fences.js +70 -0
  43. package/node_modules/@openvulcan/dsh-vulcan-answers/lib/index.d.ts +19 -0
  44. package/node_modules/@openvulcan/dsh-vulcan-answers/lib/index.js +52 -0
  45. package/node_modules/@openvulcan/dsh-vulcan-answers/lib/limits.d.ts +9 -0
  46. package/node_modules/@openvulcan/dsh-vulcan-answers/lib/limits.js +9 -0
  47. package/node_modules/@openvulcan/dsh-vulcan-answers/lib/prompt.d.ts +22 -0
  48. package/node_modules/@openvulcan/dsh-vulcan-answers/lib/prompt.js +39 -0
  49. package/node_modules/@openvulcan/dsh-vulcan-answers/lib/reply-format.d.ts +20 -0
  50. package/node_modules/@openvulcan/dsh-vulcan-answers/lib/reply-format.js +17 -0
  51. package/node_modules/@openvulcan/dsh-vulcan-answers/lib/session-state.d.ts +66 -0
  52. package/node_modules/@openvulcan/dsh-vulcan-answers/lib/session-state.js +118 -0
  53. package/node_modules/@openvulcan/dsh-vulcan-answers/package.json +59 -0
  54. package/node_modules/@openvulcan/dsh-vulcan-luaskills/LICENSE +21 -0
  55. package/node_modules/@openvulcan/dsh-vulcan-luaskills/assets/luaskills-icon.svg +1 -0
  56. package/node_modules/@openvulcan/dsh-vulcan-luaskills/lib/client.js +949 -0
  57. package/node_modules/@openvulcan/dsh-vulcan-luaskills/lib/constants.d.ts +46 -0
  58. package/node_modules/@openvulcan/dsh-vulcan-luaskills/lib/constants.js +51 -0
  59. package/node_modules/@openvulcan/dsh-vulcan-luaskills/lib/engine.d.ts +55 -0
  60. package/node_modules/@openvulcan/dsh-vulcan-luaskills/lib/engine.js +149 -0
  61. package/node_modules/@openvulcan/dsh-vulcan-luaskills/lib/hub.d.ts +49 -0
  62. package/node_modules/@openvulcan/dsh-vulcan-luaskills/lib/hub.js +116 -0
  63. package/node_modules/@openvulcan/dsh-vulcan-luaskills/lib/index.d.ts +26 -0
  64. package/node_modules/@openvulcan/dsh-vulcan-luaskills/lib/index.js +276 -0
  65. package/node_modules/@openvulcan/dsh-vulcan-luaskills/lib/protocol.d.ts +65 -0
  66. package/node_modules/@openvulcan/dsh-vulcan-luaskills/lib/protocol.js +2 -0
  67. package/node_modules/@openvulcan/dsh-vulcan-luaskills/lib/runtime.d.ts +111 -0
  68. package/node_modules/@openvulcan/dsh-vulcan-luaskills/lib/runtime.js +281 -0
  69. package/node_modules/@openvulcan/dsh-vulcan-luaskills/lib/sdk-install.d.ts +26 -0
  70. package/node_modules/@openvulcan/dsh-vulcan-luaskills/lib/sdk-install.js +87 -0
  71. package/node_modules/@openvulcan/dsh-vulcan-luaskills/lib/settings.d.ts +79 -0
  72. package/node_modules/@openvulcan/dsh-vulcan-luaskills/lib/settings.js +165 -0
  73. package/node_modules/@openvulcan/dsh-vulcan-luaskills/lib/skills.d.ts +34 -0
  74. package/node_modules/@openvulcan/dsh-vulcan-luaskills/lib/skills.js +61 -0
  75. package/node_modules/@openvulcan/dsh-vulcan-luaskills/lib/tools.d.ts +59 -0
  76. package/node_modules/@openvulcan/dsh-vulcan-luaskills/lib/tools.js +107 -0
  77. package/node_modules/@openvulcan/dsh-vulcan-luaskills/lib/worker.d.ts +10 -0
  78. package/node_modules/@openvulcan/dsh-vulcan-luaskills/lib/worker.js +140 -0
  79. package/node_modules/@openvulcan/dsh-vulcan-luaskills/package.json +57 -0
  80. package/node_modules/@openvulcan/dsh-vulcan-memory/LICENSE +21 -0
  81. package/node_modules/@openvulcan/dsh-vulcan-memory/assets/markdown-memory-guide.md +21 -0
  82. package/node_modules/@openvulcan/dsh-vulcan-memory/assets/memory-curator/SKILL.md +31 -0
  83. package/node_modules/@openvulcan/dsh-vulcan-memory/assets/memory-icon.png +0 -0
  84. package/node_modules/@openvulcan/dsh-vulcan-memory/lib/client.js +1090 -0
  85. package/node_modules/@openvulcan/dsh-vulcan-memory/lib/index.d.ts +30 -0
  86. package/node_modules/@openvulcan/dsh-vulcan-memory/lib/index.js +240 -0
  87. package/node_modules/@openvulcan/dsh-vulcan-memory/lib/inject.d.ts +30 -0
  88. package/node_modules/@openvulcan/dsh-vulcan-memory/lib/inject.js +46 -0
  89. package/node_modules/@openvulcan/dsh-vulcan-memory/lib/markdown/constants.d.ts +23 -0
  90. package/node_modules/@openvulcan/dsh-vulcan-memory/lib/markdown/constants.js +25 -0
  91. package/node_modules/@openvulcan/dsh-vulcan-memory/lib/markdown/document.d.ts +108 -0
  92. package/node_modules/@openvulcan/dsh-vulcan-memory/lib/markdown/document.js +373 -0
  93. package/node_modules/@openvulcan/dsh-vulcan-memory/lib/markdown/errors.d.ts +14 -0
  94. package/node_modules/@openvulcan/dsh-vulcan-memory/lib/markdown/errors.js +31 -0
  95. package/node_modules/@openvulcan/dsh-vulcan-memory/lib/markdown/index.d.ts +196 -0
  96. package/node_modules/@openvulcan/dsh-vulcan-memory/lib/markdown/index.js +204 -0
  97. package/node_modules/@openvulcan/dsh-vulcan-memory/lib/markdown/operations.d.ts +92 -0
  98. package/node_modules/@openvulcan/dsh-vulcan-memory/lib/markdown/operations.js +472 -0
  99. package/node_modules/@openvulcan/dsh-vulcan-memory/lib/markdown/package.d.ts +126 -0
  100. package/node_modules/@openvulcan/dsh-vulcan-memory/lib/markdown/package.js +553 -0
  101. package/node_modules/@openvulcan/dsh-vulcan-memory/lib/markdown/registry.d.ts +56 -0
  102. package/node_modules/@openvulcan/dsh-vulcan-memory/lib/markdown/registry.js +182 -0
  103. package/node_modules/@openvulcan/dsh-vulcan-memory/lib/markdown/scope.d.ts +13 -0
  104. package/node_modules/@openvulcan/dsh-vulcan-memory/lib/markdown/scope.js +24 -0
  105. package/node_modules/@openvulcan/dsh-vulcan-memory/lib/markdown/search.d.ts +65 -0
  106. package/node_modules/@openvulcan/dsh-vulcan-memory/lib/markdown/search.js +227 -0
  107. package/node_modules/@openvulcan/dsh-vulcan-memory/lib/markdown/snapshot.d.ts +34 -0
  108. package/node_modules/@openvulcan/dsh-vulcan-memory/lib/markdown/snapshot.js +171 -0
  109. package/node_modules/@openvulcan/dsh-vulcan-memory/lib/markdown/store.d.ts +72 -0
  110. package/node_modules/@openvulcan/dsh-vulcan-memory/lib/markdown/store.js +250 -0
  111. package/node_modules/@openvulcan/dsh-vulcan-memory/lib/markdown/timestamp.d.ts +6 -0
  112. package/node_modules/@openvulcan/dsh-vulcan-memory/lib/markdown/timestamp.js +12 -0
  113. package/node_modules/@openvulcan/dsh-vulcan-memory/lib/markdown/tokens.d.ts +8 -0
  114. package/node_modules/@openvulcan/dsh-vulcan-memory/lib/markdown/tokens.js +38 -0
  115. package/node_modules/@openvulcan/dsh-vulcan-memory/lib/page.d.ts +132 -0
  116. package/node_modules/@openvulcan/dsh-vulcan-memory/lib/page.js +235 -0
  117. package/node_modules/@openvulcan/dsh-vulcan-memory/lib/project.d.ts +14 -0
  118. package/node_modules/@openvulcan/dsh-vulcan-memory/lib/project.js +27 -0
  119. package/node_modules/@openvulcan/dsh-vulcan-memory/lib/settings.d.ts +29 -0
  120. package/node_modules/@openvulcan/dsh-vulcan-memory/lib/settings.js +62 -0
  121. package/node_modules/@openvulcan/dsh-vulcan-memory/package.json +63 -0
  122. package/node_modules/@openvulcan/dsh-vulcan-reasoning/LICENSE +21 -0
  123. package/node_modules/@openvulcan/dsh-vulcan-reasoning/lib/client.js +509 -0
  124. package/node_modules/@openvulcan/dsh-vulcan-reasoning/lib/index.d.ts +6 -0
  125. package/node_modules/@openvulcan/dsh-vulcan-reasoning/lib/index.js +8 -0
  126. package/node_modules/@openvulcan/dsh-vulcan-reasoning/package.json +42 -0
  127. package/node_modules/@openvulcan/dsh-vulcan-style/LICENSE +21 -0
  128. package/node_modules/@openvulcan/dsh-vulcan-style/assets/style-icon.png +0 -0
  129. package/node_modules/@openvulcan/dsh-vulcan-style/lib/client.js +1121 -0
  130. package/node_modules/@openvulcan/dsh-vulcan-style/lib/index.d.ts +34 -0
  131. package/node_modules/@openvulcan/dsh-vulcan-style/lib/index.js +42 -0
  132. package/node_modules/@openvulcan/dsh-vulcan-style/package.json +51 -0
  133. package/node_modules/@openvulcan/openvulcan-account/LICENSE +21 -0
  134. package/node_modules/@openvulcan/openvulcan-account/lib/account.d.ts +140 -0
  135. package/node_modules/@openvulcan/openvulcan-account/lib/account.js +347 -0
  136. package/node_modules/@openvulcan/openvulcan-account/lib/index.d.ts +1 -0
  137. package/node_modules/@openvulcan/openvulcan-account/lib/index.js +1 -0
  138. package/node_modules/@openvulcan/openvulcan-account/package.json +29 -0
  139. package/node_modules/@openvulcan/vulcan-decision/LICENSE +21 -0
  140. package/node_modules/@openvulcan/vulcan-decision/README.md +23 -0
  141. package/node_modules/@openvulcan/vulcan-decision/bin/vulcan-decision.js +4 -0
  142. package/node_modules/@openvulcan/vulcan-decision/lib/cli.d.ts +5 -0
  143. package/node_modules/@openvulcan/vulcan-decision/lib/cli.js +267 -0
  144. package/node_modules/@openvulcan/vulcan-decision/lib/cloud/edge.d.ts +211 -0
  145. package/node_modules/@openvulcan/vulcan-decision/lib/cloud/edge.js +594 -0
  146. package/node_modules/@openvulcan/vulcan-decision/lib/download.d.ts +36 -0
  147. package/node_modules/@openvulcan/vulcan-decision/lib/download.js +99 -0
  148. package/node_modules/@openvulcan/vulcan-decision/lib/engine.d.ts +37 -0
  149. package/node_modules/@openvulcan/vulcan-decision/lib/engine.js +39 -0
  150. package/node_modules/@openvulcan/vulcan-decision/lib/hardware.d.ts +105 -0
  151. package/node_modules/@openvulcan/vulcan-decision/lib/hardware.js +207 -0
  152. package/node_modules/@openvulcan/vulcan-decision/lib/home.d.ts +26 -0
  153. package/node_modules/@openvulcan/vulcan-decision/lib/home.js +54 -0
  154. package/node_modules/@openvulcan/vulcan-decision/lib/index.d.ts +17 -0
  155. package/node_modules/@openvulcan/vulcan-decision/lib/index.js +17 -0
  156. package/node_modules/@openvulcan/vulcan-decision/lib/local/engine.d.ts +54 -0
  157. package/node_modules/@openvulcan/vulcan-decision/lib/local/engine.js +157 -0
  158. package/node_modules/@openvulcan/vulcan-decision/lib/local/models.d.ts +126 -0
  159. package/node_modules/@openvulcan/vulcan-decision/lib/local/models.js +261 -0
  160. package/node_modules/@openvulcan/vulcan-decision/lib/local/packs.d.ts +102 -0
  161. package/node_modules/@openvulcan/vulcan-decision/lib/local/packs.js +195 -0
  162. package/node_modules/@openvulcan/vulcan-decision/lib/local/worker.d.ts +61 -0
  163. package/node_modules/@openvulcan/vulcan-decision/lib/local/worker.js +123 -0
  164. package/node_modules/@openvulcan/vulcan-decision/lib/protocol.d.ts +74 -0
  165. package/node_modules/@openvulcan/vulcan-decision/lib/protocol.js +31 -0
  166. package/node_modules/@openvulcan/vulcan-decision/lib/render.d.ts +15 -0
  167. package/node_modules/@openvulcan/vulcan-decision/lib/render.js +33 -0
  168. package/node_modules/@openvulcan/vulcan-decision/lib/version.d.ts +2 -0
  169. package/node_modules/@openvulcan/vulcan-decision/lib/version.js +4 -0
  170. package/node_modules/@openvulcan/vulcan-decision/package.json +40 -0
  171. package/node_modules/@openvulcan/vulcan-decision/python/vulcan_decision_worker.py +363 -0
  172. package/node_modules/@openvulcan/vulcan-decision/runtime-packs/onnx-cpu.json +627 -0
  173. package/node_modules/@openvulcan/vulcan-decision/runtime-packs/onnx-cuda.json +423 -0
  174. package/node_modules/@openvulcan/vulcan-decision/runtime-packs/torch-cpu.json +1047 -0
  175. package/node_modules/@openvulcan/vulcan-decision/runtime-packs/torch-cu128.json +647 -0
  176. package/package.json +55 -4
@@ -0,0 +1,698 @@
1
+ /**
2
+ * Parsed `ui-*` answer component fences. Parsing is pure and total: a fence that does not match its
3
+ * documented syntax returns null, so the caller leaves dsh's ordinary code block in place. The syntax
4
+ * authority is `assets/prompts/answer-mode-interactive.md`.
5
+ *
6
+ * Ported from VulcanCode `apps/vulcan-code-web/src/components/conversation/answer-components/answer-component-model.ts`.
7
+ * `ui-code` (needs the host's answer-time file capture) and `ui-gallery` (needs local image access) are
8
+ * not offered in dsh, so their fences parse as null and stay ordinary code.
9
+ */
10
+ import { ANSWER_CHART_MAX_SERIES, ANSWER_COMPONENT_MAX_ROWS } from './limits.js';
11
+ /** Fence languages this plugin renders; any other `ui-*` fence stays ordinary code. */
12
+ export const SUPPORTED_ANSWER_LANGUAGES = new Set([
13
+ 'ui-kv', 'ui-metrics', 'ui-findings', 'ui-compare', 'ui-checklist', 'ui-steps', 'ui-timeline', 'ui-cards',
14
+ 'ui-files', 'ui-tree', 'ui-chart', 'ui-flow', 'ui-sequence', 'ui-er', 'ui-math', 'ui-decision',
15
+ 'ui-callout', 'ui-details', 'ui-tabs', 'ui-columns',
16
+ ]);
17
+ const TONES = new Set(['ok', 'warn', 'no']);
18
+ const STATUSES = new Set(['ok', 'warn', 'no', 'todo']);
19
+ const SEVERITIES = new Set(['high', 'medium', 'low', 'info', 'ok']);
20
+ const STEP_STATUSES = new Set(['done', 'now', 'todo']);
21
+ const CALLOUT_TONES = new Set(['info', 'success', 'warning', 'danger']);
22
+ /** Row tone words models reuse as callout tones. */
23
+ const CALLOUT_TONE_ALIASES = new Map([['ok', 'success'], ['warn', 'warning'], ['no', 'danger']]);
24
+ const CHART_KINDS = new Set(['bar', 'hbar', 'stack', 'line', 'area', 'pie', 'donut']);
25
+ /** Finite decimal number syntax shared by metrics and charts. */
26
+ const NUMBER = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/iu;
27
+ /**
28
+ * Parse one fence; `language` is `ui-<name>`, `meta` the rest of the info string, `body` the text.
29
+ * Returns null for unknown or unsupported names and for invalid syntax.
30
+ */
31
+ export function parseAnswerComponent(language, meta, body) {
32
+ const args = meta.trim();
33
+ if (!body.trim())
34
+ return null;
35
+ switch (language) {
36
+ case 'ui-kv':
37
+ // A row that is not a pair, even after the lenient split, may carry a third cell: models give facts a
38
+ // note column. Reading the pair first keeps a value such as `curl x|sh` whole.
39
+ return titled(args, () => mapLines(body, (line) => rowCells(line, 2, 2) ?? rowCells(line, 2, 3), ([label = '', value = '', note]) => (note === undefined ? { label, value } : { label, value, note }), (rows) => ({ kind: 'kv', rows })));
40
+ case 'ui-metrics':
41
+ return titled(args, () => mapLines(body, metricCells, metric, (rows) => ({ kind: 'metrics', rows })));
42
+ case 'ui-findings':
43
+ return titled(args, () => findings(body));
44
+ case 'ui-compare':
45
+ return titled(args, () => compare(body));
46
+ case 'ui-checklist':
47
+ return titled(args, () => checklist(body));
48
+ case 'ui-steps':
49
+ return titled(args, () => mapRows(body, 2, 3, ([status = '', title = '', text]) => (STEP_STATUSES.has(status) ? { status: status, title, text } : null), (steps) => ({ kind: 'steps', steps })));
50
+ case 'ui-timeline':
51
+ return titled(args, () => mapRows(body, 2, 3, ([time = '', title = '', text]) => ({ time, title, text }), (rows) => ({ kind: 'timeline', rows })));
52
+ case 'ui-cards':
53
+ return titled(args, () => mapRows(body, 2, 3, ([title = '', text = '', tag]) => ({ title, text, tag: tag === undefined ? undefined : toned(tag) }), (cards) => ({ kind: 'cards', cards })));
54
+ case 'ui-files':
55
+ return titled(args, () => files(body));
56
+ case 'ui-tree':
57
+ return titled(args, () => tree(body));
58
+ case 'ui-chart':
59
+ return chart(args, body);
60
+ case 'ui-flow':
61
+ return args === '' || args === 'LR' ? flow(args === 'LR', body) : null;
62
+ case 'ui-sequence':
63
+ return args === '' || args === 'num' ? sequence(args === 'num', body) : null;
64
+ case 'ui-er':
65
+ return args === '' || args === 'LR' || args === 'TB' ? er(args !== 'TB', body) : null;
66
+ case 'ui-math':
67
+ return math(args, body);
68
+ case 'ui-decision':
69
+ return args === '' || args === 'multi' ? decision(args === 'multi', body) : null;
70
+ case 'ui-callout':
71
+ return callout(args, body);
72
+ case 'ui-details':
73
+ return args ? { kind: 'details', title: args, body } : null;
74
+ case 'ui-tabs':
75
+ case 'ui-columns':
76
+ return titled(args, () => sections(language === 'ui-tabs' ? 'tabs' : 'columns', body));
77
+ default:
78
+ return null;
79
+ }
80
+ }
81
+ /** Run `parse` for a row component; text written after the name becomes its heading. */
82
+ function titled(args, parse) {
83
+ const component = parse();
84
+ return component && args ? { ...component, heading: args } : component;
85
+ }
86
+ /**
87
+ * `severity | finding | location` rows. Models sometimes add a fourth note cell as in `ui-checklist`; it is read
88
+ * only after the documented split, including the lenient one, has failed, so `fetch|iex` stays in the finding.
89
+ * Models also mark a resolved or healthy item with the severity `ok`.
90
+ */
91
+ function findings(body) {
92
+ const cells = (line) => rowCells(line, 2, 3) ?? rowCells(line, 4, 4);
93
+ return mapLines(body, cells, ([severity = '', text = '', location, note]) => (SEVERITIES.has(severity) ? { severity: severity, text, location, note } : null), (rows) => ({ kind: 'findings', rows }));
94
+ }
95
+ /**
96
+ * `ui-callout <tone> [title]`. Models sometimes omit the tone or write a row tone word: a row tone word maps to
97
+ * its callout tone, and without any tone word the callout is `info` with the whole text after the name as title.
98
+ */
99
+ function callout(args, body) {
100
+ const [word = '', ...rest] = args.split(/\s+/u).filter(Boolean);
101
+ const tone = CALLOUT_TONES.has(word) ? word : CALLOUT_TONE_ALIASES.get(word);
102
+ const title = (tone === undefined ? args : rest.join(' ')) || undefined;
103
+ return { kind: 'callout', tone: tone ?? 'info', title, body };
104
+ }
105
+ /**
106
+ * Split one row into trimmed cells; outer pipes are optional and `\|` is a literal pipe. `lenient` also
107
+ * keeps pipes inside balanced backtick code and pipes touching text on both sides (`fetch|iex`) as literal.
108
+ */
109
+ export function answerCells(line, lenient = false) {
110
+ let text = line.trim();
111
+ if (text.startsWith('|'))
112
+ text = text.slice(1);
113
+ if (text.endsWith('|') && !text.endsWith('\\|'))
114
+ text = text.slice(0, -1);
115
+ const codeAware = lenient && (text.match(/`/gu)?.length ?? 0) % 2 === 0;
116
+ const cells = [];
117
+ let cell = '';
118
+ let inCode = false;
119
+ for (let index = 0; index < text.length; index += 1) {
120
+ const char = text[index];
121
+ if (char === '\\' && text[index + 1] === '|') {
122
+ cell += '|';
123
+ index += 1;
124
+ }
125
+ else if (char === '`') {
126
+ if (codeAware)
127
+ inCode = !inCode;
128
+ cell += char;
129
+ }
130
+ else if (char === '|' && !(inCode || (lenient && /\S/u.test(text[index - 1] ?? ' ') && /\S/u.test(text[index + 1] ?? ' ')))) {
131
+ cells.push(cell.trim());
132
+ cell = '';
133
+ }
134
+ else {
135
+ cell += char;
136
+ }
137
+ }
138
+ cells.push(cell.trim());
139
+ return cells;
140
+ }
141
+ /** Nonblank lines, skipping a Markdown table delimiter row. */
142
+ function dataLines(body) {
143
+ return body.split(/\r?\n/u).filter((line) => line.trim() && !(line.includes('-') && /^[\s|:-]+$/u.test(line)));
144
+ }
145
+ /**
146
+ * Cells of a row with `min..=max` nonempty cells, or null. A row with too many cells is split again
147
+ * leniently, since models write unescaped pipes inside commands such as `fetch|iex`.
148
+ */
149
+ function rowCells(line, min, max) {
150
+ const valid = (cells) => cells.length >= min && cells.length <= max && cells.every(Boolean);
151
+ const cells = answerCells(line);
152
+ if (valid(cells))
153
+ return cells;
154
+ if (cells.length <= max)
155
+ return null;
156
+ const lenient = answerCells(line, true);
157
+ return valid(lenient) ? lenient : null;
158
+ }
159
+ /** Parse rows of `min..=max` nonempty cells (trailing cells optional) and wrap them; null when invalid. */
160
+ function mapRows(body, min, max, row, wrap) {
161
+ return mapLines(body, (line) => rowCells(line, min, max), row, wrap);
162
+ }
163
+ /** Parse rows whose cells come from `cells` and wrap them; null when a row is invalid or the row count is out of range. */
164
+ function mapLines(body, cells, row, wrap) {
165
+ const lines = dataLines(body);
166
+ if (lines.length === 0 || lines.length > ANSWER_COMPONENT_MAX_ROWS)
167
+ return null;
168
+ const rows = [];
169
+ for (const line of lines) {
170
+ const split = cells(line);
171
+ const parsed = split === null ? null : row(split);
172
+ if (parsed === null)
173
+ return null;
174
+ rows.push(parsed);
175
+ }
176
+ return wrap(rows);
177
+ }
178
+ /** Split a leading tone word from a cell. */
179
+ function toned(cell) {
180
+ const [word = '', ...rest] = cell.split(' ');
181
+ return TONES.has(word) ? { tone: word, text: rest.join(' ').trim() } : { text: cell };
182
+ }
183
+ /** A finite number, or null. */
184
+ function number(text) {
185
+ return NUMBER.test(text) ? Number(text) : null;
186
+ }
187
+ /**
188
+ * `label | value [| maximum [| unit]] [| tone]`; a maximum requires a nonnegative numeric value, and a
189
+ * value above it is an overrun whose tone defaults to `no`. Models also write `label | value | unit`: a lone
190
+ * third cell that is not an unsigned number (a change such as `+477` included) is read as the unit, without a
191
+ * meter, and the value may then be text such as `127 → 604`. Models also leave the maximum and unit
192
+ * cells empty to keep the columns aligned (`label | value | | | tone`, `label | value | | MB`); an empty cell in
193
+ * those two positions means the value is absent, while an empty label, value or any other cell is malformed.
194
+ */
195
+ function metric(input) {
196
+ const cells = [...input];
197
+ if (!cells[0] || !cells[1])
198
+ return null;
199
+ const last = cells[cells.length - 1] ?? '';
200
+ const tone = cells.length > 2 && TONES.has(last) ? cells.pop() : undefined;
201
+ if (cells.slice(4).some((cell) => !cell))
202
+ return null;
203
+ const [label = '', value = ''] = cells;
204
+ const maximum = cells[2] || undefined;
205
+ const unit = cells[3] || undefined;
206
+ if (maximum === undefined)
207
+ return { label, value, unit, tone };
208
+ // A signed third cell such as `+477` is a change, never a maximum, so it reads as the unit too.
209
+ const max = /^[+-]/u.test(maximum) ? null : number(maximum);
210
+ if (max === null && unit === undefined)
211
+ return { label, value, unit: maximum, tone };
212
+ const numeric = number(value);
213
+ if (numeric === null || max === null || max <= 0 || numeric < 0)
214
+ return null;
215
+ const over = numeric > max;
216
+ return { label, value, numeric, maximum: max, over, unit, tone: tone ?? (over ? 'no' : undefined) };
217
+ }
218
+ /**
219
+ * Cells of one metric row, 2..=5 of them, keeping empty cells for `metric` to judge; a row with too many cells
220
+ * is split again leniently, as `rowCells` does.
221
+ */
222
+ function metricCells(line) {
223
+ const fits = (cells) => cells.length >= 2 && cells.length <= 5;
224
+ const cells = answerCells(line);
225
+ if (fits(cells))
226
+ return cells;
227
+ if (cells.length <= 5)
228
+ return null;
229
+ const lenient = answerCells(line, true);
230
+ return fits(lenient) ? lenient : null;
231
+ }
232
+ /** Decision matrix: a header of criterion and at least two options, then one row per criterion. */
233
+ function compare(body) {
234
+ const [headerLine, ...lines] = dataLines(body);
235
+ if (!headerLine || lines.length === 0 || lines.length > ANSWER_COMPONENT_MAX_ROWS)
236
+ return null;
237
+ const header = rowCells(headerLine, 3, Number.MAX_SAFE_INTEGER);
238
+ if (header === null)
239
+ return null;
240
+ const rows = [];
241
+ for (const line of lines) {
242
+ const cells = rowCells(line, header.length, header.length);
243
+ if (cells === null)
244
+ return null;
245
+ rows.push({ criterion: cells[0] ?? '', cells: cells.slice(1).map(toned) });
246
+ }
247
+ return { kind: 'compare', header, rows };
248
+ }
249
+ /**
250
+ * `path | +added -removed | note` rows; both later cells are optional, totals are summed. Models also write one
251
+ * side only (`+145`), a change label in place of the counts (`path | new | note`), or leave the counts cell empty
252
+ * to keep the columns aligned (`path | | note`); all are accepted.
253
+ */
254
+ function files(body) {
255
+ let added = 0;
256
+ let removed = 0;
257
+ return mapLines(body, fileCells, ([path = '', second, third]) => {
258
+ const delta = second === undefined ? null : lineDelta(second);
259
+ if (delta === null && third !== undefined)
260
+ return { path, status: second, note: third };
261
+ if (delta === null)
262
+ return { path, note: second };
263
+ added += delta.added ?? 0;
264
+ removed += delta.removed ?? 0;
265
+ return { path, ...delta, note: third };
266
+ }, (rows) => ({ kind: 'files', files: rows, added, removed }));
267
+ }
268
+ /**
269
+ * Cells of one `ui-files` row: 1..=3 nonempty cells, or `path | | note` with an empty counts cell, read as
270
+ * `path | note`. Any other empty cell still makes the row malformed.
271
+ */
272
+ function fileCells(line) {
273
+ const plain = rowCells(line, 1, 3);
274
+ if (plain !== null)
275
+ return plain;
276
+ const [path, counts, note, ...rest] = answerCells(line);
277
+ return rest.length === 0 && path && counts === '' && note ? [path, note] : null;
278
+ }
279
+ /** Line counts of `+added -removed`, `+added` or `-removed` (`−` also marks removals); null for other text. */
280
+ function lineDelta(cell) {
281
+ const match = /^(?:\+(\d+)(?:\s+[-−](\d+))?|[-−](\d+))$/u.exec(cell);
282
+ if (match === null)
283
+ return null;
284
+ const removed = match[2] ?? match[3];
285
+ return {
286
+ ...(match[1] === undefined ? {} : { added: Number(match[1]) }),
287
+ ...(removed === undefined ? {} : { removed: Number(removed) }),
288
+ };
289
+ }
290
+ /**
291
+ * `status | item | note` rows. Models sometimes add a fourth location cell as in `ui-findings`; it is accepted
292
+ * only when it is path-shaped, so a genuinely extra column of prose still invalidates the component.
293
+ */
294
+ function checklist(body) {
295
+ const cells = (line) => {
296
+ const plain = rowCells(line, 2, 3);
297
+ if (plain !== null)
298
+ return plain;
299
+ const located = rowCells(line, 4, 4);
300
+ return located !== null && answerLocationPath(located[3] ?? '') !== null ? located : null;
301
+ };
302
+ return mapLines(body, cells, ([status = '', item = '', note, location]) => (STATUSES.has(status) ? { status: status, item, note, location } : null), (items) => ({ kind: 'checklist', items }));
303
+ }
304
+ /**
305
+ * The file path of a path-shaped location such as `src/a.rs:42`, or null for prose: only a token without
306
+ * spaces or full-width punctuation and with a separator or an extension is a file. The `:line` or
307
+ * `:line:column` suffix is stripped.
308
+ */
309
+ export function answerLocationPath(location) {
310
+ const path = location.trim().replace(/:\d+(?::\d+)?$/u, '');
311
+ if (!/^[^\s\u3000-\u303f\uff00-\uffef]+$/u.test(path) || !/[\\/]|\.[\p{L}\p{N}]+$/u.test(path))
312
+ return null;
313
+ return path;
314
+ }
315
+ /** Split a leading plan marker (`+ `, `- `, `~ `) from a line; a leading `\` keeps the text literal. */
316
+ function change(line) {
317
+ if (line.startsWith('\\'))
318
+ return { text: line.slice(1) };
319
+ const marker = /^([+~-]) /u.exec(line);
320
+ if (!marker)
321
+ return { text: line };
322
+ const mark = marker[1] === '+' ? 'add' : marker[1] === '-' ? 'del' : 'chg';
323
+ return { mark, text: line.slice(2).trimStart() };
324
+ }
325
+ /** Indentation tree with optional `label | note`; tabs count as four columns. */
326
+ function tree(body) {
327
+ const lines = body.split(/\r?\n/u).filter((line) => line.trim());
328
+ if (lines.length > ANSWER_COMPONENT_MAX_ROWS)
329
+ return null;
330
+ const roots = [];
331
+ const stack = [];
332
+ for (const line of lines) {
333
+ const indent = [...(/^\s*/u.exec(line)?.[0] ?? '')].reduce((width, ch) => width + (ch === '\t' ? 4 : 1), 0);
334
+ const { mark, text } = change(line.trim());
335
+ const split = text.indexOf('|');
336
+ const label = (split < 0 ? text : text.slice(0, split)).trim();
337
+ const note = split < 0 ? undefined : text.slice(split + 1).trim() || undefined;
338
+ const node = { label, note, mark, children: [] };
339
+ while (stack.length > 0 && (stack[stack.length - 1]?.indent ?? -1) >= indent)
340
+ stack.pop();
341
+ const parent = stack[stack.length - 1];
342
+ (parent ? parent.node.children : roots).push(node);
343
+ stack.push({ indent, node });
344
+ }
345
+ return { kind: 'tree', nodes: roots };
346
+ }
347
+ /** `### Title` lines split the body into titled Markdown sections; two or more nonempty sections. */
348
+ function sections(kind, body) {
349
+ const result = [];
350
+ let current = null;
351
+ for (const line of body.split(/\r?\n/u)) {
352
+ if (line.startsWith('### ')) {
353
+ if (current)
354
+ result.push({ title: current.title, body: current.lines.join('\n').trim() });
355
+ current = { title: line.slice(4).trim(), lines: [] };
356
+ }
357
+ else if (current) {
358
+ current.lines.push(line);
359
+ }
360
+ else if (line.trim()) {
361
+ return null;
362
+ }
363
+ }
364
+ if (current)
365
+ result.push({ title: current.title, body: current.lines.join('\n').trim() });
366
+ if (result.length < 2 || result.length > ANSWER_COMPONENT_MAX_ROWS || result.some((section) => !section.body))
367
+ return null;
368
+ return { kind, sections: result };
369
+ }
370
+ /** `ui-chart <kind> [title]`: header row of category and series names, then one number per series. */
371
+ function chart(args, body) {
372
+ const [kind = '', ...title] = args.split(/\s+/u).filter(Boolean);
373
+ if (!CHART_KINDS.has(kind))
374
+ return null;
375
+ const [headerLine, ...lines] = dataLines(body);
376
+ if (!headerLine || lines.length === 0 || lines.length > ANSWER_COMPONENT_MAX_ROWS)
377
+ return null;
378
+ const header = rowCells(headerLine, 2, ANSWER_CHART_MAX_SERIES + 1);
379
+ const circular = kind === 'pie' || kind === 'donut';
380
+ if (header === null || (circular && header.length !== 2))
381
+ return null;
382
+ const rows = [];
383
+ for (const line of lines) {
384
+ const cells = rowCells(line, header.length, header.length);
385
+ if (cells === null)
386
+ return null;
387
+ const values = cells.slice(1).map((cell) => number(cell.replace(/%$/u, '').trim()));
388
+ if (values.some((value) => value === null))
389
+ return null;
390
+ rows.push({ category: cells[0] ?? '', values: values });
391
+ }
392
+ if ((circular || kind === 'stack') && rows.some((row) => row.values.some((value) => value < 0)))
393
+ return null;
394
+ if (circular && rows.reduce((sum, row) => sum + (row.values[0] ?? 0), 0) <= 0)
395
+ return null;
396
+ return {
397
+ kind: 'chart',
398
+ chart: { kind: kind, title: title.join(' ') || undefined, categoryLabel: header[0] ?? '', series: header.slice(1), rows },
399
+ };
400
+ }
401
+ /** Split at the first ASCII or full-width colon, or a `|`, into a trimmed head and an optional tail. */
402
+ function labelled(text) {
403
+ const index = text.search(/[::|]/u);
404
+ return index < 0 ? { head: text.trim() } : { head: text.slice(0, index).trim(), tail: text.slice(index + 1).trim() };
405
+ }
406
+ /** Flow node outline and label from one node spec: `[(Store)]`, `{Decision}`, `(Start)`, `[Box]` or plain. */
407
+ function flowNode(spec) {
408
+ const text = spec.trim();
409
+ const outlines = [['[(', ')]', 'store'], ['{', '}', 'decision'], ['(', ')', 'terminal'], ['[', ']', 'box']];
410
+ for (const [open, close, shape] of outlines) {
411
+ if (text.length >= open.length + close.length && text.startsWith(open) && text.endsWith(close)) {
412
+ const label = text.slice(open.length, text.length - close.length).trim();
413
+ return label ? { label, shape } : null;
414
+ }
415
+ }
416
+ return text ? { label: text, shape: 'box' } : null;
417
+ }
418
+ /** Mermaid node outlines, longer openings first, with the closest `ui-flow` outline. */
419
+ const MERMAID_OUTLINES = [
420
+ ['([', '])', 'terminal'], ['[[', ']]', 'box'], ['((', '))', 'terminal'], ['{{', '}}', 'box'], ['[/', '/]', 'box'],
421
+ ['[\\', '\\]', 'box'], ['[(', ')]', 'store'], ['[', ']', 'box'], ['(', ')', 'terminal'], ['{', '}', 'decision'],
422
+ ];
423
+ /**
424
+ * A Mermaid-style node `ID[text]`: an identifier followed directly by an outline with nonempty text.
425
+ * `paren` marks the plain `(` outline, which also reads as a call such as `f(x)`.
426
+ */
427
+ function mermaidNode(spec) {
428
+ const match = /^([A-Za-z_][\w-]*)([[({].*)$/u.exec(spec);
429
+ if (!match)
430
+ return null;
431
+ const id = match[1] ?? '';
432
+ const outline = match[2] ?? '';
433
+ for (const [open, close, shape] of MERMAID_OUTLINES) {
434
+ if (outline.length >= open.length + close.length && outline.startsWith(open) && outline.endsWith(close)) {
435
+ const label = outline.slice(open.length, outline.length - close.length).trim();
436
+ return label ? { id, label, shape, paren: open === '(' } : null;
437
+ }
438
+ }
439
+ return null;
440
+ }
441
+ /**
442
+ * `ui-flow [LR]`: one chain per line; nodes keep their first outline unless a later one is explicit. A
443
+ * Mermaid ID such as `B` in `B{OK?}` names the node `OK?` throughout the fence. A plain `(` outline counts
444
+ * only when its ID also stands alone, so `f(x)` stays text.
445
+ */
446
+ function flow(horizontal, body) {
447
+ const lines = dataLines(body);
448
+ if (lines.length === 0 || lines.length > ANSWER_COMPONENT_MAX_ROWS)
449
+ return null;
450
+ const parsedLines = lines.map((line) => {
451
+ const { mark, text } = change(line.trim());
452
+ const parts = [];
453
+ const dashed = [];
454
+ let rest = text;
455
+ for (let index = rest.indexOf('->'); index >= 0; index = rest.indexOf('->')) {
456
+ const dash = index > 0 && rest[index - 1] === '-';
457
+ parts.push(rest.slice(0, dash ? index - 1 : index));
458
+ dashed.push(dash);
459
+ rest = rest.slice(index + 2);
460
+ }
461
+ if (parts.length === 0)
462
+ return { mark, groups: [[text.trim()]], dashed, label: undefined, lone: true };
463
+ const { head, tail } = labelled(rest);
464
+ parts.push(head);
465
+ return { mark, groups: parts.map((part) => part.split('&').map((spec) => spec.trim())), dashed, label: tail, lone: false };
466
+ });
467
+ const specs = parsedLines.flatMap((line) => line.groups.flat());
468
+ const standalone = new Set(specs);
469
+ const aliases = new Map();
470
+ for (const spec of specs) {
471
+ const mermaid = mermaidNode(spec);
472
+ if (mermaid && (!mermaid.paren || standalone.has(mermaid.id)))
473
+ aliases.set(mermaid.id, mermaid.label);
474
+ }
475
+ const nodes = [];
476
+ const ids = new Map();
477
+ const chains = [];
478
+ const changed = new Set();
479
+ const node = (spec) => {
480
+ const mermaid = mermaidNode(spec);
481
+ const alias = aliases.get(spec);
482
+ const parsed = mermaid && aliases.has(mermaid.id) ? mermaid
483
+ : alias !== undefined ? { label: alias, shape: 'box' }
484
+ : flowNode(spec);
485
+ if (parsed === null)
486
+ return null;
487
+ let id = ids.get(parsed.label);
488
+ if (id === undefined) {
489
+ id = nodes.push({ label: parsed.label, shape: 'box' }) - 1;
490
+ ids.set(parsed.label, id);
491
+ }
492
+ const entry = nodes[id];
493
+ if (entry && parsed.shape !== 'box')
494
+ entry.shape = parsed.shape;
495
+ return id;
496
+ };
497
+ for (const { mark, groups: specGroups, dashed, label, lone } of parsedLines) {
498
+ if (lone) {
499
+ const id = node(specGroups[0]?.[0] ?? '');
500
+ if (id === null)
501
+ return null;
502
+ if (mark === 'chg')
503
+ changed.add(id);
504
+ chains.push({ groups: [[id]], dashed: [], mark });
505
+ continue;
506
+ }
507
+ const groups = [];
508
+ for (const part of specGroups) {
509
+ const group = part.map(node);
510
+ if (group.some((id) => id === null))
511
+ return null;
512
+ groups.push(group);
513
+ }
514
+ chains.push({ groups, dashed, label: label || undefined, mark });
515
+ }
516
+ // Nodes drawn only on added (or only on removed) chains carry that marker; `~ Node` lines mark changes.
517
+ const linked = new Set();
518
+ nodes.forEach((entry, id) => {
519
+ const marks = new Set(chains.filter((chain) => chain.groups.length > 1 && chain.groups.some((group) => group.includes(id))).map((chain) => chain.mark));
520
+ if (marks.size > 0)
521
+ linked.add(id);
522
+ const [only] = [...marks];
523
+ if (marks.size === 1 && (only === 'add' || only === 'del'))
524
+ entry.mark = only;
525
+ if (changed.has(id))
526
+ entry.mark = 'chg';
527
+ });
528
+ // A lone node line only declares or marks a node that already appears on a chain.
529
+ const shown = chains.filter((chain) => chain.groups.length > 1 || !linked.has(chain.groups[0]?.[0] ?? -1));
530
+ return { kind: 'flow', horizontal, nodes, chains: shown };
531
+ }
532
+ /** `ui-sequence [num]`: messages, notes and phases; actors appear in first-use order. */
533
+ function sequence(numbered, body) {
534
+ const lines = dataLines(body);
535
+ if (lines.length === 0 || lines.length > ANSWER_COMPONENT_MAX_ROWS)
536
+ return null;
537
+ const actors = [];
538
+ const actor = (name) => {
539
+ const trimmed = name.trim();
540
+ if (!trimmed)
541
+ return null;
542
+ const known = actors.indexOf(trimmed);
543
+ return known >= 0 ? known : actors.push(trimmed) - 1;
544
+ };
545
+ const steps = [];
546
+ for (const raw of lines) {
547
+ const line = raw.trim();
548
+ if (line.length >= 4 && line.startsWith('==') && line.endsWith('==')) {
549
+ steps.push({ kind: 'phase', text: line.slice(2, -2).trim() });
550
+ continue;
551
+ }
552
+ if (line.startsWith('note ')) {
553
+ const { head, tail } = labelled(line.slice(5));
554
+ const indexes = head.split(/[,,]/u).map(actor);
555
+ if (tail === undefined || indexes.some((index) => index === null))
556
+ return null;
557
+ const span = indexes;
558
+ steps.push({ kind: 'note', first: Math.min(...span), last: Math.max(...span), text: tail });
559
+ continue;
560
+ }
561
+ const { mark, text } = change(line);
562
+ const index = text.indexOf('->');
563
+ if (index < 0)
564
+ return null;
565
+ const dashed = index > 0 && text[index - 1] === '-';
566
+ const from = actor(text.slice(0, dashed ? index - 1 : index));
567
+ const { head, tail } = labelled(text.slice(index + 2));
568
+ const to = actor(head);
569
+ if (from === null || to === null)
570
+ return null;
571
+ steps.push({ kind: 'message', from, to, dashed, text: tail ?? '', mark });
572
+ }
573
+ return actors.length > 0 ? { kind: 'sequence', numbered, actors, steps } : null;
574
+ }
575
+ const ER_KEYS = new Set(['PK', 'FK', 'UK']);
576
+ /** Split a trailing `| note` from a line into a trimmed head and an optional nonempty note. */
577
+ function noted(text) {
578
+ const split = text.indexOf('|');
579
+ if (split < 0)
580
+ return { head: text.trim() };
581
+ return { head: text.slice(0, split).trim(), note: text.slice(split + 1).trim() || undefined };
582
+ }
583
+ /**
584
+ * `ui-er [LR|TB]`: unindented lines name tables, indented lines are `name [type] [PK|FK|UK] [-> Table]`
585
+ * fields; every reference must name a table of the same diagram.
586
+ */
587
+ function er(horizontal, body) {
588
+ const lines = body.split(/\r?\n/u).filter((line) => line.trim());
589
+ if (lines.length === 0 || lines.length > ANSWER_COMPONENT_MAX_ROWS)
590
+ return null;
591
+ const tables = [];
592
+ const references = [];
593
+ for (const line of lines) {
594
+ const { mark, text } = change(line.trim());
595
+ const { head, note } = noted(text);
596
+ if (!head)
597
+ return null;
598
+ if (!/^\s/u.test(line)) {
599
+ tables.push({ name: head, note, mark, fields: [] });
600
+ continue;
601
+ }
602
+ const table = tables[tables.length - 1];
603
+ if (!table)
604
+ return null;
605
+ const arrow = head.indexOf('->');
606
+ const target = arrow < 0 ? undefined : head.slice(arrow + 2).trim();
607
+ if (target === '')
608
+ return null;
609
+ const [name, ...rest] = (arrow < 0 ? head : head.slice(0, arrow)).trim().split(/\s+/u).filter(Boolean);
610
+ if (!name)
611
+ return null;
612
+ const keys = rest.filter((token) => ER_KEYS.has(token));
613
+ if (target !== undefined && !keys.includes('FK'))
614
+ keys.push('FK');
615
+ const type = rest.filter((token) => !ER_KEYS.has(token)).join(' ') || undefined;
616
+ const field = { name, type, keys, note, mark };
617
+ table.fields.push(field);
618
+ if (target !== undefined)
619
+ references.push({ field, target });
620
+ }
621
+ const names = tables.map((table) => table.name);
622
+ if (new Set(names).size !== names.length)
623
+ return null;
624
+ for (const reference of references) {
625
+ const index = names.indexOf(reference.target);
626
+ if (index < 0)
627
+ return null;
628
+ reference.field.target = index;
629
+ }
630
+ return { kind: 'er', horizontal, tables };
631
+ }
632
+ /**
633
+ * `ui-math [title]`: formula lines, then after a blank line optional `symbol | meaning` legend rows. Each
634
+ * formula line is one displayed equation unless the formula uses a `\begin` environment.
635
+ */
636
+ function math(args, body) {
637
+ const lines = body.split(/\r?\n/u);
638
+ while (lines.length > 0 && !lines[0]?.trim())
639
+ lines.shift();
640
+ while (lines.length > 0 && !lines[lines.length - 1]?.trim())
641
+ lines.pop();
642
+ // Models also separate groups of equations with blank lines, so only the group after the last blank line can
643
+ // be the legend: it is one when every row is `symbol | meaning`, further formulas when none is, and a mix is
644
+ // invalid. Every earlier group is formulas.
645
+ let blank = lines.length - 1;
646
+ while (blank >= 0 && lines[blank]?.trim())
647
+ blank -= 1;
648
+ const after = blank < 0 ? [] : dataLines(lines.slice(blank + 1).join('\n'));
649
+ const legendRows = after.map((line) => rowCells(line, 2, 2));
650
+ const isLegend = legendRows.every((cells) => cells !== null);
651
+ if (!isLegend && legendRows.some((cells) => cells !== null))
652
+ return null;
653
+ const formula = (blank < 0 ? lines : lines.slice(0, blank)).map((line) => line.trim()).filter(Boolean)
654
+ .concat(isLegend ? [] : after.map((line) => line.trim()));
655
+ const legend = isLegend ? legendRows : [];
656
+ if (formula.length === 0 || formula.length + legend.length > ANSWER_COMPONENT_MAX_ROWS)
657
+ return null;
658
+ const symbols = legend.map(([symbol = '', meaning = '']) => ({ symbol, meaning }));
659
+ const equations = formula.some((line) => line.includes('\\begin')) ? [formula.join('\n')] : formula;
660
+ return { kind: 'math', title: args || undefined, equations, symbols };
661
+ }
662
+ /**
663
+ * One decision option line: `* text` (recommended) or `- text`. Models also write the recommendation as `*text`
664
+ * without the space; a line wrapped in or opening with emphasis (`*Q?*`, `**Q?**`) stays text. `-` still needs
665
+ * its space so labels such as `-3` are not read as options.
666
+ */
667
+ function decisionOption(line) {
668
+ if (line.startsWith('* ') || line.startsWith('- '))
669
+ return { recommended: line.startsWith('*'), text: line.slice(2) };
670
+ if (line.startsWith('*') && !line.startsWith('**') && !line.endsWith('*'))
671
+ return { recommended: true, text: line.slice(1) };
672
+ return null;
673
+ }
674
+ /**
675
+ * `ui-decision [multi]`: the question line, then option lines `* label | note` (the recommended option)
676
+ * or `- label | note`. Labels are unique; a single choice recommends at most one option. A body whose first
677
+ * line is already an option has no question; the reply summary then names it by its options.
678
+ */
679
+ function decision(multiple, body) {
680
+ const rows = body.split(/\r?\n/u).map((line) => line.trim()).filter(Boolean);
681
+ const [first = ''] = rows;
682
+ const question = decisionOption(first) === null ? first : '';
683
+ const lines = question ? rows.slice(1) : rows;
684
+ if (lines.length < 2 || lines.length > ANSWER_COMPONENT_MAX_ROWS)
685
+ return null;
686
+ const options = [];
687
+ for (const line of lines) {
688
+ const option = decisionOption(line);
689
+ const cells = option === null ? null : rowCells(option.text, 1, 2);
690
+ const label = cells?.[0];
691
+ if (option === null || cells === null || label === undefined || options.some((item) => item.label === label))
692
+ return null;
693
+ options.push({ label, note: cells[1], recommended: option.recommended });
694
+ }
695
+ if (!multiple && options.filter((option) => option.recommended).length > 1)
696
+ return null;
697
+ return { kind: 'decision', multiple, question, options };
698
+ }