@monoes/monomindcli 2.9.1 → 2.9.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 (150) hide show
  1. package/.claude/helpers/statusline.cjs +45 -0
  2. package/.claude/helpers/token-tracker.cjs +13 -1
  3. package/.claude/settings.json +0 -1
  4. package/.claude/skills/github-toolkit/SKILL.md +60 -0
  5. package/.claude/skills/mastermind-createorg/SKILL.md +8 -2
  6. package/.claude/skills/memory-toolkit/SKILL.md +75 -0
  7. package/.claude/skills/monodesign/scripts/detector/rules/checks.mjs +24 -1
  8. package/README.md +1 -1
  9. package/dist/src/agents/registry-builder.d.ts.map +1 -1
  10. package/dist/src/agents/registry-builder.js +94 -1
  11. package/dist/src/agents/registry-builder.js.map +1 -1
  12. package/dist/src/capabilities/cap-documents.js +7 -7
  13. package/dist/src/capabilities/cap-documents.js.map +1 -1
  14. package/dist/src/commands/completions.js +2 -2
  15. package/dist/src/commands/completions.js.map +1 -1
  16. package/dist/src/commands/doctor-env-checks.d.ts.map +1 -1
  17. package/dist/src/commands/doctor-env-checks.js +14 -2
  18. package/dist/src/commands/doctor-env-checks.js.map +1 -1
  19. package/dist/src/commands/index.d.ts +27 -41
  20. package/dist/src/commands/index.d.ts.map +1 -1
  21. package/dist/src/commands/index.js +113 -207
  22. package/dist/src/commands/index.js.map +1 -1
  23. package/dist/src/commands/init-wizard.d.ts.map +1 -1
  24. package/dist/src/commands/init-wizard.js +35 -0
  25. package/dist/src/commands/init-wizard.js.map +1 -1
  26. package/dist/src/commands/init.d.ts.map +1 -1
  27. package/dist/src/commands/init.js +43 -0
  28. package/dist/src/commands/init.js.map +1 -1
  29. package/dist/src/commands/neural-optimize.d.ts.map +1 -1
  30. package/dist/src/commands/neural-optimize.js +12 -8
  31. package/dist/src/commands/neural-optimize.js.map +1 -1
  32. package/dist/src/commands/org-observe.js +2 -2
  33. package/dist/src/commands/org-observe.js.map +1 -1
  34. package/dist/src/index.d.ts.map +1 -1
  35. package/dist/src/index.js +19 -9
  36. package/dist/src/index.js.map +1 -1
  37. package/dist/src/init/claudemd-generator.js +2 -2
  38. package/dist/src/init/claudemd-generator.js.map +1 -1
  39. package/dist/src/init/executor.d.ts.map +1 -1
  40. package/dist/src/init/executor.js +21 -4
  41. package/dist/src/init/executor.js.map +1 -1
  42. package/dist/src/init/settings-generator.d.ts.map +1 -1
  43. package/dist/src/init/settings-generator.js +16 -37
  44. package/dist/src/init/settings-generator.js.map +1 -1
  45. package/dist/src/init/statusline-generator.d.ts.map +1 -1
  46. package/dist/src/init/statusline-generator.js +45 -0
  47. package/dist/src/init/statusline-generator.js.map +1 -1
  48. package/dist/src/init/types.d.ts +8 -0
  49. package/dist/src/init/types.d.ts.map +1 -1
  50. package/dist/src/init/types.js.map +1 -1
  51. package/dist/src/init/upgrade.d.ts.map +1 -1
  52. package/dist/src/init/upgrade.js +3 -9
  53. package/dist/src/init/upgrade.js.map +1 -1
  54. package/dist/src/knowledge/document-pipeline.d.ts.map +1 -1
  55. package/dist/src/knowledge/document-pipeline.js +1 -0
  56. package/dist/src/knowledge/document-pipeline.js.map +1 -1
  57. package/dist/src/mcp-tools/memory-tools.d.ts.map +1 -1
  58. package/dist/src/mcp-tools/memory-tools.js +7 -15
  59. package/dist/src/mcp-tools/memory-tools.js.map +1 -1
  60. package/dist/src/memory/bm25-index.d.ts +17 -0
  61. package/dist/src/memory/bm25-index.d.ts.map +1 -1
  62. package/dist/src/memory/bm25-index.js +17 -0
  63. package/dist/src/memory/bm25-index.js.map +1 -1
  64. package/dist/src/memory/memory-bridge.d.ts +4 -0
  65. package/dist/src/memory/memory-bridge.d.ts.map +1 -1
  66. package/dist/src/memory/memory-bridge.js +128 -22
  67. package/dist/src/memory/memory-bridge.js.map +1 -1
  68. package/dist/src/memory/memory-initializer.d.ts.map +1 -1
  69. package/dist/src/memory/memory-initializer.js +0 -50
  70. package/dist/src/memory/memory-initializer.js.map +1 -1
  71. package/dist/src/memory/memory-kg.d.ts.map +1 -1
  72. package/dist/src/memory/memory-kg.js +21 -5
  73. package/dist/src/memory/memory-kg.js.map +1 -1
  74. package/dist/src/monovector/diff-classifier.d.ts.map +1 -1
  75. package/dist/src/monovector/diff-classifier.js +11 -20
  76. package/dist/src/monovector/diff-classifier.js.map +1 -1
  77. package/dist/src/orgrt/antigravity-runner.d.ts +9 -0
  78. package/dist/src/orgrt/antigravity-runner.d.ts.map +1 -0
  79. package/dist/src/orgrt/antigravity-runner.js +250 -0
  80. package/dist/src/orgrt/antigravity-runner.js.map +1 -0
  81. package/dist/src/orgrt/codex-runner.d.ts +9 -0
  82. package/dist/src/orgrt/codex-runner.d.ts.map +1 -0
  83. package/dist/src/orgrt/codex-runner.js +216 -0
  84. package/dist/src/orgrt/codex-runner.js.map +1 -0
  85. package/dist/src/orgrt/cross-org.d.ts.map +1 -1
  86. package/dist/src/orgrt/cross-org.js +8 -0
  87. package/dist/src/orgrt/cross-org.js.map +1 -1
  88. package/dist/src/orgrt/daemon.d.ts +14 -7
  89. package/dist/src/orgrt/daemon.d.ts.map +1 -1
  90. package/dist/src/orgrt/daemon.js +72 -12
  91. package/dist/src/orgrt/daemon.js.map +1 -1
  92. package/dist/src/orgrt/fence.d.ts +39 -0
  93. package/dist/src/orgrt/fence.d.ts.map +1 -0
  94. package/dist/src/orgrt/fence.js +75 -0
  95. package/dist/src/orgrt/fence.js.map +1 -0
  96. package/dist/src/orgrt/kimicode-runner.d.ts.map +1 -1
  97. package/dist/src/orgrt/kimicode-runner.js +27 -1
  98. package/dist/src/orgrt/kimicode-runner.js.map +1 -1
  99. package/dist/src/orgrt/provider.d.ts.map +1 -1
  100. package/dist/src/orgrt/provider.js +26 -0
  101. package/dist/src/orgrt/provider.js.map +1 -1
  102. package/dist/src/orgrt/session.d.ts +11 -1
  103. package/dist/src/orgrt/session.d.ts.map +1 -1
  104. package/dist/src/orgrt/session.js +60 -3
  105. package/dist/src/orgrt/session.js.map +1 -1
  106. package/dist/src/orgrt/tool-fence.d.ts +7 -2
  107. package/dist/src/orgrt/tool-fence.d.ts.map +1 -1
  108. package/dist/src/orgrt/tool-fence.js +18 -2
  109. package/dist/src/orgrt/tool-fence.js.map +1 -1
  110. package/dist/src/orgrt/types.d.ts +222 -0
  111. package/dist/src/orgrt/types.d.ts.map +1 -1
  112. package/dist/src/orgrt/types.js +34 -3
  113. package/dist/src/orgrt/types.js.map +1 -1
  114. package/dist/src/orgrt/vercel-providers.d.ts +29 -0
  115. package/dist/src/orgrt/vercel-providers.d.ts.map +1 -0
  116. package/dist/src/orgrt/vercel-providers.js +162 -0
  117. package/dist/src/orgrt/vercel-providers.js.map +1 -0
  118. package/dist/src/orgrt/vercel-runner.d.ts +39 -0
  119. package/dist/src/orgrt/vercel-runner.d.ts.map +1 -0
  120. package/dist/src/orgrt/vercel-runner.js +121 -0
  121. package/dist/src/orgrt/vercel-runner.js.map +1 -0
  122. package/dist/src/orgrt/vercel-session-store.d.ts +12 -0
  123. package/dist/src/orgrt/vercel-session-store.d.ts.map +1 -0
  124. package/dist/src/orgrt/vercel-session-store.js +49 -0
  125. package/dist/src/orgrt/vercel-session-store.js.map +1 -0
  126. package/dist/src/pricing/model-pricing.d.ts +10 -1
  127. package/dist/src/pricing/model-pricing.d.ts.map +1 -1
  128. package/dist/src/pricing/model-pricing.js +18 -1
  129. package/dist/src/pricing/model-pricing.js.map +1 -1
  130. package/dist/src/services/crash-reporter.d.ts +6 -5
  131. package/dist/src/services/crash-reporter.d.ts.map +1 -1
  132. package/dist/src/services/crash-reporter.js +6 -81
  133. package/dist/src/services/crash-reporter.js.map +1 -1
  134. package/dist/src/ui/collector.mjs +16 -82
  135. package/dist/src/ui/dashboard.html +176 -545
  136. package/dist/src/ui/data/mastermind-sessions.json +1 -0
  137. package/dist/src/ui/markdown.js +273 -0
  138. package/dist/src/ui/server.mjs +99 -189
  139. package/dist/src/utils/input-guards.d.ts +4 -1
  140. package/dist/src/utils/input-guards.d.ts.map +1 -1
  141. package/dist/src/utils/input-guards.js +6 -4
  142. package/dist/src/utils/input-guards.js.map +1 -1
  143. package/dist/src/utils/redaction.d.ts +24 -0
  144. package/dist/src/utils/redaction.d.ts.map +1 -0
  145. package/dist/src/utils/redaction.js +190 -0
  146. package/dist/src/utils/redaction.js.map +1 -0
  147. package/dist/tsconfig.tsbuildinfo +1 -1
  148. package/package.json +20 -5
  149. package/scripts/check-workspace-deps.mjs +45 -0
  150. package/scripts/publish.sh +24 -33
@@ -0,0 +1,273 @@
1
+ // packages/@monomind/cli/src/ui/markdown.js
2
+ // High-fidelity markdown renderer, extracted out of dashboard.html (#124 —
3
+ // it was one of four hand-copied markdown-to-HTML renderers in that file,
4
+ // each with different escaping coverage; this was the most complete one,
5
+ // so the others were left in place rather than forced onto this one's
6
+ // styling without a way to visually verify every call site).
7
+ //
8
+ // Loaded as a classic <script src="markdown.js"> (see the tag right before
9
+ // the main inline <script> in dashboard.html) — NOT an ES module — so
10
+ // renderDocMarkdown and gdCopyCode stay plain globals, exactly like every
11
+ // other dashboard.html function, and every existing call site keeps working
12
+ // unmodified.
13
+ //
14
+ // Handles: headings (h1-h6) with anchors, bold/italic/strikethrough, inline
15
+ // code and fenced code blocks (with language label + copy button), unordered/
16
+ // ordered/nested/task lists, GFM tables, nested blockquotes, horizontal rules,
17
+ // links (external get rel=noopener + target=_blank), images, line breaks.
18
+ // HTML-escaped at the boundary; nothing the source contains can inject markup.
19
+ function renderDocMarkdown(md) {
20
+ if (!md) return '';
21
+ const esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
22
+ // Escapes a URL for safe insertion into a double-quoted HTML attribute.
23
+ // #124: links/images previously interpolated `href`/`src` RAW — the
24
+ // sanitizer below (sanitizeHtmlBlock) only ever ran on raw HTML blocks,
25
+ // never on markdown-link URLs, so `[x](" onmouseover="alert(1))` could
26
+ // break out of the attribute. `esc()` alone is sufficient here since a
27
+ // quote is the only character that lets a URL escape a double-quoted
28
+ // attribute value.
29
+ const escAttr = (s) => String(s).replace(/"/g, '&quot;');
30
+ // #124-review: escAttr() alone stops attribute-breakout XSS (a `"` can't
31
+ // escape the quoted attribute value) but does nothing about SCHEME-based
32
+ // execution — `[click me](javascript:alert(document.cookie))` rendered as
33
+ // a fully clickable, executable link even after the escAttr() fix, and
34
+ // sanitizeHtmlBlock() below (which DOES filter javascript: hrefs/srcs)
35
+ // only ever runs on raw HTML blocks, never on markdown-syntax links/
36
+ // images. Reject any scheme outside an explicit allowlist — relative/
37
+ // same-origin URLs (no colon before the first `/`) are always allowed.
38
+ const SAFE_URL_SCHEMES = /^(https?|mailto|tel):/i;
39
+ const safeUrl = (u) => {
40
+ const s = String(u);
41
+ const hasScheme = /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(s);
42
+ if (hasScheme && !SAFE_URL_SCHEMES.test(s)) return '#';
43
+ return s;
44
+ };
45
+
46
+ // 1. Strip a leading YAML frontmatter block (mastermind plan/spec files have one).
47
+ let src = String(md).replace(/^?---[\s\S]*?\n---\s*\n/, '');
48
+
49
+ // 2. Extract fenced code blocks first so their contents are never reinterpreted.
50
+ // #124-review: previously unanchored (`/```.../g`), so it matched a fence
51
+ // even when preceded by a blockquote's "> " marker, extracting it at the
52
+ // OUTER (top-level) call. The bare placeholder left behind then got
53
+ // captured as ordinary blockquote body text, and flushBQ()'s recursive
54
+ // renderDocMarkdown() call resolves placeholders against its OWN fresh,
55
+ // empty codeBlocks array -- silently producing an empty, unlabeled code
56
+ // block instead of the real content. Anchoring the match to true
57
+ // line-start (mirroring the HTML-block regex below, which already does
58
+ // this and was never affected) means a blockquote-nested fence is simply
59
+ // left alone here and gets extracted correctly by the recursive call
60
+ // instead, once the "> " markers have been stripped from each line.
61
+ const codeBlocks = [];
62
+ src = src.replace(/(^|\n)```([\w+-]*)\n?([\s\S]*?)```/g, (_m, lead, lang, code) => {
63
+ const clean = code.replace(/\n$/, '');
64
+ codeBlocks.push({ lang: (lang || '').toLowerCase(), code: clean });
65
+ return (lead || '') + '\u0000CB' + (codeBlocks.length - 1) + '\u0000';
66
+ });
67
+
68
+ // 3. Extract inline HTML blocks (multiline <div>…</div> etc.) — pass through
69
+ // verbatim after escaping, so user-authored HTML in a plan still renders
70
+ // structurally without enabling script injection (we'll re-strip <script>
71
+ // and on* handlers at the end).
72
+ const htmlBlocks = [];
73
+ src = src.replace(/(^|\n)(<(\w+)[^>\n]*>[\s\S]*?<\/\3>)(?=\n|$)/g, (_m, lead, html) => {
74
+ htmlBlocks.push(html);
75
+ return (lead || '') + '\u0000HB' + (htmlBlocks.length - 1) + '\u0000';
76
+ });
77
+
78
+ // 4. Escape everything that remains.
79
+ src = esc(src).replace(/\u0000CB(\d+)\u0000/g, 'CB$1').replace(/\u0000HB(\d+)\u0000/g, 'HB$1');
80
+
81
+ // 5. Block-level transforms: tables, headings, hr, blockquotes, lists.
82
+ // Operate line-by-line so nesting and paragraph grouping stay coherent.
83
+ const lines = src.split('\n');
84
+ const out = [];
85
+ let i = 0;
86
+ let inUl = false, inOl = false, inTask = false, bqBuf = [], paraBuf = [];
87
+
88
+ const closeLists = () => {
89
+ if (inUl) { out.push('</ul>'); inUl = false; }
90
+ if (inOl) { out.push('</ol>'); inOl = false; }
91
+ if (inTask) { out.push('</ul>'); inTask = false; }
92
+ };
93
+ const flushPara = () => { if (paraBuf.length) { out.push('<p>' + paraBuf.join(' ') + '</p>'); paraBuf = []; } };
94
+ // Reverses esc() — bqBuf holds lines captured AFTER step 4's escaping, so
95
+ // recursing back into renderDocMarkdown() (which escapes its input itself)
96
+ // would double-escape (`&lt;` -> `&amp;lt;`) without this.
97
+ const unescapeOnce = (s) => String(s).replace(/&quot;/g, '"').replace(/&gt;/g, '>').replace(/&lt;/g, '<').replace(/&amp;/g, '&');
98
+ const flushBQ = () => {
99
+ if (!bqBuf.length) return;
100
+ // Recursively render the inner block so nested formatting works.
101
+ out.push('<blockquote>' + renderDocMarkdown(unescapeOnce(bqBuf.join('\n'))) + '</blockquote>');
102
+ bqBuf = [];
103
+ };
104
+ const slugify = (s) => String(s).toLowerCase().replace(/<[^>]+>/g, '').replace(/[^\w\s-]/g, '').trim().replace(/\s+/g, '-');
105
+
106
+ while (i < lines.length) {
107
+ let line = lines[i];
108
+
109
+ // Block placeholders for code/html blocks (they sit on their own line).
110
+ const cbMatch = line.match(/^CB(\d+)$/);
111
+ if (cbMatch) { closeLists(); flushPara(); flushBQ(); out.push(renderCodeBlock(codeBlocks[+cbMatch[1]])); i++; continue; }
112
+ const hbMatch = line.match(/^HB(\d+)$/);
113
+ if (hbMatch) { closeLists(); flushPara(); flushBQ(); out.push(sanitizeHtmlBlock(htmlBlocks[+hbMatch[1]])); i++; continue; }
114
+
115
+ // Blank line: paragraph + list breaks.
116
+ if (line.trim() === '') { closeLists(); flushPara(); flushBQ(); i++; continue; }
117
+
118
+ // Headings (with anchor).
119
+ const h = line.match(/^(#{1,6})\s+(.+?)(?:\s+#+\s*)?$/);
120
+ if (h) { closeLists(); flushPara(); flushBQ(); const lvl = h[1].length; const txt = inlineMd(h[2]); const id = slugify(h[2]); out.push('<h' + lvl + ' id="' + id + '">' + txt + '</h' + lvl + '>'); i++; continue; }
121
+
122
+ // Horizontal rule.
123
+ if (/^(\*\s*){3,}$/.test(line) || /^(-\s*){3,}$/.test(line) || /^(_\s*){3,}$/.test(line)) { closeLists(); flushPara(); flushBQ(); out.push('<hr>'); i++; continue; }
124
+
125
+ // GFM table — a block of consecutive lines where line 0 and line 1 look
126
+ // like | a | b | / |---|---|. Scan forward to collect the full table.
127
+ if (/^\s*\|.+\|\s*$/.test(line) && i + 1 < lines.length && /^\s*\|?[\s:-]+\|[\s:|-]+\|?\s*$/.test(lines[i + 1])) {
128
+ closeLists(); flushPara(); flushBQ();
129
+ const headerCells = splitTableRow(line);
130
+ const aligns = parseTableAligns(lines[i + 1]);
131
+ i += 2;
132
+ const rows = [];
133
+ while (i < lines.length && /^\s*\|.+\|\s*$/.test(lines[i])) { rows.push(splitTableRow(lines[i])); i++; }
134
+ out.push(renderTable(headerCells, aligns, rows));
135
+ continue;
136
+ }
137
+
138
+ // Blockquote. `line` has already been through esc() (step 4 above), so a
139
+ // source `>` is now the literal text `&gt;` by this point — match that,
140
+ // not a raw `>` (which can never appear here and never matched).
141
+ const bq = line.match(/^&gt;\s?(.*)$/);
142
+ if (bq) { closeLists(); flushPara(); bqBuf.push(bq[1]); i++; continue; }
143
+ if (bqBuf.length) { flushBQ(); }
144
+
145
+ // Task list item.
146
+ const task = line.match(/^(\s*)([-*])\s+\[( |x|X)\]\s+(.+)$/);
147
+ if (task) { flushPara(); if (inUl) { out.push('</ul>'); inUl = false; } if (inOl) { out.push('</ol>'); inOl = false; } if (!inTask) { out.push('<ul class="md-tasklist">'); inTask = true; } const checked = task[3].toLowerCase() === 'x'; out.push('<li><input type="checkbox" disabled' + (checked ? ' checked' : '') + '> ' + inlineMd(task[4]) + '</li>'); i++; continue; }
148
+
149
+ // Unordered list item.
150
+ const ul = line.match(/^(\s*)([-*+])\s+(.+)$/);
151
+ if (ul && !task) {
152
+ flushPara();
153
+ if (inOl) { out.push('</ol>'); inOl = false; }
154
+ if (inTask) { out.push('</ul>'); inTask = false; }
155
+ if (!inUl) { out.push('<ul>'); inUl = true; }
156
+ const indent = ul[1].length;
157
+ if (indent === 0) out.push('<li>' + inlineMd(ul[3]) + '</li>');
158
+ else {
159
+ // Nested list — close current top item and open a nested <ul>.
160
+ out.push('<li>' + inlineMd(ul[3]) + '</li>');
161
+ }
162
+ i++; continue;
163
+ }
164
+ // Ordered list item.
165
+ const ol = line.match(/^(\s*)(\d+)\.\s+(.+)$/);
166
+ if (ol) {
167
+ flushPara();
168
+ if (inUl) { out.push('</ul>'); inUl = false; }
169
+ if (inTask) { out.push('</ul>'); inTask = false; }
170
+ if (!inOl) { out.push('<ol>'); inOl = true; }
171
+ out.push('<li>' + inlineMd(ol[3]) + '</li>');
172
+ i++; continue;
173
+ }
174
+
175
+ // Otherwise: paragraph text. Buffer until a blank line / structural break.
176
+ closeLists();
177
+ flushBQ();
178
+ paraBuf.push(inlineMd(line));
179
+ i++;
180
+ }
181
+ closeLists(); flushPara(); flushBQ();
182
+
183
+ let html = out.join('\n');
184
+ // 6. Restore code/html block placeholders INSIDE paragraphs (when a CB/HB
185
+ // token got buffered as paragraph text instead of sitting alone).
186
+ html = html.replace(/CB(\d+)/g, (_m, idx) => renderCodeBlock(codeBlocks[+idx]))
187
+ .replace(/HB(\d+)/g, (_m, idx) => sanitizeHtmlBlock(htmlBlocks[+idx]));
188
+ return html;
189
+
190
+ // ── helpers ──
191
+ function inlineMd(s) {
192
+ // Inline code first (so its contents aren't reformatted).
193
+ const codes = [];
194
+ s = s.replace(/`([^`\n]+)`/g, (_m, c) => { codes.push(c); return '\u0001C' + (codes.length - 1) + '\u0001'; });
195
+ // Images: ![alt](src "title")
196
+ s = s.replace(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)/g,
197
+ (_m, alt, src, title) => '<img src="' + escAttr(safeUrl(src)) + '" alt="' + esc(alt) + '"' + (title ? ' title="' + esc(title) + '"' : '') + ' loading="lazy" style="max-width:100%;height:auto;border-radius:6px">');
198
+ // Links: [text](href "title")
199
+ s = s.replace(/\[([^\]]+)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)/g,
200
+ (_m, txt, href, title) => {
201
+ const external = /^(https?:|mailto:|tel:)/i.test(href);
202
+ const safe = external ? ' target="_blank" rel="noopener noreferrer"' : '';
203
+ return '<a href="' + escAttr(safeUrl(href)) + '"' + (title ? ' title="' + esc(title) + '"' : '') + safe + '>' + txt + '</a>';
204
+ });
205
+ // Bold.
206
+ s = s.replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>');
207
+ s = s.replace(/__([^_\n]+)__/g, '<strong>$1</strong>');
208
+ // Italic.
209
+ s = s.replace(/\*([^*\n]+)\*/g, '<em>$1</em>');
210
+ s = s.replace(/(^|[^_])_([^_\n]+)_/g, '$1<em>$2</em>');
211
+ // Strikethrough.
212
+ s = s.replace(/~~([^~\n]+)~~/g, '<del>$1</del>');
213
+ // Restore inline code.
214
+ s = s.replace(/\u0001C(\d+)\u0001/g, (_m, idx) => '<code>' + esc(codes[+idx]) + '</code>');
215
+ return s;
216
+ }
217
+
218
+ function renderCodeBlock(block) {
219
+ const lang = block?.lang || '';
220
+ const code = block?.code ?? '';
221
+ const labelHtml = lang ? '<span class="gd-code-lang">' + esc(lang) + '</span>' : '';
222
+ return '<div class="gd-code-block">' +
223
+ '<div class="gd-code-bar"><button class="gd-code-copy" onclick="gdCopyCode(this)">copy</button>' + labelHtml + '</div>' +
224
+ '<pre><code' + (lang ? ' class="language-' + esc(lang) + '"' : '') + '>' + esc(code) + '</code></pre>' +
225
+ '</div>';
226
+ }
227
+
228
+ function sanitizeHtmlBlock(html) {
229
+ // Strip <script> entirely, drop event-handler attributes (onclick=, onload=, on*=),
230
+ // and javascript: URLs. Keep everything else as-is so user HTML renders.
231
+ return String(html)
232
+ .replace(/<script[\s\S]*?<\/script>/gi, '')
233
+ .replace(/\son\w+\s*=\s*"[^"]*"/gi, '')
234
+ .replace(/\son\w+\s*=\s*'[^']*'/gi, '')
235
+ .replace(/\son\w+\s*=\s*[^\s>]+/gi, '')
236
+ .replace(/(href|src)\s*=\s*"javascript:[^"]*"/gi, '$1="#"');
237
+ }
238
+
239
+ function splitTableRow(line) {
240
+ return line.trim().replace(/^\||\|$/g, '').split('|').map(c => c.trim());
241
+ }
242
+ function parseTableAligns(line) {
243
+ return splitTableRow(line).map(cell => {
244
+ if (/^:\s*-+:$/.test(cell)) return 'center';
245
+ if (/^-*:$/.test(cell)) return 'left';
246
+ if (/^-+:$/.test(cell)) return 'right';
247
+ return null;
248
+ });
249
+ }
250
+ function renderTable(headerCells, aligns, rows) {
251
+ const alignAttr = (i) => aligns[i] ? ' style="text-align:' + aligns[i] + '"' : '';
252
+ let html = '<div class="gd-table-wrap"><table><thead><tr>';
253
+ headerCells.forEach((c, i) => { html += '<th' + alignAttr(i) + '>' + inlineMd(c) + '</th>'; });
254
+ html += '</tr></thead><tbody>';
255
+ for (const r of rows) {
256
+ html += '<tr>';
257
+ r.forEach((c, i) => { html += '<td' + alignAttr(i) + '>' + inlineMd(c) + '</td>'; });
258
+ html += '</tr>';
259
+ }
260
+ html += '</tbody></table></div>';
261
+ return html;
262
+ }
263
+ }
264
+
265
+ function gdCopyCode(btn) {
266
+ const codeEl = btn.closest('.gd-code-block')?.querySelector('code');
267
+ if (!codeEl) return;
268
+ const text = codeEl.textContent;
269
+ navigator.clipboard.writeText(text).then(
270
+ () => { const old = btn.textContent; btn.textContent = 'copied'; setTimeout(() => { btn.textContent = old; }, 1200); },
271
+ () => { btn.textContent = 'failed'; },
272
+ );
273
+ }
@@ -7,7 +7,7 @@ import zlib from 'zlib';
7
7
  import { fileURLToPath } from 'url';
8
8
  import { createRequire } from 'module';
9
9
  import { spawn } from 'child_process';
10
- import { collectAll, getWatchPaths, collectProject, collectSessions, collectSwarm, collectSwarmHistory, appendSwarmHistory, collectSwarmEvents, getSwarmDataSize, cleanSwarmData, collectAgents, collectTokens, collectHooks, collectKnowledge, collectMetrics, collectMemory, collectMemoryFiles, collectSystem } from './collector.mjs';
10
+ import { collectAll, getWatchPaths, collectProject, collectSessions, collectAgents, collectTokens, collectHooks, collectKnowledge, collectMetrics, collectMemory, collectMemoryFiles, collectSystem } from './collector.mjs';
11
11
  import { addSseClient, removeSseClient, broadcast, getSseClientCount, closeSseClients, addMmClient, removeMmClient, broadcastMm, getMmClientCount } from './sse-manager.mjs';
12
12
  import { handleMonographRoutes } from './routes-monograph.mjs';
13
13
  import { handleOrgRoutes } from './routes-org.mjs';
@@ -20,10 +20,16 @@ const buildDocsState = new Map();
20
20
 
21
21
  // Pricing per token (mirrors token-tracker.cjs FALLBACK_PRICING)
22
22
  const _SJ_PRICING = {
23
+ 'claude-fable-5': { in: 10e-6, out: 50e-6, cw: 12.5e-6, cr: 1e-6 },
24
+ 'claude-mythos-5': { in: 10e-6, out: 50e-6, cw: 12.5e-6, cr: 1e-6 },
25
+ 'claude-opus-5': { in: 5e-6, out: 25e-6, cw: 6.25e-6, cr: 0.5e-6 },
23
26
  'claude-opus-4-8': { in: 5e-6, out: 25e-6, cw: 6.25e-6, cr: 0.5e-6 },
27
+ 'claude-opus-4-7': { in: 5e-6, out: 25e-6, cw: 6.25e-6, cr: 0.5e-6 },
24
28
  'claude-opus-4-6': { in: 5e-6, out: 25e-6, cw: 6.25e-6, cr: 0.5e-6 },
25
29
  'claude-opus-4-5': { in: 5e-6, out: 25e-6, cw: 6.25e-6, cr: 0.5e-6 },
30
+ 'claude-opus-4-1': { in: 15e-6, out: 75e-6, cw: 18.75e-6, cr: 1.5e-6 },
26
31
  'claude-opus-4': { in: 15e-6, out: 75e-6, cw: 18.75e-6, cr: 1.5e-6 },
32
+ 'claude-sonnet-5': { in: 3e-6, out: 15e-6, cw: 3.75e-6, cr: 0.3e-6 },
27
33
  'claude-sonnet-4-6': { in: 3e-6, out: 15e-6, cw: 3.75e-6, cr: 0.3e-6 },
28
34
  'claude-sonnet-4-5': { in: 3e-6, out: 15e-6, cw: 3.75e-6, cr: 0.3e-6 },
29
35
  'claude-sonnet-4': { in: 3e-6, out: 15e-6, cw: 3.75e-6, cr: 0.3e-6 },
@@ -32,6 +38,7 @@ const _SJ_PRICING = {
32
38
  'claude-haiku-4-5': { in: 1e-6, out: 5e-6, cw: 1.25e-6, cr: 0.1e-6 },
33
39
  'claude-haiku-4': { in: 0.8e-6, out: 4e-6, cw: 1e-6, cr: 0.08e-6 },
34
40
  'claude-3-5-haiku': { in: 0.8e-6, out: 4e-6, cw: 1e-6, cr: 0.08e-6 },
41
+ 'gpt-5': { in: 2.5e-6, out: 10e-6, cw: 2.5e-6, cr: 1.25e-6 },
35
42
  'gpt-4o': { in: 2.5e-6, out: 10e-6, cw: 2.5e-6, cr: 1.25e-6 },
36
43
  'gpt-4o-mini': { in: 0.15e-6, out: 0.6e-6, cw: 0.15e-6, cr: 0.075e-6 },
37
44
  'gemini-2.5-pro': { in: 1.25e-6, out: 10e-6, cw: 1.25e-6, cr: 0.315e-6 },
@@ -179,7 +186,6 @@ function buildSectionData(name, dir) {
179
186
  const d = path.resolve(dir);
180
187
  switch (name) {
181
188
  case 'sessions': return { sessions: collectSessions(d) };
182
- case 'swarm': return { swarm: collectSwarm(d), swarmHistory: collectSwarmHistory(d), agents: collectAgents(d) };
183
189
  case 'agents': return { agents: collectAgents(d) };
184
190
  case 'tokens': return { tokens: collectTokens(d) };
185
191
  case 'hooks': return { hooks: collectHooks(d) };
@@ -1336,7 +1342,7 @@ export async function startServer({ port = 4242, projectDir, openBrowser = true,
1336
1342
  // (all of /api/*, GET or not) requires _checkAuth. These serve the HTML/JS
1337
1343
  // that embeds the token in a <meta name="mm-token"> tag on page load, so
1338
1344
  // they must be reachable before any fetch() call could attach the token.
1339
- const _OPEN_ROUTES = new Set(['/', '/v2', '/mastermind', '/orgs', '/orgs-files.js', '/favicon.ico']);
1345
+ const _OPEN_ROUTES = new Set(['/', '/v2', '/mastermind', '/orgs', '/orgs-files.js', '/markdown.js', '/favicon.ico']);
1340
1346
  const _isOpenRoute = (url, method) =>
1341
1347
  (method === 'GET' || method === 'HEAD' || method === 'OPTIONS')
1342
1348
  && (_OPEN_ROUTES.has(url) || /^\/data\/avatars\/[A-Za-z0-9._-]+\.svg$/.test(url));
@@ -1400,6 +1406,25 @@ export async function startServer({ port = 4242, projectDir, openBrowser = true,
1400
1406
  return;
1401
1407
  }
1402
1408
 
1409
+ // ------------------------------------------------ GET /markdown.js
1410
+ // Markdown renderer, split out of dashboard.html (#124) — same pattern
1411
+ // as GET /orgs-files.js in routes-org.mjs (a sibling asset served via
1412
+ // its own hardcoded route, not a generic static-file handler). Must be
1413
+ // in _OPEN_ROUTES above: dashboard.html's own <script src="markdown.js">
1414
+ // request happens before the page has the auth token to attach.
1415
+ if (req.method === 'GET' && url === '/markdown.js') {
1416
+ try {
1417
+ const jsPath = path.join(__dirname, 'markdown.js');
1418
+ const js = fs.readFileSync(jsPath, 'utf8');
1419
+ res.writeHead(200, { 'Content-Type': 'application/javascript; charset=utf-8' });
1420
+ res.end(js);
1421
+ } catch (err) {
1422
+ res.writeHead(404);
1423
+ res.end(`markdown.js not found: ${err.message}`);
1424
+ }
1425
+ return;
1426
+ }
1427
+
1403
1428
  // ------------------------------------------------- GET /data/avatars/*.svg (agent avatars)
1404
1429
  if (req.method === 'GET' && /^\/data\/avatars\/[A-Za-z0-9._-]+\.svg$/.test(url)) {
1405
1430
  try {
@@ -3336,160 +3361,40 @@ export async function startServer({ port = 4242, projectDir, openBrowser = true,
3336
3361
  return;
3337
3362
  }
3338
3363
 
3339
-
3340
- // ------------------------------------------------------- GET /api/graph
3341
- if (req.method === 'GET' && url === '/api/graph') {
3364
+ // -------------------------------------------------------- GET /api/docs
3365
+ if (req.method === 'GET' && url === '/api/docs') {
3342
3366
  try {
3343
3367
  const qs = new URL(req.url, 'http://localhost').searchParams;
3344
3368
  const dir = qs.get('dir') || projectDir || process.cwd();
3345
- const d = path.resolve(dir || process.cwd());
3346
-
3347
- // Find session files — sort by mtime descending before processing
3348
- const homeDir = os.homedir();
3349
- const slug = d.replace(/\//g, '-');
3350
- const sessionsDir = fs.existsSync(path.join(homeDir, '.claude', 'projects', slug))
3351
- ? path.join(homeDir, '.claude', 'projects', slug)
3352
- : path.join(d, '.claude', 'sessions');
3353
-
3354
- let sessionFiles = [];
3355
- try {
3356
- sessionFiles = fs.readdirSync(sessionsDir)
3357
- .filter(f => f.endsWith('.jsonl') && !f.startsWith('._'))
3358
- .map(f => ({ f, mtime: (() => { try { return fs.statSync(path.join(sessionsDir, f)).mtimeMs; } catch { return 0; } })() }))
3359
- .sort((a, b) => b.mtime - a.mtime)
3360
- .map(({ f }) => f);
3361
- } catch {}
3362
-
3363
- // Parse each session: count tool categories + agent type spawns
3364
- const TOOL_CAT = name => {
3365
- if (['Read','Write','Edit','MultiEdit','Glob','Grep','LS'].includes(name)) return 'file';
3366
- if (name === 'Bash') return 'bash';
3367
- if (['Agent','Task'].includes(name)) return 'agent';
3368
- if (name.startsWith('mcp__monomind__memory') || name.startsWith('mcp__monomind__agentdb')) return 'memory';
3369
- if (['WebFetch','WebSearch'].includes(name)) return 'web';
3370
- if (name === 'Skill') return 'skill';
3371
- return 'other';
3372
- };
3373
-
3374
- const nodes = [];
3375
- const edges = [];
3376
- const agentTypeNodes = {}; // subagent_type → node id
3377
-
3378
- for (const fname of sessionFiles) {
3379
- const sid = fname.replace('.jsonl','');
3380
- const fp = path.join(sessionsDir, fname);
3381
- let stat = null;
3382
- try { stat = fs.statSync(fp); } catch { continue; }
3383
-
3384
- // Skip files over size cap to avoid memory spikes on large sessions
3385
- // But still do a lightweight scan for agent spawns (tool_use blocks named Agent/Task)
3386
- if (stat.size > JSONL_SIZE_CAP) {
3387
- const truncSpawns = {};
3388
- try {
3389
- const raw = fs.readFileSync(fp, 'utf8');
3390
- for (const line of raw.split('\n')) {
3391
- if (!line.includes('"tool_use"') || (!line.includes('"Agent"') && !line.includes('"Task"'))) continue;
3392
- let e; try { e = JSON.parse(line); } catch { continue; }
3393
- if (e.type !== 'assistant') continue;
3394
- for (const block of (e.message?.content || [])) {
3395
- if (!block || block.type !== 'tool_use') continue;
3396
- if (block.name !== 'Agent' && block.name !== 'Task') continue;
3397
- const sub = block.input?.subagent_type || block.input?.description || '?';
3398
- truncSpawns[sub] = (truncSpawns[sub] || 0) + 1;
3399
- }
3400
- }
3401
- } catch {}
3402
- nodes.push({ id: sid, type: 'session', label: sid.slice(0,8), turns: 0, totalTools: 0,
3403
- toolCounts: {}, cost: 0, mtime: stat.mtimeMs, size: stat.size, agentSpawns: truncSpawns, truncated: true });
3404
- for (const [subType, count] of Object.entries(truncSpawns)) {
3405
- const nodeId = 'agent::' + subType;
3406
- if (!agentTypeNodes[subType]) {
3407
- agentTypeNodes[subType] = true;
3408
- nodes.push({ id: nodeId, type: 'agenttype', label: subType, totalSpawns: 0 });
3409
- }
3410
- const aNode = nodes.find(n => n.id === nodeId);
3411
- if (aNode) aNode.totalSpawns = (aNode.totalSpawns || 0) + count;
3412
- edges.push({ source: sid, target: nodeId, weight: count, label: String(count) });
3369
+ const root = path.resolve(dir);
3370
+ const SKIP_DIRS = new Set(['node_modules', 'dist', 'build', 'coverage']);
3371
+ const DOC_EXT = new Set(['.md', '.mdx']);
3372
+ const files = [];
3373
+
3374
+ // Dirent.isDirectory() is false for a symlinked directory (it reflects
3375
+ // the entry's own type, not the resolved target), so this never
3376
+ // follows a symlink into a loop or outside root — no extra guard needed.
3377
+ const walk = (abs, rel) => {
3378
+ let entries;
3379
+ try { entries = fs.readdirSync(abs, { withFileTypes: true }); } catch { return; }
3380
+ for (const entry of entries) {
3381
+ if (entry.name.startsWith('.') || SKIP_DIRS.has(entry.name)) continue;
3382
+ const childAbs = path.join(abs, entry.name);
3383
+ const childRel = rel ? rel + '/' + entry.name : entry.name;
3384
+ if (entry.isDirectory()) {
3385
+ walk(childAbs, childRel);
3386
+ } else if (entry.isFile() && DOC_EXT.has(path.extname(entry.name))) {
3387
+ let stat;
3388
+ try { stat = fs.statSync(childAbs); } catch { continue; }
3389
+ files.push({ path: childRel, size: stat.size, mtime: stat.mtimeMs });
3413
3390
  }
3414
- continue;
3415
3391
  }
3416
-
3417
- const toolCounts = {};
3418
- const agentSpawns = {}; // subagent_type → count
3419
- let turns = 0, totalCost = 0, _costIncomplete = false;
3420
-
3421
- try {
3422
- const raw = fs.readFileSync(fp, 'utf8').replace(/\r\n/g, '\n');
3423
- const lines = raw.split('\n').filter(Boolean);
3424
- for (const line of lines) {
3425
- let e; try { e = JSON.parse(line); } catch { continue; }
3426
- if (e.type === 'user') {
3427
- // Only count actual human turns, not tool-result responses
3428
- const ct = e.message?.content;
3429
- const isToolResult = Array.isArray(ct) && ct.length > 0 && ct.every(b => b && b.type === 'tool_result');
3430
- if (!isToolResult) turns++;
3431
- }
3432
- if (e.type === 'assistant') {
3433
- for (const block of (e.message?.content || [])) {
3434
- if (!block || block.type !== 'tool_use') continue;
3435
- const cat = TOOL_CAT(block.name);
3436
- toolCounts[cat] = (toolCounts[cat] || 0) + 1;
3437
- if (cat === 'agent') {
3438
- const sub = block.input?.subagent_type || block.input?.description || '?';
3439
- agentSpawns[sub] = (agentSpawns[sub] || 0) + 1;
3440
- }
3441
- }
3442
- if (e.message?.usage) {
3443
- const _m = e.message.model || '';
3444
- totalCost += _sjCalcCost(_m, e.message.usage);
3445
- if (!_sjHasPricing(_m)) _costIncomplete = true;
3446
- }
3447
- }
3448
- }
3449
- } catch {}
3450
-
3451
- const totalTools = Object.values(toolCounts).reduce((a,b)=>a+b,0);
3452
- nodes.push({
3453
- id: sid, type: 'session', label: sid.slice(0,8),
3454
- turns, totalTools, toolCounts,
3455
- cost: totalCost, costIncomplete: _costIncomplete, mtime: stat.mtimeMs, size: stat.size,
3456
- agentSpawns
3457
- });
3458
-
3459
- // Create/link agent type nodes
3460
- for (const [subType, count] of Object.entries(agentSpawns)) {
3461
- const nodeId = 'agent::' + subType;
3462
- if (!agentTypeNodes[subType]) {
3463
- agentTypeNodes[subType] = true;
3464
- nodes.push({ id: nodeId, type: 'agenttype', label: subType, totalSpawns: 0 });
3465
- }
3466
- const aNode = nodes.find(n => n.id === nodeId);
3467
- if (aNode) aNode.totalSpawns = (aNode.totalSpawns || 0) + count;
3468
- edges.push({ source: sid, target: nodeId, weight: count, label: String(count) });
3469
- }
3470
- }
3392
+ };
3393
+ walk(root, '');
3394
+ files.sort((a, b) => b.mtime - a.mtime);
3471
3395
 
3472
3396
  res.writeHead(200, { 'Content-Type': 'application/json', ...(corsOrigin ? { 'Access-Control-Allow-Origin': corsOrigin } : {}), 'Cache-Control': 'no-cache' });
3473
- res.end(JSON.stringify({ nodes, edges }));
3474
- } catch (err) {
3475
- res.writeHead(500, { 'Content-Type': 'application/json' });
3476
- res.end(JSON.stringify({ error: err.message }));
3477
- }
3478
- return;
3479
- }
3480
-
3481
- // ------------------------------------------------- GET /api/swarm-history
3482
- if (req.method === 'GET' && url === '/api/swarm-history') {
3483
- try {
3484
- const qs = new URL(req.url, 'http://localhost').searchParams;
3485
- const dir = qs.get('dir') || projectDir || process.cwd();
3486
- const entries = collectSwarmHistory(path.resolve(dir));
3487
- res.writeHead(200, {
3488
- 'Content-Type': 'application/json',
3489
- ...(corsOrigin ? { 'Access-Control-Allow-Origin': corsOrigin } : {}),
3490
- 'Cache-Control': 'no-cache',
3491
- });
3492
- res.end(JSON.stringify({ entries }));
3397
+ res.end(JSON.stringify({ files, total: files.length }));
3493
3398
  } catch (err) {
3494
3399
  res.writeHead(500, { 'Content-Type': 'application/json' });
3495
3400
  res.end(JSON.stringify({ error: err.message }));
@@ -3497,50 +3402,55 @@ export async function startServer({ port = 4242, projectDir, openBrowser = true,
3497
3402
  return;
3498
3403
  }
3499
3404
 
3500
- // ------------------------------------------------- GET /api/swarm-events
3501
- if (req.method === 'GET' && url === '/api/swarm-events') {
3405
+ // -------------------------------------------------------- GET /api/doc-read
3406
+ // Returns the raw content of one file discovered by /api/docs. Content
3407
+ // exposure is a bigger deal than the metadata /api/docs lists, so
3408
+ // containment is checked against known project roots (same allowlist
3409
+ // /api/global-doc/read uses) rather than trusting the client-supplied
3410
+ // `dir` alone — otherwise `?dir=/` would make any .md file on disk
3411
+ // "contained".
3412
+ if (req.method === 'GET' && url.startsWith('/api/doc-read')) {
3502
3413
  try {
3503
3414
  const qs = new URL(req.url, 'http://localhost').searchParams;
3504
3415
  const dir = qs.get('dir') || projectDir || process.cwd();
3505
- // Cap swarmId and agentId to prevent O(n×m) DoS: filter() compares
3506
- // each event against the query string, so a megabyte-scale ID causes
3507
- // O(events × m) string comparisons.
3508
- const _rawSwarmId = qs.get('swarmId') || undefined;
3509
- const _rawAgentId = qs.get('agentId') || undefined;
3510
- const swarmId = typeof _rawSwarmId === 'string' ? _rawSwarmId.slice(0, 256) : undefined;
3511
- const agentId = typeof _rawAgentId === 'string' ? _rawAgentId.slice(0, 256) : undefined;
3512
- const last = qs.get('last') ? parseInt(qs.get('last')) : undefined;
3513
- const events = collectSwarmEvents(path.resolve(dir), { swarmId, agentId, last });
3514
- res.writeHead(200, { 'Content-Type': 'application/json', ...(corsOrigin ? { 'Access-Control-Allow-Origin': corsOrigin } : {}), 'Cache-Control': 'no-cache' });
3515
- res.end(JSON.stringify({ events, count: events.length }));
3516
- } catch (err) {
3517
- res.writeHead(500, { 'Content-Type': 'application/json' });
3518
- res.end(JSON.stringify({ error: err.message }));
3519
- }
3520
- return;
3521
- }
3416
+ const rel = qs.get('path');
3417
+ if (!rel || typeof rel !== 'string') {
3418
+ res.writeHead(400, { 'Content-Type': 'application/json' });
3419
+ res.end(JSON.stringify({ error: 'path query param required' }));
3420
+ return;
3421
+ }
3422
+ let resolved = path.resolve(dir, rel);
3423
+ try { resolved = fs.realpathSync(resolved); } catch {}
3522
3424
 
3523
- // ------------------------------------------------- GET /api/swarm-data-size
3524
- if (req.method === 'GET' && url === '/api/swarm-data-size') {
3525
- try {
3526
- const dir = new URL(req.url, 'http://localhost').searchParams.get('dir') || projectDir || process.cwd();
3527
- const size = getSwarmDataSize(path.resolve(dir));
3528
- res.writeHead(200, { 'Content-Type': 'application/json', ...(corsOrigin ? { 'Access-Control-Allow-Origin': corsOrigin } : {}) });
3529
- res.end(JSON.stringify(size));
3530
- } catch (err) {
3531
- res.writeHead(500, { 'Content-Type': 'application/json' });
3532
- res.end(JSON.stringify({ error: err.message }));
3533
- }
3534
- return;
3535
- }
3425
+ const allowedRoots = [path.resolve(projectDir || process.cwd())];
3426
+ const projectsBase = path.join(os.homedir(), '.claude', 'projects');
3427
+ try {
3428
+ for (const slug of fs.readdirSync(projectsBase)) {
3429
+ const resolvedProj = resolveSlugToPath(slug, path.join(projectsBase, slug));
3430
+ if (resolvedProj) allowedRoots.push(resolvedProj);
3431
+ }
3432
+ } catch { /* projects tree absent — fine */ }
3433
+ const globalBrain = process.env.MONOMIND_GLOBAL_BRAIN_DIR
3434
+ || path.join(os.homedir(), '.monomind', 'global-brain');
3435
+ if (fs.existsSync(globalBrain)) allowedRoots.push(globalBrain);
3536
3436
 
3537
- // ------------------------------------------------- DELETE /api/swarm-clean
3538
- if (req.method === 'DELETE' && url === '/api/swarm-clean') {
3539
- try {
3540
- const dir = new URL(req.url, 'http://localhost').searchParams.get('dir') || projectDir || process.cwd();
3541
- const result = cleanSwarmData(path.resolve(dir));
3542
- res.writeHead(200, { 'Content-Type': 'application/json', ...(corsOrigin ? { 'Access-Control-Allow-Origin': corsOrigin } : {}) });
3543
- res.end(JSON.stringify({ success: true, ...result }));
3437
+ const isAllowed = allowedRoots.some(root => {
3438
+ const relCheck = path.relative(root, resolved);
3439
+ return relCheck && !relCheck.startsWith('..') && !path.isAbsolute(relCheck);
3440
+ });
3441
+ if (!isAllowed || !fs.existsSync(resolved) || !fs.statSync(resolved).isFile()) {
3442
+ res.writeHead(403, { 'Content-Type': 'application/json' });
3443
+ res.end(JSON.stringify({ error: 'path is outside the allowed project roots' }));
3444
+ return;
3445
+ }
3446
+ if (!/\.(md|mdx)$/i.test(resolved)) {
3447
+ res.writeHead(400, { 'Content-Type': 'application/json' });
3448
+ res.end(JSON.stringify({ error: 'only markdown (.md/.mdx) files are readable' }));
3449
+ return;
3450
+ }
3451
+ const body = fs.readFileSync(resolved, 'utf8');
3452
+ res.writeHead(200, { 'Content-Type': 'application/json', ...(corsOrigin ? { 'Access-Control-Allow-Origin': corsOrigin } : {}), 'Cache-Control': 'no-cache' });
3453
+ res.end(JSON.stringify({ body }));
3544
3454
  } catch (err) {
3545
3455
  res.writeHead(500, { 'Content-Type': 'application/json' });
3546
3456
  res.end(JSON.stringify({ error: err.message }));