@jossuealcala/madre 1.0.1 → 1.0.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 (189) hide show
  1. package/ADDITIONAL-TERMS.md +93 -0
  2. package/CHANGELOG.en.md +2013 -0
  3. package/CHANGELOG.md +102 -1
  4. package/CONTRIBUTING.md +1 -1
  5. package/LICENSE +661 -202
  6. package/NOTICE +26 -14
  7. package/README.en.md +241 -0
  8. package/README.md +21 -12
  9. package/TRADEMARKS.en.md +50 -0
  10. package/TRADEMARKS.md +33 -16
  11. package/bin/madre.cjs +40 -0
  12. package/bin/madre.mjs +7 -0
  13. package/docs/INTERNALS.md +6 -0
  14. package/docs/REFERENCE.md +40 -5
  15. package/docs/SDK.md +4 -2
  16. package/docs/en/INTERNALS.md +103 -0
  17. package/docs/en/REFERENCE.md +365 -0
  18. package/docs/en/SDK.md +308 -0
  19. package/docs/en/training/README.md +59 -0
  20. package/docs/training/README.md +48 -9
  21. package/docs/training/train.sh +7 -2
  22. package/package.json +13 -5
  23. package/public/abduction.js +2 -0
  24. package/public/app.js +3154 -539
  25. package/public/attribution.js +114 -0
  26. package/public/brands.js +23 -6
  27. package/public/code-tools.js +143 -0
  28. package/public/editor.js +22 -4
  29. package/public/es.js +1004 -416
  30. package/public/hardware.js +50 -0
  31. package/public/i18n.js +25 -1
  32. package/public/index.html +18 -11
  33. package/public/inquiry.js +2 -0
  34. package/public/loader.js +2 -0
  35. package/public/macroverso.js +189 -96
  36. package/public/market-art.js +161 -0
  37. package/public/market-view.js +529 -0
  38. package/public/market.js +189 -0
  39. package/public/neural.js +110 -0
  40. package/public/nostromo-engine.js +414 -47
  41. package/public/power.js +100 -0
  42. package/public/resay.js +11 -0
  43. package/public/review-ask.js +2 -0
  44. package/public/routing.js +23 -1
  45. package/public/salas-mac.css +119 -18
  46. package/public/salas-mac.html +14 -2
  47. package/public/salas-mac.js +358 -56
  48. package/public/salas.css +2 -0
  49. package/public/salas.html +1 -0
  50. package/public/salas.js +4 -2
  51. package/public/styles.css +1793 -77
  52. package/public/syntax.js +2 -0
  53. package/public/touch.js +2 -0
  54. package/public/tour-chapters.js +2 -0
  55. package/public/tour.js +84 -5
  56. package/public/troubleshooting.js +24 -17
  57. package/public/wallpaper.js +275 -0
  58. package/src/adapters/claude.mjs +2 -0
  59. package/src/adapters/codex.mjs +2 -0
  60. package/src/adapters/cursor.mjs +96 -0
  61. package/src/adapters/gemini.mjs +13 -4
  62. package/src/adapters/madre.mjs +5 -2
  63. package/src/adapters/opencode.mjs +7 -3
  64. package/src/adapters/process.mjs +2 -0
  65. package/src/asking.mjs +2 -0
  66. package/src/auth-probe.mjs +145 -12
  67. package/src/bridge/devices.mjs +2 -0
  68. package/src/bridge/handoff.mjs +2 -0
  69. package/src/bridge/host.mjs +2 -0
  70. package/src/bridge/network.mjs +2 -0
  71. package/src/bridge/policy.mjs +3 -1
  72. package/src/bridge/qr.mjs +2 -0
  73. package/src/bridge/server.mjs +2 -0
  74. package/src/capabilities.mjs +11 -0
  75. package/src/chats.mjs +2 -0
  76. package/src/checkpoint.mjs +18 -1
  77. package/src/choices.mjs +2 -0
  78. package/src/cli-args.mjs +2 -0
  79. package/src/cold.mjs +2 -0
  80. package/src/commands.mjs +2 -0
  81. package/src/config.mjs +2 -0
  82. package/src/conversation-context.mjs +15 -1
  83. package/src/credentials.mjs +35 -3
  84. package/src/dataset.mjs +78 -4
  85. package/src/directives.mjs +2 -0
  86. package/src/distiller.mjs +2 -0
  87. package/src/embeddings.mjs +2 -0
  88. package/src/enlaces.mjs +214 -6
  89. package/src/event-store.mjs +2 -0
  90. package/src/exam.mjs +6 -4
  91. package/src/extensions.mjs +13 -3
  92. package/src/eyecat-watch.mjs +2 -0
  93. package/src/eyecat.mjs +2 -0
  94. package/src/file-ops.mjs +2 -0
  95. package/src/files.mjs +32 -0
  96. package/src/github.mjs +2 -0
  97. package/src/heartbeat.mjs +2 -0
  98. package/src/i18n.mjs +31 -1
  99. package/src/image-studio.mjs +2 -0
  100. package/src/json-file.mjs +2 -0
  101. package/src/launch.mjs +2 -0
  102. package/src/lease.mjs +5 -1
  103. package/src/lobby-work.mjs +2 -0
  104. package/src/local-ai.mjs +2 -0
  105. package/src/looks.mjs +127 -2
  106. package/src/market-key.mjs +8 -0
  107. package/src/market-remote.mjs +239 -0
  108. package/src/maturity.mjs +3 -1
  109. package/src/mcp/connect.mjs +345 -0
  110. package/src/mcp/github-server.mjs +2 -0
  111. package/src/mcp/image-server.mjs +2 -0
  112. package/src/mcp/memory-server.mjs +2 -0
  113. package/src/mcp/proxy.mjs +153 -0
  114. package/src/mcp/smtp-server.mjs +2 -0
  115. package/src/memory-dashboard.mjs +2 -0
  116. package/src/memory-tools.mjs +2 -0
  117. package/src/memory.mjs +2 -0
  118. package/src/models.mjs +49 -3
  119. package/src/module-scope.mjs +68 -0
  120. package/src/modules/ahp.mjs +104 -7
  121. package/src/modules/ash-policy.mjs +2 -0
  122. package/src/modules/ash.mjs +3 -1
  123. package/src/modules/bishop-exam.mjs +122 -0
  124. package/src/modules/bishop-plan.mjs +5 -3
  125. package/src/modules/bishop.mjs +84 -23
  126. package/src/modules/choices.mjs +2 -0
  127. package/src/modules/correo.mjs +2 -0
  128. package/src/modules/enlaces.mjs +89 -6
  129. package/src/modules/git-pulse.mjs +2 -0
  130. package/src/modules/github.mjs +2 -0
  131. package/src/modules/helpers.mjs +2 -0
  132. package/src/modules/image-studio.mjs +13 -2
  133. package/src/modules/index.mjs +4 -1
  134. package/src/modules/intents-calibration.mjs +2 -0
  135. package/src/modules/intents-desk.mjs +4 -2
  136. package/src/modules/intents-observe.mjs +2 -0
  137. package/src/modules/intents-policy.mjs +4 -1
  138. package/src/modules/intents-try.mjs +2 -0
  139. package/src/modules/intents.mjs +11 -7
  140. package/src/modules/local-models.mjs +124 -0
  141. package/src/modules/mcp-connect.mjs +293 -0
  142. package/src/modules/ollama.mjs +15 -4
  143. package/src/modules/playwright.mjs +88 -15
  144. package/src/modules/ripley.mjs +2 -0
  145. package/src/modules/runs.mjs +5 -3
  146. package/src/modules/sdk.mjs +63 -9
  147. package/src/modules/updates.mjs +2 -0
  148. package/src/mother.mjs +2 -0
  149. package/src/npm-launch.mjs +18 -0
  150. package/src/ollama.mjs +8 -5
  151. package/src/outbound.mjs +16 -6
  152. package/src/override.mjs +2 -0
  153. package/src/ports.mjs +6 -2
  154. package/src/prices.mjs +2 -0
  155. package/src/privacy.mjs +2 -0
  156. package/src/quota-monitor.mjs +2 -0
  157. package/src/quota-sources.mjs +2 -0
  158. package/src/request-guard.mjs +2 -0
  159. package/src/review-ask.mjs +2 -0
  160. package/src/room/archivist.mjs +2 -0
  161. package/src/room/attachments.mjs +2 -0
  162. package/src/room/budget.mjs +2 -0
  163. package/src/room/context.mjs +2 -0
  164. package/src/room/control.mjs +7 -1
  165. package/src/room/copilot.mjs +76 -0
  166. package/src/room/economy.mjs +2 -0
  167. package/src/room/escalation.mjs +2 -0
  168. package/src/room/ghost.mjs +2 -0
  169. package/src/room/guard.mjs +2 -0
  170. package/src/room/prompt.mjs +25 -5
  171. package/src/room/returns.mjs +82 -0
  172. package/src/room/runs.mjs +4 -0
  173. package/src/room/vectors.mjs +2 -0
  174. package/src/room.mjs +223 -35
  175. package/src/router.mjs +3 -1
  176. package/src/runs.mjs +6 -1
  177. package/src/runtime-detection.mjs +11 -0
  178. package/src/salas.mjs +28 -0
  179. package/src/sentinel-errors.mjs +2 -0
  180. package/src/server-sentinel.mjs +2 -0
  181. package/src/server.mjs +757 -81
  182. package/src/setup.mjs +3 -1
  183. package/src/smtp.mjs +2 -0
  184. package/src/tour.mjs +2 -0
  185. package/src/updates.mjs +2 -0
  186. package/src/usage-sentinel.mjs +2 -0
  187. package/src/vault.mjs +2 -0
  188. package/src/verdict.mjs +3 -1
  189. package/src/whats-new.mjs +2 -0
@@ -0,0 +1,114 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
3
+
4
+ // Who made MADRE (2026-10-15). This file is what term 1 of ADDITIONAL-TERMS.md asks every copy and
5
+ // every work based on MADRE to keep showing: the corner of every project and of Home, «MADRE
6
+ // <version> · © 2026 Jossue Alcalá» linked to its author, and the same attribution in the
7
+ // Appropriate Legal Notices (Settings › Updates and errors › License and authorship). One source
8
+ // for both pages, so the words cannot drift from NOTICE and the terms (test/attribution.test.mjs).
9
+
10
+ import { language, t } from './i18n.js';
11
+
12
+ export const AUTHOR = 'Jossue Alcalá';
13
+ export const AUTHOR_URL = 'https://jossuealcala.com';
14
+ export const YEAR = 2026;
15
+ export const LICENSE_NAME = 'GNU AGPL v3';
16
+ const REPO = 'https://github.com/jossuealcacao-exe/madre';
17
+ export const LEGAL_LINKS = {
18
+ license: `${REPO}/blob/main/LICENSE`,
19
+ terms: `${REPO}/blob/main/ADDITIONAL-TERMS.md`,
20
+ source: REPO,
21
+ };
22
+
23
+ export function drawAttribution(host, version) {
24
+ if (!host || !version) return null;
25
+ const link = document.createElement('a');
26
+ link.className = 'colophon-link';
27
+ link.href = AUTHOR_URL;
28
+ link.target = '_blank';
29
+ link.rel = 'noopener';
30
+ const name = document.createElement('b');
31
+ name.textContent = `MADRE ${version}`;
32
+ link.append(name, document.createTextNode(` · © ${YEAR} ${AUTHOR}`));
33
+ host.replaceChildren(link);
34
+ host.hidden = false;
35
+ return link;
36
+ }
37
+
38
+ // The keys modules put beside the version (src/modules/sdk.mjs, `footer`, 2026-10-18): drawn here,
39
+ // with MADRE's own icons, so a module never reaches into the page. Each one opens its address in a
40
+ // new tab; hovered or focused, it shows the module's words and the key that goes there. Asked again
41
+ // whenever the modules change.
42
+ const FOOTER_GLYPHS = {
43
+ heart: '<path d="M10 16.4s-6-3.5-6-7.9A3.3 3.3 0 0 1 10 6.6a3.3 3.3 0 0 1 6 1.9c0 4.4-6 7.9-6 7.9z" fill="currentColor"/>',
44
+ star: '<path d="M10 3.4l2 4.2 4.6.6-3.4 3.2.9 4.6L10 13.8 5.9 16l.9-4.6-3.4-3.2 4.6-.6z" fill="currentColor"/>',
45
+ link: '<path d="M8.6 11.4l2.8-2.8M9 5.8l1.5-1.5a3 3 0 0 1 4.2 4.2L13.2 10M11 14.2l-1.5 1.5a3 3 0 0 1-4.2-4.2L6.8 10" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>',
46
+ };
47
+ const footerWords = (value) => (value == null ? '' : typeof value === 'string' ? value : value[language()] ?? value.en ?? value.es ?? '');
48
+ export async function drawColophonKeys(host) {
49
+ if (!host) return null;
50
+ const answer = await fetch('/api/modules/footer', { cache: 'no-store' }).then((response) => (response.ok ? response.json() : null)).catch(() => null);
51
+ host.querySelector('.colophon-keys')?.remove();
52
+ const keys = (answer?.keys ?? []).flatMap((module) => (module.footer ?? []).map((key) => ({ ...key, module: module.id })));
53
+ if (!keys.length) return null;
54
+ const make = (tag, className, text) => { const node = document.createElement(tag); if (className) node.className = className; if (text != null) node.textContent = text; return node; };
55
+ const box = make('span', 'colophon-keys');
56
+ for (const key of keys) {
57
+ const label = footerWords(key.label);
58
+ const one = make('span', `colophon-key ${key.icon}`);
59
+ const press = make('a', 'colophon-key-mark');
60
+ press.href = key.url;
61
+ press.target = '_blank';
62
+ press.rel = 'noopener';
63
+ press.setAttribute('aria-label', label);
64
+ press.innerHTML = `<svg viewBox="0 0 20 20" aria-hidden="true">${FOOTER_GLYPHS[key.icon] ?? FOOTER_GLYPHS.link}</svg>`; // trusted: our own SVG strings
65
+ const card = make('span', 'colophon-card');
66
+ card.setAttribute('role', 'tooltip');
67
+ card.append(make('b', null, label));
68
+ if (footerWords(key.text)) card.append(make('span', null, footerWords(key.text)));
69
+ const go = make('a', 'colophon-card-go', footerWords(key.action) || label);
70
+ go.href = key.url;
71
+ go.target = '_blank';
72
+ go.rel = 'noopener';
73
+ card.append(go);
74
+ one.append(press, card);
75
+ box.append(one);
76
+ }
77
+ host.append(box);
78
+ return box;
79
+ }
80
+
81
+ // The Appropriate Legal Notices (AGPL v3, section 0 and 5(d)): the copyright, that there is no
82
+ // warranty, that it may be shared under the License, how to read it — and, by term 1(b) of the
83
+ // additional terms, the author's attribution. Built with the Settings blocks both pages share.
84
+ export function drawLegal(host) {
85
+ if (!host) return null;
86
+ const make = (tag, className, text) => { const node = document.createElement(tag); if (className) node.className = className; if (text != null) node.textContent = text; return node; };
87
+ const block = make('section', 'sx-block sx-legal');
88
+ const head = make('header', 'sx-head');
89
+ const words = make('div', 'sx-words');
90
+ words.append(make('h3', null, t('License and authorship')), make('p', null, t('© {year} {author}. MADRE is free software: you can share it and change it under the {license}, with additional terms that keep its author\'s attribution visible. It comes with no warranty of any kind.', { year: YEAR, author: AUTHOR, license: LICENSE_NAME })));
91
+ head.append(words);
92
+ const rows = make('div', 'sx-rows');
93
+ const row = (label, hint, url, words) => {
94
+ const line = make('div', 'sx-row');
95
+ const text = make('div', 'sx-words');
96
+ text.append(make('b', null, label));
97
+ if (hint) text.append(make('span', 'sx-hint', hint));
98
+ const box = make('div', 'sx-control');
99
+ const link = make('a', 'sx-btn', words);
100
+ link.href = url;
101
+ link.target = '_blank';
102
+ link.rel = 'noopener';
103
+ box.append(link);
104
+ line.append(text, box);
105
+ rows.append(line);
106
+ };
107
+ row(t('Made by {author}', { author: AUTHOR }), t('Every copy and every version based on MADRE keeps this attribution visible.'), AUTHOR_URL, 'jossuealcala.com');
108
+ row(t('The license'), LICENSE_NAME, LEGAL_LINKS.license, t('Read'));
109
+ row(t('The additional terms'), t('Author attribution, origin and trademarks (Section 7).'), LEGAL_LINKS.terms, t('Read'));
110
+ row(t('The source code'), t('Whoever offers a modified MADRE must offer its source code too.'), LEGAL_LINKS.source, 'GitHub');
111
+ block.append(head, rows);
112
+ host.append(block);
113
+ return block;
114
+ }
package/public/brands.js CHANGED
@@ -1,7 +1,10 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
1
3
  // Visual identity per agent.
2
4
  //
3
5
  // Colors follow each vendor's public palette so a Codex bubble reads as Codex
4
- // and a Claude bubble as Claude. The marks are MADRE's own monograms: the
6
+ // and a Claude bubble as Claude. The marks are drawn by MADRE: free takes that
7
+ // recall each vendor's mark without reproducing it (2026-10-17), because the
5
8
  // vendors' logos are registered trademarks and their brand guidelines do not
6
9
  // allow embedding them in third-party products without permission. To use an
7
10
  // official asset under a license you hold, set `mark` to that inline SVG.
@@ -18,7 +21,8 @@ export const BRANDS = {
18
21
  color: '#10A37F',
19
22
  colorLight: '#0B7A5F',
20
23
  ink: '#FFFFFF',
21
- mark: () => glyph('<path fill="currentColor" d="M16 4l10.4 6v12L16 28 5.6 22V10L16 4zm0 3.4L8.5 11.7v8.6L16 24.6l7.5-4.3v-8.6L16 7.4zm0 4.2l3.9 2.2v4.4L16 20.4l-3.9-2.2v-4.4L16 11.6z"/>'),
24
+ // A free take on OpenAI's blossom (2026-10-17): three rounded links turned 60° and knotted.
25
+ mark: () => glyph('<g fill="none" stroke="currentColor" stroke-width="2.4"><rect x="11.2" y="4.4" width="9.6" height="23.2" rx="4.8"/><rect x="11.2" y="4.4" width="9.6" height="23.2" rx="4.8" transform="rotate(60 16 16)"/><rect x="11.2" y="4.4" width="9.6" height="23.2" rx="4.8" transform="rotate(120 16 16)"/></g>'),
22
26
  },
23
27
  claude: {
24
28
  label: 'Claude',
@@ -26,7 +30,8 @@ export const BRANDS = {
26
30
  color: '#D97757',
27
31
  colorLight: '#B85C3C',
28
32
  ink: '#FFFFFF',
29
- mark: () => glyph('<path fill="currentColor" d="M15 5h2l1.2 8.3 6.1-5.7 1.4 1.4-5.7 6.1L28.3 16v2l-8.3 1.2 5.7 6.1-1.4 1.4-6.1-5.7L17 29h-2l-1.2-8.3-6.1 5.7-1.4-1.4 5.7-6.1L3.7 18v-2l8.3-1.2-5.7-6.1 1.4-1.4 6.1 5.7L15 5z" opacity=".95"/>'),
33
+ // A free take on Claude's spark (2026-10-17): ten tapered rays of uneven length round a heart.
34
+ mark: () => glyph('<circle cx="16" cy="16" r="3" fill="currentColor"/><path fill="currentColor" d="M15.5 14.48L16.4 2.61L17.4 2.07L18.27 2.79L16.8 14.61ZM16.49 14.48L22.85 7.14L23.85 7.19L24.02 8.18L17.46 15.34ZM17.29 15.05L28.29 12.41L29.09 13.15L28.67 14.16L17.57 16.33ZM17.6 15.99L25.98 19.58L26.25 20.51L25.38 20.93L17.07 17.19ZM17.3 16.93L23.66 27.24L23.16 28.26L22.02 28.2L16.17 17.59ZM16.5 17.52L15.65 27.59L14.78 28.14L14.04 27.43L15.2 17.39ZM15.51 17.52L8.41 25.81L7.35 25.7L7.12 24.65L14.54 16.66ZM14.71 16.95L6.02 18.92L5.25 18.34L5.71 17.49L14.43 15.67ZM14.4 16.01L3.57 11.55L3.37 10.45L4.32 9.86L14.93 14.81ZM14.7 15.07L9.8 6.91L10.15 5.98L11.13 6.14L15.83 14.41Z"/>'),
30
35
  },
31
36
  gemini: {
32
37
  label: 'Gemini',
@@ -35,7 +40,8 @@ export const BRANDS = {
35
40
  colorLight: '#5B45B8',
36
41
  ink: '#FFFFFF',
37
42
  gradient: 'linear-gradient(135deg, #4796E3 0%, #8E75E8 55%, #D96570 100%)',
38
- mark: () => glyph('<path fill="currentColor" d="M16 3c.6 7.1 5.9 12.4 13 13-7.1.6-12.4 5.9-13 13-.6-7.1-5.9-12.4-13-13 7.1-.6 12.4-5.9 13-13z"/>'),
43
+ // A free take on Gemini's sparkle (2026-10-17): four points in long curves, taller than wide.
44
+ mark: () => glyph('<path fill="currentColor" d="M16 2.4C16.8 10.2 21.4 15 29 16 21.4 17 16.8 21.8 16 29.6 15.2 21.8 10.6 17 3 16 10.6 15 15.2 10.2 16 2.4z"/>'),
39
45
  },
40
46
  opencode: {
41
47
  label: 'OpenCode',
@@ -43,7 +49,18 @@ export const BRANDS = {
43
49
  color: '#F2C94C',
44
50
  colorLight: '#9A7A12',
45
51
  ink: '#111111',
46
- mark: () => glyph('<path fill="currentColor" d="M11 8l-7 8 7 8 2.2-2.2L7.4 16l5.8-5.8L11 8zm10 0l-2.2 2.2 5.8 5.8-5.8 5.8L21 24l7-8-7-8zm-3.6-1.5l2.3.6-5.1 18.4-2.3-.6 5.1-18.4z"/>'),
52
+ // A free take on OpenCode's block letter (2026-10-17): a square ring with a lighter block inside.
53
+ mark: () => glyph('<path fill="currentColor" fill-rule="evenodd" d="M6 6h20v20H6zM10.4 10.4v11.2h11.2V10.4z"/><path fill="currentColor" opacity=".5" d="M10.4 15.2h11.2v6.4H10.4z"/>'),
54
+ },
55
+ // MADRE's own drawing for Cursor (2026-10-17), a free take on its mark rather than the mark: a
56
+ // cube seen from above, its three faces in three weights, and a pointer cut out of the right face.
57
+ cursor: {
58
+ label: 'Cursor',
59
+ vendor: 'Cursor',
60
+ color: '#D9DEE5',
61
+ colorLight: '#3A3F47',
62
+ ink: '#111111',
63
+ mark: () => glyph('<path fill="currentColor" d="M16 3.2 27.4 9.6 16 16 4.6 9.6z"/><path fill="currentColor" opacity=".42" d="M4.6 9.6 16 16v12.8L4.6 22.4z"/><path fill="currentColor" fill-rule="evenodd" opacity=".7" d="M27.4 9.6v12.8L16 28.8V16zM16 16l11.4-6.4-6.2 12-1.9-3.9z"/>'),
47
64
  },
48
65
  };
49
66
 
@@ -72,7 +89,7 @@ BRANDS.ollama = {
72
89
  // the same hue lifted; on a light ground the plum itself reads.
73
90
  BRANDS.bishop = {
74
91
  label: 'Coordinator',
75
- vendor: 'the Coordinator\'s project',
92
+ vendor: 'Home',
76
93
  color: '#D598D7',
77
94
  colorLight: '#5C255E',
78
95
  solid: '#5C255E',
@@ -0,0 +1,143 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
3
+ // The code workspace's arithmetic (1.0.3), kept apart from the page so it can be tested on its own:
4
+ // what an agent changed in a file line by line, where each of those changes stands in the file as
5
+ // it is now, what the file reads like with one of them taken back, and which function a line is in.
6
+ // Nothing here touches the page, the network or the disk.
7
+
8
+ const lines = (text) => String(text ?? '').replace(/\r\n/g, '\n').split('\n');
9
+
10
+ // The changes between two texts as runs of lines: each says where it starts on each side and what
11
+ // it removed and put. The common start and end are set aside first; what is left is compared line
12
+ // by line (longest common subsequence). Past `maxCells` comparisons the middle is one change: a
13
+ // file rewritten that much is read as rewritten, not taken apart.
14
+ export function lineDiff(beforeText, afterText, { maxCells = 2_000_000 } = {}) {
15
+ const a = lines(beforeText);
16
+ const b = lines(afterText);
17
+ let start = 0;
18
+ while (start < a.length && start < b.length && a[start] === b[start]) start += 1;
19
+ let endA = a.length;
20
+ let endB = b.length;
21
+ while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) { endA -= 1; endB -= 1; }
22
+ const A = a.slice(start, endA);
23
+ const B = b.slice(start, endB);
24
+ if (!A.length && !B.length) return [];
25
+ if (A.length * B.length > maxCells) return [{ beforeStart: start, before: A, afterStart: start, after: B }];
26
+ const n = A.length;
27
+ const m = B.length;
28
+ const width = m + 1;
29
+ const table = new Uint32Array((n + 1) * width);
30
+ for (let i = n - 1; i >= 0; i -= 1) {
31
+ for (let j = m - 1; j >= 0; j -= 1) {
32
+ table[i * width + j] = A[i] === B[j] ? table[(i + 1) * width + j + 1] + 1 : Math.max(table[(i + 1) * width + j], table[i * width + j + 1]);
33
+ }
34
+ }
35
+ const hunks = [];
36
+ let open = null;
37
+ let i = 0;
38
+ let j = 0;
39
+ while (i < n || j < m) {
40
+ if (i < n && j < m && A[i] === B[j]) { if (open) { hunks.push(open); open = null; } i += 1; j += 1; continue; }
41
+ if (!open) open = { beforeStart: start + i, before: [], afterStart: start + j, after: [] };
42
+ if (j < m && (i === n || table[i * width + j + 1] >= table[(i + 1) * width + j])) { open.after.push(B[j]); j += 1; }
43
+ else { open.before.push(A[i]); i += 1; }
44
+ }
45
+ if (open) hunks.push(open);
46
+ return hunks;
47
+ }
48
+
49
+ // Where each change of an agent stands in the file as it is now. Untouched since the turn, a change
50
+ // is where the turn left it. Edited since, it is found by its own lines, nearest to where it was; a
51
+ // change whose lines are no longer there (the human rewrote them) is no longer the agent's, and is
52
+ // left out. A change that only removed lines is found by the line the turn left just before it.
53
+ export function placeHunks(currentText, afterText, hunks) {
54
+ const now = lines(currentText);
55
+ if (afterText === null || afterText === undefined || String(afterText).replace(/\r\n/g, '\n') === now.join('\n')) return hunks.map((hunk) => ({ ...hunk, at: hunk.afterStart }));
56
+ const after = lines(afterText);
57
+ const out = [];
58
+ for (const hunk of hunks) {
59
+ let at = -1;
60
+ if (hunk.after.length) at = nearest(now, hunk.after, hunk.afterStart);
61
+ else {
62
+ const before = hunk.afterStart > 0 ? after[hunk.afterStart - 1] : null;
63
+ if (before === null) at = now.length && now[0] === after[0] ? 0 : -1;
64
+ else { const found = nearest(now, [before], hunk.afterStart - 1); at = found < 0 ? -1 : found + 1; }
65
+ }
66
+ if (at >= 0) out.push({ ...hunk, at });
67
+ }
68
+ return out;
69
+ }
70
+ function nearest(haystack, block, hint) {
71
+ let best = -1;
72
+ for (let k = 0; k + block.length <= haystack.length; k += 1) {
73
+ let same = true;
74
+ for (let x = 0; x < block.length; x += 1) if (haystack[k + x] !== block[x]) { same = false; break; }
75
+ if (same && (best < 0 || Math.abs(k - hint) < Math.abs(best - hint))) best = k;
76
+ }
77
+ return best;
78
+ }
79
+
80
+ // The file with one change taken back: the lines the agent put, where they stand now, become the
81
+ // lines it removed. Everything else stays byte for byte, line endings included.
82
+ export function revertHunk(currentText, hunk) {
83
+ const text = String(currentText ?? '');
84
+ const crlf = /\r\n/.test(text);
85
+ const now = lines(text);
86
+ now.splice(hunk.at, hunk.after.length, ...hunk.before);
87
+ return now.join(crlf ? '\r\n' : '\n');
88
+ }
89
+
90
+ // A change named by what it says, so «kept» survives a reload and never sticks to another change.
91
+ export function hunkKey(hunk) {
92
+ let hash = 0x811c9dc5;
93
+ const said = `${hunk.before.join('\n')}\u0000${hunk.after.join('\n')}`;
94
+ for (let i = 0; i < said.length; i += 1) {
95
+ hash ^= said.charCodeAt(i);
96
+ hash = Math.imul(hash, 0x01000193) >>> 0;
97
+ }
98
+ return hash.toString(16).padStart(8, '0');
99
+ }
100
+
101
+ // The function, class or heading a line belongs to, for the path above the code: the nearest line
102
+ // above it that opens one and is indented no deeper. `null` when nothing encloses it.
103
+ const OPENS = {
104
+ js: [
105
+ /^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s*\*?\s*([A-Za-z_$][\w$]*)/,
106
+ /^\s*(?:export\s+)?(?:default\s+)?class\s+([A-Za-z_$][\w$]*)/,
107
+ /^\s*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(?:function\b|\([^)]*\)\s*=>|[A-Za-z_$][\w$]*\s*=>)/,
108
+ /^\s*(?:static\s+)?(?:async\s+)?(?:get\s+|set\s+)?(#?[A-Za-z_$][\w$]*)\s*\([^)]*\)\s*\{\s*$/,
109
+ ],
110
+ py: [/^\s*(?:async\s+)?def\s+([A-Za-z_]\w*)/, /^\s*class\s+([A-Za-z_]\w*)/],
111
+ css: [/^\s*([^{}@\s][^{}]*?)\s*\{\s*$/, /^\s*(@media[^{]*?)\s*\{/],
112
+ sh: [/^\s*(?:function\s+)?([A-Za-z_][\w-]*)\s*\(\)\s*\{?/],
113
+ };
114
+ const NOT_NAMES = new Set(['if', 'for', 'while', 'switch', 'catch', 'function', 'return', 'else', 'do', 'try']);
115
+ export function symbolAt(textOrLines, index, familyId) {
116
+ const all = Array.isArray(textOrLines) ? textOrLines : lines(textOrLines);
117
+ if (index < 0 || index >= all.length) return null;
118
+ if (familyId === 'md') {
119
+ for (let k = index; k >= 0; k -= 1) { const heading = all[k].match(/^#{1,6}\s+(.+?)\s*#*\s*$/); if (heading) return heading[1]; }
120
+ return null;
121
+ }
122
+ const patterns = OPENS[familyId];
123
+ if (!patterns) return null;
124
+ const depth = (line) => line.match(/^\s*/)[0].replace(/\t/g, ' ').length;
125
+ let limit = all[index].trim() ? depth(all[index]) : Infinity;
126
+ // A block closed above the line (a sibling function that ended) is skipped whole: its closing
127
+ // line is counted at its depth, and the opening line at that depth that matches it is passed over.
128
+ const closed = new Map();
129
+ for (let k = index; k >= 0 && index - k < 4000; k -= 1) {
130
+ const line = all[k];
131
+ const said = line.trim();
132
+ if (!said) continue;
133
+ const indent = depth(line);
134
+ if (k !== index && indent > limit) continue;
135
+ if (k !== index && /^[}\])]/.test(said) && !/[{([]\s*$/.test(said)) { closed.set(indent, (closed.get(indent) ?? 0) + 1); continue; }
136
+ const name = patterns.map((pattern) => said && line.match(pattern)?.[1]).find(Boolean) ?? null;
137
+ const opens = Boolean(name) || /[{([]\s*$/.test(said) || (familyId === 'py' && /:\s*$/.test(said));
138
+ if (k !== index && opens && (closed.get(indent) ?? 0) > 0) { closed.set(indent, closed.get(indent) - 1); continue; }
139
+ if (name && !NOT_NAMES.has(name)) return name.trim().slice(0, 80);
140
+ if (k !== index) limit = Math.min(limit, indent);
141
+ }
142
+ return null;
143
+ }
package/public/editor.js CHANGED
@@ -1,3 +1,5 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
1
3
  // The editor: a file of the project, written by the human's own hand.
2
4
  //
3
5
  // A textarea does the typing — the caret, the selection, IME, spellcheck off, the browser's own
@@ -33,6 +35,8 @@ const ICONS = {
33
35
  wrap: svg('M3.5 5.5h13M3.5 10h10a2.6 2.6 0 0 1 0 5.2H10M11.8 13.3 10 15.2l1.8 1.8M3.5 15h3.5'),
34
36
  ask: svg('M4 5.6A2.6 2.6 0 0 1 6.6 3h6.8A2.6 2.6 0 0 1 16 5.6v4.8A2.6 2.6 0 0 1 13.4 13H9.2L5.6 16v-3A2.6 2.6 0 0 1 4 10.4z'),
35
37
  link: svg('M8.3 11.7l3.4-3.4M7.2 13.2 6 14.4a2.6 2.6 0 0 1-3.7-3.7l2.6-2.6a2.6 2.6 0 0 1 3.7 0M12.8 6.8 14 5.6a2.6 2.6 0 0 1 3.7 3.7l-2.6 2.6a2.6 2.6 0 0 1-3.7 0'),
38
+ // The room's chooser chevron (app.js, ICON_CHEVRON), drawn on this grid: a menu opens under it.
39
+ chevron: svg('M5.25 7.75 10 12.5l4.75-4.75'),
36
40
  };
37
41
 
38
42
  // Spaces or tabs, and how many: what the file already does, not what the editor prefers.
@@ -102,7 +106,11 @@ export function findAll(text, query, { caseSensitive = false, regex = false, wor
102
106
  return found;
103
107
  }
104
108
 
105
- export function createEditor({ text = '', name = '', family = null, tokenize = null, t = (s) => s, fill = null, onSave = null, onAsk = null, onDirty = null }) {
109
+ // `draft`: what the human had written and not saved, kept by the room while another file was on
110
+ // screen (the code workspace's tabs); the file on disk stays `text`, so the editor knows it is unsaved.
111
+ // `onCaret(line)`: the line the caret is on, whenever it moves. `setMarks` paints lines in the margin
112
+ // (an agent's changes, in its colour) without touching the text.
113
+ export function createEditor({ text = '', draft = null, name = '', family = null, tokenize = null, t = (s) => s, fill = null, onSave = null, onAsk = null, onDirty = null, onCaret = null }) {
106
114
  const say = (key, vars) => (vars && fill ? fill(t(key), vars) : t(key));
107
115
  const crlf = /\r\n/.test(text);
108
116
  const indent = detectIndent(text);
@@ -153,7 +161,7 @@ export function createEditor({ text = '', name = '', family = null, tokenize = n
153
161
  <div class="ed-code">
154
162
  <div class="ed-active" aria-hidden="true"></div>
155
163
  <div class="ed-float" hidden>
156
- <button type="button" class="ed-chip ask" data-float="ask"><i class="ed-ico">${ICONS.ask}</i><span></span><b>▾</b></button>
164
+ <button type="button" class="ed-chip ask" data-float="ask"><i class="ed-ico">${ICONS.ask}</i><span></span><i class="ed-chev">${ICONS.chevron}</i></button>
157
165
  <button type="button" class="ed-chip" data-float="comment"><i class="ed-ico">${ICONS.comment}</i><span></span></button>
158
166
  <button type="button" class="ed-chip" data-float="copy"><i class="ed-ico">${ICONS.link}</i><span></span></button>
159
167
  </div>
@@ -200,7 +208,7 @@ export function createEditor({ text = '', name = '', family = null, tokenize = n
200
208
  $('.ed-indent').textContent = indent.tabs ? t('Tabs') : say('Spaces: {n}', { n: indent.size });
201
209
  $('.ed-eol').textContent = crlf ? 'CRLF' : 'LF';
202
210
  $('.ed-lang').textContent = family?.label ?? t('Plain text');
203
- input.value = saved;
211
+ input.value = draft === null || draft === undefined ? saved : String(draft).replace(/\r\n/g, '\n');
204
212
  // A textarea given its text holds the caret after the last character; the first focus would jump
205
213
  // there. Editing starts where reading did: at the top, or at the line asked for.
206
214
  input.setSelectionRange?.(0, 0);
@@ -240,6 +248,8 @@ export function createEditor({ text = '', name = '', family = null, tokenize = n
240
248
  return html;
241
249
  }
242
250
  let lineCount = 1;
251
+ let lastCaret = 0;
252
+ let marks = new Map();
243
253
  function countLines() { return lineCount; }
244
254
  let frame = 0;
245
255
  function draw() {
@@ -262,7 +272,8 @@ export function createEditor({ text = '', name = '', family = null, tokenize = n
262
272
  const here = [];
263
273
  for (let j = markAt; j < matches.length && matches[j].start < end; j += 1) here.push({ ...matches[j], cur: j === current });
264
274
  html.push(`<div class="l">${drawLine(line, carry, offset, here) || ' '}</div>`);
265
- numbers.push(`<div>${i + 1}</div>`);
275
+ const mark = marks.get(i + 1);
276
+ numbers.push(mark ? `<div class="mk" style="--mk:${mark.color}" title="${escape(mark.title ?? '')}">${i + 1}</div>` : `<div>${i + 1}</div>`);
266
277
  offset = end + 1;
267
278
  }
268
279
  hl.innerHTML = html.join('');
@@ -322,6 +333,7 @@ export function createEditor({ text = '', name = '', family = null, tokenize = n
322
333
  const line = caretLine(at);
323
334
  const col = at - (value.lastIndexOf('\n', at - 1) + 1) + 1;
324
335
  $('.ed-pos').textContent = say('Ln {line}, Col {col}', { line, col });
336
+ if (line !== lastCaret) { lastCaret = line; onCaret?.(line); }
325
337
  const chosen = input.selectionEnd - input.selectionStart;
326
338
  $('.ed-sel').textContent = chosen ? say('{n} selected', { n: chosen }) : '';
327
339
  const dirty = isDirty();
@@ -738,6 +750,12 @@ export function createEditor({ text = '', name = '', family = null, tokenize = n
738
750
  save,
739
751
  // The file on disk changed under the editor and the human chose the disk's version.
740
752
  reset(next) { saved = String(next).replace(/\r\n/g, '\n'); input.value = saved; changed(); },
753
+ // Lines to mark in the margin: [{ from, to, color, title }], one-based. An empty list clears them.
754
+ setMarks(list = []) {
755
+ marks = new Map();
756
+ for (const one of list) for (let n = one.from; n <= one.to; n += 1) marks.set(n, { color: /^[#\w(),.%\s-]{1,40}$/.test(one.color ?? '') ? one.color : 'currentColor', title: one.title ?? '' });
757
+ draw();
758
+ },
741
759
  // Taken off the page: what it listened to outside itself goes with it.
742
760
  destroy() { document.removeEventListener('selectionchange', followSelection); resized?.disconnect(); cancelAnimationFrame(frame); },
743
761
  };