@jossuealcala/madre 0.7.2 → 1.0.1

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 (172) hide show
  1. package/CHANGELOG.md +817 -1
  2. package/CONTRIBUTING.md +5 -1
  3. package/NOTICE +20 -1
  4. package/README.md +99 -34
  5. package/TRADEMARKS.md +65 -0
  6. package/bin/madre.mjs +30 -10
  7. package/docs/INTERNALS.md +2 -2
  8. package/docs/REFERENCE.md +159 -82
  9. package/docs/SDK.md +23 -19
  10. package/docs/madre-banner.png +0 -0
  11. package/package.json +4 -3
  12. package/public/app.js +11668 -4219
  13. package/public/assets/brand/Geist-latin-ext.woff2 +0 -0
  14. package/public/assets/brand/Geist-latin.woff2 +0 -0
  15. package/public/assets/brand/GeistMono-latin-ext.woff2 +0 -0
  16. package/public/assets/brand/GeistMono-latin.woff2 +0 -0
  17. package/public/assets/brand/JetBrainsMono-latin-ext.woff2 +0 -0
  18. package/public/assets/brand/JetBrainsMono-latin.woff2 +0 -0
  19. package/public/assets/brand/OFL-Geist.txt +93 -0
  20. package/public/assets/brand/OFL-GeistMono.txt +93 -0
  21. package/public/assets/brand/OFL-JetBrainsMono.txt +93 -0
  22. package/public/assets/brand/OFL-Sora.txt +93 -0
  23. package/public/assets/brand/Sora-latin-ext.woff2 +0 -0
  24. package/public/assets/brand/Sora-latin.woff2 +0 -0
  25. package/public/assets/brand/madre-app-180.png +0 -0
  26. package/public/assets/brand/madre-app-192.png +0 -0
  27. package/public/assets/brand/madre-app-512.png +0 -0
  28. package/public/assets/brand/madre-app.svg +1 -0
  29. package/public/assets/brand/madre-favicon.svg +1 -0
  30. package/public/assets/news/1.0/alerts.webp +0 -0
  31. package/public/assets/news/1.0/bar.webp +0 -0
  32. package/public/assets/news/1.0/finder.webp +0 -0
  33. package/public/assets/news/1.0/github.webp +0 -0
  34. package/public/assets/news/1.0/intents.webp +0 -0
  35. package/public/assets/news/1.0/looks.webp +0 -0
  36. package/public/assets/news/1.0/modules.webp +0 -0
  37. package/public/assets/news/1.0/mother.webp +0 -0
  38. package/public/assets/news/1.0/nostromo.webp +0 -0
  39. package/public/assets/news/1.0/permissions.webp +0 -0
  40. package/public/assets/news/1.0/room.webp +0 -0
  41. package/public/assets/news/1.0/settings.webp +0 -0
  42. package/public/assets/wallpapers/biznaga.webp +0 -0
  43. package/public/assets/wallpapers/canela.webp +0 -0
  44. package/public/assets/wallpapers/jarrito.webp +0 -0
  45. package/public/assets/wallpapers/magna.webp +0 -0
  46. package/public/assets/wallpapers/oceano.webp +0 -0
  47. package/public/assets/wallpapers/paraiso.webp +0 -0
  48. package/public/assets/wallpapers/quila.webp +0 -0
  49. package/public/assets/wallpapers/tradicion.webp +0 -0
  50. package/public/assets/wallpapers/tropical.webp +0 -0
  51. package/public/assets/wallpapers/urbania.webp +0 -0
  52. package/public/assets/wallpapers/ventura.webp +0 -0
  53. package/public/assets/wallpapers/xico.webp +0 -0
  54. package/public/brands.js +28 -2
  55. package/public/editor.js +744 -0
  56. package/public/es.js +3443 -1425
  57. package/public/i18n.js +1 -1
  58. package/public/index.html +201 -175
  59. package/public/inquiry.js +11 -11
  60. package/public/loader.js +79 -0
  61. package/public/macroverso.js +1517 -0
  62. package/public/manifest.webmanifest +14 -0
  63. package/public/nostromo-engine.js +2222 -0
  64. package/public/resay.js +24 -0
  65. package/public/review-ask.js +85 -0
  66. package/public/salas-mac.css +1281 -0
  67. package/public/salas-mac.html +158 -0
  68. package/public/salas-mac.js +4593 -0
  69. package/public/salas.css +85 -0
  70. package/public/salas.html +47 -0
  71. package/public/salas.js +200 -0
  72. package/public/styles.css +6372 -426
  73. package/public/touch.js +83 -0
  74. package/public/tour-chapters.js +452 -0
  75. package/public/tour.js +546 -0
  76. package/public/troubleshooting.js +160 -141
  77. package/src/adapters/codex.mjs +46 -6
  78. package/src/adapters/gemini.mjs +30 -10
  79. package/src/adapters/madre.mjs +110 -18
  80. package/src/adapters/process.mjs +42 -2
  81. package/src/auth-probe.mjs +31 -8
  82. package/src/bridge/devices.mjs +202 -0
  83. package/src/bridge/handoff.mjs +45 -0
  84. package/src/bridge/host.mjs +82 -0
  85. package/src/bridge/network.mjs +133 -0
  86. package/src/bridge/policy.mjs +89 -0
  87. package/src/bridge/qr.mjs +287 -0
  88. package/src/bridge/server.mjs +269 -0
  89. package/src/capabilities.mjs +5 -9
  90. package/src/chats.mjs +59 -22
  91. package/src/checkpoint.mjs +137 -35
  92. package/src/choices.mjs +18 -3
  93. package/src/cli-args.mjs +12 -1
  94. package/src/commands.mjs +15 -15
  95. package/src/config.mjs +73 -9
  96. package/src/conversation-context.mjs +5 -2
  97. package/src/credentials.mjs +80 -0
  98. package/src/dataset.mjs +3 -1
  99. package/src/directives.mjs +6 -11
  100. package/src/enlaces.mjs +451 -0
  101. package/src/event-store.mjs +55 -10
  102. package/src/exam.mjs +5 -5
  103. package/src/extensions.mjs +31 -12
  104. package/src/file-ops.mjs +23 -0
  105. package/src/files.mjs +52 -2
  106. package/src/github.mjs +407 -0
  107. package/src/heartbeat.mjs +55 -0
  108. package/src/json-file.mjs +63 -0
  109. package/src/lease.mjs +2 -2
  110. package/src/lobby-work.mjs +103 -0
  111. package/src/local-ai.mjs +48 -0
  112. package/src/looks.mjs +88 -0
  113. package/src/maturity.mjs +3 -3
  114. package/src/mcp/github-server.mjs +164 -0
  115. package/src/mcp/image-server.mjs +1 -1
  116. package/src/mcp/memory-server.mjs +11 -3
  117. package/src/memory.mjs +38 -12
  118. package/src/models.mjs +127 -5
  119. package/src/modules/ahp.mjs +4 -4
  120. package/src/modules/ash.mjs +1 -1
  121. package/src/modules/bishop-plan.mjs +207 -0
  122. package/src/modules/bishop.mjs +645 -0
  123. package/src/modules/choices.mjs +2 -2
  124. package/src/modules/correo.mjs +1 -1
  125. package/src/modules/enlaces.mjs +255 -0
  126. package/src/modules/git-pulse.mjs +2 -2
  127. package/src/modules/github.mjs +322 -0
  128. package/src/modules/image-studio.mjs +3 -3
  129. package/src/modules/index.mjs +18 -4
  130. package/src/modules/intents-calibration.mjs +338 -0
  131. package/src/modules/intents-desk.mjs +481 -0
  132. package/src/modules/intents-observe.mjs +266 -0
  133. package/src/modules/intents-policy.mjs +414 -0
  134. package/src/modules/intents-try.mjs +91 -0
  135. package/src/modules/intents.mjs +145 -0
  136. package/src/modules/ollama.mjs +18 -6
  137. package/src/modules/playwright.mjs +121 -11
  138. package/src/modules/ripley.mjs +1 -1
  139. package/src/modules/runs.mjs +2 -2
  140. package/src/modules/sdk.mjs +10 -4
  141. package/src/ollama.mjs +6 -1
  142. package/src/outbound.mjs +36 -17
  143. package/src/override.mjs +100 -0
  144. package/src/ports.mjs +23 -1
  145. package/src/prices.mjs +72 -0
  146. package/src/privacy.mjs +30 -11
  147. package/src/quota-sources.mjs +0 -8
  148. package/src/request-guard.mjs +1 -1
  149. package/src/review-ask.mjs +111 -0
  150. package/src/room/archivist.mjs +55 -37
  151. package/src/room/budget.mjs +17 -5
  152. package/src/room/control.mjs +70 -7
  153. package/src/room/economy.mjs +35 -7
  154. package/src/room/escalation.mjs +3 -0
  155. package/src/room/guard.mjs +26 -2
  156. package/src/room/prompt.mjs +22 -15
  157. package/src/room/runs.mjs +84 -21
  158. package/src/room/vectors.mjs +19 -4
  159. package/src/room.mjs +411 -84
  160. package/src/router.mjs +3 -1
  161. package/src/runs.mjs +23 -5
  162. package/src/salas.mjs +313 -0
  163. package/src/sentinel-errors.mjs +1 -1
  164. package/src/server-sentinel.mjs +124 -0
  165. package/src/server.mjs +1864 -178
  166. package/src/setup.mjs +1 -0
  167. package/src/tour.mjs +174 -0
  168. package/src/updates.mjs +48 -8
  169. package/src/usage-sentinel.mjs +1 -1
  170. package/src/verdict.mjs +7 -7
  171. package/src/whats-new.mjs +160 -0
  172. package/docs/madre-banner.svg +0 -25
package/src/memory.mjs CHANGED
@@ -10,7 +10,7 @@
10
10
  // match meaning and not only words. Filled in the background.
11
11
  // GHOST events never reach the ledger, so they never reach here either.
12
12
 
13
- import { mkdir, rm } from 'node:fs/promises';
13
+ import { mkdir } from 'node:fs/promises';
14
14
  import { dirname } from 'node:path';
15
15
  import { messageEntry } from './conversation-context.mjs';
16
16
  import { cosine, toBlob, fromBlob } from './embeddings.mjs';
@@ -196,6 +196,11 @@ export class RoomMemory {
196
196
 
197
197
  #open() {
198
198
  this.#db = new this.#Database(this.#file);
199
+ // Another process writes this file too — the memory server an agent's turn starts, `madre
200
+ // dataset` — and SQLite answered «database is locked» at once, so a note, an index line or a
201
+ // verdict was lost (LOG-08 of the 1.0 review). It waits its turn a few seconds instead. A
202
+ // pragma, because the `timeout` option of node:sqlite is newer than the Node 22.5 MADRE needs.
203
+ this.#db.exec('PRAGMA busy_timeout = 5000');
199
204
  if (this.#file !== ':memory:') this.#db.exec('PRAGMA journal_mode = WAL');
200
205
  this.#createSchema();
201
206
  this.#meta = this.#db.prepare('SELECT value FROM meta WHERE key = ?');
@@ -323,7 +328,9 @@ export class RoomMemory {
323
328
  let entries = 0;
324
329
  for (const row of this.#db.prepare('SELECT text FROM entries').iterate()) if (this.#privacy.hits(row.text)) entries += 1;
325
330
  let memories = 0;
326
- for (const row of this.#db.prepare('SELECT text FROM memories').iterate()) if (this.#privacy.hits(row.text)) memories += 1;
331
+ for (const row of this.#db.prepare('SELECT text, correction FROM memories').iterate()) {
332
+ if (this.#privacy.hits(row.text) || (row.correction && this.#privacy.hits(row.correction))) memories += 1;
333
+ }
327
334
  return { entries, memories };
328
335
  }
329
336
 
@@ -334,13 +341,17 @@ export class RoomMemory {
334
341
  if (!this.#privacy?.enabled) return { entries: 0, memories: 0 };
335
342
  const counts = { entries: 0, memories: 0 };
336
343
  const entryRows = this.#db.prepare('SELECT sequence, event_id, timestamp, type, role, sender, target, message_id, text, distilled FROM entries').all();
337
- const memoryRows = this.#db.prepare('SELECT id, created, kind, text, norm, from_sequence, through_sequence, sources, agent, origin, message_id FROM memories').all();
344
+ // Preserve every column when replacing a row. In particular, a refuted note must keep its
345
+ // standing and its refutation; a recall must keep its history. The schema is the source of
346
+ // this list so a later additive column cannot silently be reset to its default here.
347
+ const memoryColumns = this.#db.prepare('PRAGMA table_info(memories)').all().map((column) => column.name);
348
+ const memoryRows = this.#db.prepare('SELECT * FROM memories').all();
338
349
  const deleteEntry = this.#db.prepare('DELETE FROM entries WHERE sequence = ?');
339
350
  const deleteEntryVector = this.#db.prepare('DELETE FROM entry_vectors WHERE sequence = ?');
340
351
  const insertEntry = this.#db.prepare('INSERT INTO entries (sequence, event_id, timestamp, type, role, sender, target, message_id, text, distilled) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');
341
352
  const deleteMemory = this.#db.prepare('DELETE FROM memories WHERE id = ?');
342
353
  const deleteMemoryVector = this.#db.prepare('DELETE FROM memory_vectors WHERE id = ?');
343
- const insertMemory = this.#db.prepare('INSERT INTO memories (id, created, kind, text, norm, from_sequence, through_sequence, sources, agent, origin, message_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');
354
+ const insertMemory = this.#db.prepare(`INSERT INTO memories (${memoryColumns.map((name) => `"${name.replaceAll('"', '""')}"`).join(', ')}) VALUES (${memoryColumns.map(() => '?').join(', ')})`);
344
355
  this.#db.exec('BEGIN');
345
356
  try {
346
357
  for (const row of entryRows) {
@@ -353,12 +364,14 @@ export class RoomMemory {
353
364
  }
354
365
  for (const row of memoryRows) {
355
366
  const { text, hits } = this.#privacy.redact(row.text);
356
- if (!hits) continue;
367
+ const correction = row.correction ? this.#privacy.redact(row.correction) : { text: null, hits: 0 };
368
+ if (!hits && !correction.hits) continue;
357
369
  deleteMemoryVector.run(row.id);
358
370
  deleteMemory.run(row.id);
359
371
  // Two notes may collapse into the same redacted sentence: the later one is dropped.
360
- const clash = this.#db.prepare('SELECT id FROM memories WHERE norm = ?').get(normalizeMemory(text));
361
- if (!clash) insertMemory.run(row.id, row.created, row.kind, text, normalizeMemory(text), row.from_sequence, row.through_sequence, row.sources, row.agent, row.origin, row.message_id);
372
+ const norm = memoryKey(row.kind, text);
373
+ const clash = this.#db.prepare('SELECT id FROM memories WHERE norm = ?').get(norm);
374
+ if (!clash) insertMemory.run(...memoryColumns.map((name) => name === 'text' ? text : name === 'norm' ? norm : name === 'correction' ? correction.text : row[name]));
362
375
  counts.memories += 1;
363
376
  }
364
377
  this.#db.exec('COMMIT');
@@ -610,9 +623,13 @@ export class RoomMemory {
610
623
  if (!row) return null;
611
624
  this.#db.exec('BEGIN');
612
625
  try {
613
- this.#db.prepare(`UPDATE memories SET refuted_by = NULL, zone = '${HOLD}' WHERE refuted_by = ?`).run(row.id);
626
+ // What it took out stands again by the rule, the human's verdict included (LOG-10 of the
627
+ // 1.0 review): written straight to HOLD, a note the human had marked bad travelled again.
628
+ const freed = this.#db.prepare('SELECT id FROM memories WHERE refuted_by = ?').all(row.id).map((one) => one.id);
629
+ this.#db.prepare('UPDATE memories SET refuted_by = NULL WHERE refuted_by = ?').run(row.id);
614
630
  this.#db.prepare('DELETE FROM memories WHERE id = ?').run(row.id);
615
631
  this.#db.prepare('DELETE FROM memory_vectors WHERE id = ?').run(row.id);
632
+ this.#restand(freed);
616
633
  this.#db.exec('COMMIT');
617
634
  return { id: row.id, text: row.text, restored: row.contradicts ?? null };
618
635
  } catch (error) {
@@ -666,18 +683,27 @@ export class RoomMemory {
666
683
  this.#db.prepare('DELETE FROM recalls WHERE memory_id = ?').run(id);
667
684
  // A note quarantined by this one comes back: with the aberration gone there is nothing
668
685
  // holding it out of circulation, and a memory must never be lost to a pointer at nothing.
669
- this.#db.prepare(`UPDATE memories SET refuted_by = NULL, zone = '${HOLD}' WHERE refuted_by = ?`).run(id);
686
+ // Where it stands is decided by the rule, the human's verdict included (LOG-10).
687
+ const freed = this.#db.prepare('SELECT id FROM memories WHERE refuted_by = ?').all(id).map((one) => one.id);
688
+ this.#db.prepare('UPDATE memories SET refuted_by = NULL WHERE refuted_by = ?').run(id);
670
689
  this.#db.prepare('DELETE FROM memories WHERE id = ?').run(id);
690
+ this.#restand(freed);
671
691
  this.#db.exec('COMMIT');
672
692
  } catch (error) { this.#db.exec('ROLLBACK'); throw error; }
673
693
  return row;
674
694
  }
675
695
 
676
696
  // Which notes are about the same thing: pairs whose vectors agree, strongest
677
- // first, a few per note. Empty without an embedder or vectors.
697
+ // first, a few per note. Comparing notes with each other needs only the vectors
698
+ // already stored, not a live embedder: when the one in use has none (Ollama off,
699
+ // the room fell back to another), the model that embedded the most notes draws
700
+ // the links. Empty only when no note was ever embedded.
678
701
  memoryLinks({ floor = 0.6, maxPerNode = 4 } = {}) {
679
- if (!this.#embedder) return [];
680
- const rows = this.#db.prepare('SELECT id, vec FROM memory_vectors WHERE model = ?').all(this.#embedder.model).map((row) => ({ id: row.id, vec: fromBlob(row.vec) }));
702
+ const stored = this.#db.prepare('SELECT model, COUNT(*) AS n FROM memory_vectors GROUP BY model ORDER BY n DESC').all();
703
+ const current = this.#embedder ? stored.find((row) => row.model === this.#embedder.model) : null;
704
+ const model = current?.model ?? stored[0]?.model;
705
+ if (!model) return [];
706
+ const rows = this.#db.prepare('SELECT id, vec FROM memory_vectors WHERE model = ?').all(model).map((row) => ({ id: row.id, vec: fromBlob(row.vec) }));
681
707
  const links = [];
682
708
  for (let i = 0; i < rows.length; i += 1) {
683
709
  for (let j = i + 1; j < rows.length; j += 1) {
package/src/models.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { homedir } from 'node:os';
3
3
  import { join } from 'node:path';
4
+ import { OPENCODE_PROVIDERS } from './credentials.mjs';
4
5
 
5
6
  // Which model a CLI should use for one turn. Every CLI accepts --model; the
6
7
  // names come from what is installed locally (Codex's model cache, OpenCode's
@@ -32,15 +33,45 @@ export const KNOWN_MODELS = {
32
33
  // for each provider), so they get no chooser at all rather than one that guesses.
33
34
  //
34
35
  // Ordered lightest first: the composer draws them as a ladder, and the order is the meaning.
36
+ //
37
+ // Codex's levels belong to each model, not to the CLI. Its own cache (~/.codex/models_cache.json)
38
+ // lists the ones each model takes, and the API refuses any other with a 400 — «'minimal' is not
39
+ // supported with the 'gpt-5.5' model» — that used to reach the room as a lone «{». The list here is
40
+ // every name Codex has used, in order; a turn gets only the ones its model takes.
35
41
  export const EFFORTS = {
36
42
  claude: ['low', 'medium', 'high', 'xhigh', 'max'],
37
- codex: ['minimal', 'low', 'medium', 'high', 'xhigh'],
43
+ codex: ['minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'],
38
44
  };
45
+ // What a Codex with no cache to read is offered: the levels its models have always taken.
46
+ const CODEX_SAFE = ['low', 'medium', 'high'];
39
47
 
40
48
  export function isValidEffort(agentId, effort) {
41
49
  return typeof effort === 'string' && (EFFORTS[agentId] ?? []).includes(effort);
42
50
  }
43
51
 
52
+ // Whose model a name is, when the name says so. Codex, Claude and Gemini each answer only to their
53
+ // own vendor's models, and a name from another one reaches the CLI as an API error that reads
54
+ // like anything but "wrong model" (Gemini: `models/gpt-5.6-sol is not found for API version
55
+ // v1beta`). OpenCode is left out on purpose: it speaks to many providers, so any name may be its.
56
+ const VENDOR = [
57
+ ['codex', /^(gpt-|o\d|codex-|chatgpt-)/i],
58
+ ['claude', /^(claude|fable|opus|sonnet|haiku)\b/i],
59
+ ['gemini', /^(gemini|gemma|auto$)/i],
60
+ ];
61
+ export function modelOwner(model) {
62
+ return VENDOR.find(([, pattern]) => pattern.test(String(model ?? '')))?.[0] ?? null;
63
+ }
64
+ // True when the name clearly belongs to another of the three: the turn is better off on the
65
+ // agent's own default than sent to fail.
66
+ export function belongsElsewhere(agentId, model) {
67
+ if (!VENDOR.some(([id]) => id === agentId)) return false;
68
+ const owner = modelOwner(model);
69
+ return Boolean(owner) && owner !== agentId;
70
+ }
71
+
72
+ // A CLI saying it does not know the model it was given, in any of the ways the four say it.
73
+ export const MODEL_NOT_FOUND = /models?\/\S+ is not found|not supported for generateContent|model[_ ]not[_ ]found|unknown model|invalid model|model (?:\S+ )?(?:does not exist|is not available|not found)|issue with the selected model/i;
74
+
44
75
  export function parseCodexDefaultEffort(toml) {
45
76
  return String(toml ?? '').match(/^\s*model_reasoning_effort\s*=\s*"([^"]+)"/m)?.[1] ?? null;
46
77
  }
@@ -61,6 +92,49 @@ export function parseCodexModelCache(json) {
61
92
  }
62
93
  }
63
94
 
95
+ // Per model, the efforts it takes (lightest first) and the one it uses when told nothing.
96
+ export function parseCodexEfforts(json) {
97
+ try {
98
+ const data = JSON.parse(json);
99
+ const byModel = {};
100
+ const walk = (node) => {
101
+ if (!node || typeof node !== 'object') return;
102
+ if (typeof node.slug === 'string' && Array.isArray(node.supported_reasoning_levels)) {
103
+ const levels = EFFORTS.codex.filter((level) => node.supported_reasoning_levels.some((one) => one?.effort === level));
104
+ if (levels.length) byModel[node.slug] = { levels, default: levels.includes(node.default_reasoning_level) ? node.default_reasoning_level : null };
105
+ }
106
+ for (const value of Object.values(node)) walk(value);
107
+ };
108
+ walk(data);
109
+ return byModel;
110
+ } catch {
111
+ return {};
112
+ }
113
+ }
114
+
115
+ // The levels one Codex model takes: its own, from the cache; for a name the cache does not list,
116
+ // the ones every listed model shares, so a model MADRE cannot read never meets a refusal.
117
+ export function codexLevels(byModel, model) {
118
+ if (model && byModel[model]) return byModel[model].levels;
119
+ const lists = Object.values(byModel).map((one) => one.levels);
120
+ return lists.length ? lists.reduce((shared, levels) => shared.filter((level) => levels.includes(level))) : CODEX_SAFE;
121
+ }
122
+
123
+ export async function readCodexEfforts({ home = homedir() } = {}) {
124
+ const [cache, toml] = await Promise.all([
125
+ readFile(join(home, '.codex', 'models_cache.json'), 'utf8').catch(() => ''),
126
+ readFile(join(home, '.codex', 'config.toml'), 'utf8').catch(() => ''),
127
+ ]);
128
+ return { byModel: parseCodexEfforts(cache), model: parseCodexDefaultModel(toml), effort: parseCodexDefaultEffort(toml) };
129
+ }
130
+
131
+ // Whether a turn's effort reaches Codex: the model it runs on — the one chosen, or the CLI's
132
+ // default — has to take it.
133
+ export async function codexTakes(model, effort, { home = homedir() } = {}) {
134
+ const read = await readCodexEfforts({ home });
135
+ return codexLevels(read.byModel, model || read.model).includes(effort);
136
+ }
137
+
64
138
  export function parseCodexDefaultModel(toml) {
65
139
  return String(toml ?? '').match(/^\s*model\s*=\s*"([^"]+)"/m)?.[1] ?? null;
66
140
  }
@@ -78,25 +152,73 @@ export async function discoverModels({ agents = [], config = {}, home = homedir(
78
152
  let discovered = [];
79
153
  let cliDefault = null;
80
154
  let effortDefault = null;
155
+ let efforts = EFFORTS[agent.id] ?? [];
156
+ let effortsByModel;
157
+ let effortsShared;
81
158
  if (agent.id === 'codex') {
82
159
  discovered = parseCodexModelCache(await readFile(join(home, '.codex', 'models_cache.json'), 'utf8').catch(() => ''));
83
- const toml = await readFile(join(home, '.codex', 'config.toml'), 'utf8').catch(() => '');
84
- cliDefault = parseCodexDefaultModel(toml);
85
- effortDefault = parseCodexDefaultEffort(toml);
160
+ const read = await readCodexEfforts({ home });
161
+ cliDefault = read.model;
162
+ effortDefault = read.effort;
163
+ // The ladder is the model's: the CLI default's here, each model's in the composer once it is
164
+ // chosen. What the CLI does when told nothing is the config's level if that model takes it,
165
+ // or the model's own.
166
+ efforts = codexLevels(read.byModel, cliDefault);
167
+ effortsShared = codexLevels(read.byModel, null);
168
+ effortsByModel = Object.fromEntries(Object.entries(read.byModel).map(([slug, one]) => [slug, { levels: one.levels, default: one.levels.includes(read.effort) ? read.effort : one.default }]));
169
+ if (!efforts.includes(effortDefault)) effortDefault = (cliDefault && read.byModel[cliDefault]?.default) || null;
86
170
  }
87
171
  if (agent.id === 'opencode' && agent.detected) {
88
172
  discovered = await listOpenCode(agent).catch(() => []);
89
173
  cliDefault = process.env.PULSE_OPENCODE_MODEL ?? null;
90
174
  }
91
175
  const custom = Array.isArray(config.models?.[agent.id]) ? config.models[agent.id] : [];
176
+ // OpenCode speaks to many providers: what `opencode models` lists is already only the ones
177
+ // signed in plus OpenCode's own. The person may hide a provider from the chooser (AJUSTES ›
178
+ // Project › OpenCode); its models are then left out, never the room default.
179
+ let groups;
180
+ if (agent.id === 'opencode') {
181
+ const hidden = new Set(openCodeHidden(config));
182
+ discovered = discovered.filter((name) => !hidden.has(providerOf(name)));
183
+ groups = groupOpenCode(unique([...custom, ...discovered]));
184
+ }
92
185
  result[agent.id] = {
186
+ ...(groups ? { groups } : {}),
93
187
  models: unique([...custom, ...discovered, ...known.defaults]),
94
188
  default: cliDefault,
95
189
  note: known.note,
96
- efforts: EFFORTS[agent.id] ?? [],
190
+ efforts,
97
191
  effortDefault: isValidEffort(agent.id, effortDefault) ? effortDefault : null,
192
+ ...(effortsByModel ? { effortsByModel, effortsShared } : {}),
98
193
  source: discovered.length ? 'discovered' : 'known',
99
194
  };
100
195
  }
101
196
  return result;
102
197
  }
198
+
199
+ // ---- OpenCode's models by provider: `provider/model`, named as the person knows the provider.
200
+ export const OPENCODE_OWN = 'opencode';
201
+ export const providerOf = (name) => String(name ?? '').split('/')[0];
202
+ export function openCodeHidden(config = {}) {
203
+ const hidden = config.opencode?.hidden;
204
+ return Array.isArray(hidden) ? hidden.filter((id) => typeof id === 'string' && /^[\w.-]{1,60}$/.test(id)) : [];
205
+ }
206
+ export function providerLabel(id) {
207
+ if (id === OPENCODE_OWN) return 'OpenCode';
208
+ const known = OPENCODE_PROVIDERS.find((one) => one.id === id);
209
+ if (known) return known.label;
210
+ return id.split(/[-_.]/).map((part) => (part ? part[0].toUpperCase() + part.slice(1) : part)).join(' ');
211
+ }
212
+ // Groups in a steady order: the providers with a key first, by name; OpenCode's own models last.
213
+ export function groupOpenCode(names = []) {
214
+ const byProvider = new Map();
215
+ for (const name of names) {
216
+ if (!/^[\w.-]+\/[\w.:/-]+$/.test(name)) continue;
217
+ const id = providerOf(name);
218
+ if (!byProvider.has(id)) byProvider.set(id, []);
219
+ byProvider.get(id).push(name);
220
+ }
221
+ return [...byProvider.entries()]
222
+ .sort(([a], [b]) => Number(a === OPENCODE_OWN) - Number(b === OPENCODE_OWN) || providerLabel(a).localeCompare(providerLabel(b)))
223
+ .map(([id, models]) => ({ id, label: providerLabel(id), own: id === OPENCODE_OWN, models }));
224
+ }
@@ -30,12 +30,12 @@ async function detect(projectRoot) {
30
30
  async function preflight(projectRoot, { toplevel = gitToplevel, npx = findOnPath } = {}) {
31
31
  const root = await realpath(projectRoot).catch(() => resolve(projectRoot));
32
32
  const problems = [];
33
- if (!(await npx('npx'))) problems.push('npx is not on PATH for the MADRE server; start MADRE from a terminal where `npx --version` works.');
33
+ if (!(await npx('npx'))) problems.push(t('npx is not on PATH for the MADRE server; start MADRE from a terminal where `npx --version` works.'));
34
34
  const top = await toplevel(root);
35
35
  if (!top) {
36
- problems.push('The project is not a git repository. AHP+ pins itself to the git root: run `git init` in the project first.');
36
+ problems.push(t('The project is not a git repository. AHP+ pins itself to the git root: run `git init` in the project first.'));
37
37
  } else if (top !== root) {
38
- problems.push(`The project's git root is ${top}, not the project itself. AHP+ would install there. Run \`git init\` inside the project, or remove the stray repository at ${top}.`);
38
+ problems.push(t('The project\'s git root is {top}, not the project itself. AHP+ would install there. Run `git init` inside the project, or remove the stray repository at {top}.', { top }));
39
39
  }
40
40
  return { ok: problems.length === 0, problems, gitRoot: top };
41
41
  }
@@ -50,7 +50,7 @@ function installCommand({ agents = [] }) {
50
50
  export default defineModule({
51
51
  id: 'ahp',
52
52
  kind: 'installer',
53
- name: 'AHP+',
53
+ name: 'Continuity',
54
54
  vendor: 'Agent Handoff Protocol Plus',
55
55
  package: PACKAGE,
56
56
  tracks: { name: PACKAGE, npm: PACKAGE },
@@ -20,7 +20,7 @@ import { t } from '../i18n.mjs';
20
20
  export default defineModule({
21
21
  id: 'ash',
22
22
  configKey: 'ash',
23
- name: 'Ash',
23
+ name: 'Token saver',
24
24
  vendor: 'MADRE',
25
25
  version: '1.0.0',
26
26
  summary: 'Asks every agent for compact prose without altering what you write. MADRE shows each turn\'s input, output and cache use; savings depend on the agent, task and cache, so no fixed percentage is promised.',
@@ -0,0 +1,207 @@
1
+ // BISHOP's plans, without the room around them (docs/MACROVERSO.md, fases 4b y 4c): the block a
2
+ // reply ends with, what each of its lines means on this Mac, and the record the buttons act on.
3
+ // Nothing here sends, signs or starts anything; src/modules/bishop.mjs does, after a human press
4
+ // or within the mode the human chose.
5
+ import { randomBytes } from 'node:crypto';
6
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
7
+ import { join } from 'node:path';
8
+ import { LINK_KINDS, MAX_ORDER_MODE, receivesOrders } from '../enlaces.mjs';
9
+ import { salasRoomId } from '../salas.mjs';
10
+
11
+ export const MAX_ORDERS = 4;
12
+ export const MAX_LINKS = 2;
13
+ export const RESULT_CHARS = 1200;
14
+ export const ORDER_CHARS = 2000;
15
+ export const PLAN_FENCE = 'pulse-salas';
16
+ export const BISHOP_MODES = ['manual', 'hybrid', 'auto'];
17
+ const KEEP_PLANS = 40;
18
+ export const clean = (value, max = 60) => String(value ?? '').replace(/[\r\n\t<>]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, max);
19
+
20
+ /* ---------- the headers a relayed message carries ---------- */
21
+
22
+ // What a turn reads first when BISHOP's channel brings it a message. English, like everything a
23
+ // turn reads, and with no word of @madre's list of actions (src/adapters/madre.mjs, ACTION_EN):
24
+ // «order» or «delegate» made @madre answer every order with its fixed refusal (S-031).
25
+ // Who sent it is said as it happened: pressed by the human, or on its own within the mode the human
26
+ // chose. Never «sent by the human» for an order nobody pressed.
27
+ export const orderHeader = ({ step, total, plan, auto = false }) => `BISHOP › ${step}/${total} · plan ${plan} · from Salas, ${auto ? 'sent on its own within the mode the human chose' : 'sent by the human'}. Answer in this room only; this turn cannot pass anything on to other rooms.`;
28
+ export const consultHeader = ({ room }) => `BISHOP › question from room ${clean(room, 60)}, asked by the human there. Answer from what you know of the linked rooms; this turn ends with the answer.`;
29
+ export const ORDER_HEADER = /^BISHOP › (\d+)\/(\d+) · plan (p-[0-9a-f]{6})\b/;
30
+ export const CONSULT_HEADER = /^BISHOP › question from room (.+?), asked by the human there\./;
31
+
32
+ /* ---------- the block ---------- */
33
+
34
+ // One order: `@<room>/<agent> #<0-4>: what to do`. The room is its name (it may have spaces) or
35
+ // its id; the agent is a crew id. The mode is a single digit and #3/#4 parse, to be refused later.
36
+ const ORDER_LINE = /^\s*[-*]?\s*@(.+?)\/([a-z][a-z0-9_-]*)\s*#(\d)\s*[::]\s*(.+)$/i;
37
+ // One link: `enlace <room> → <room>: decision, fact; 30 días; órdenes #1`. The arrow says which way
38
+ // notes travel (the first room hands over, the second reads); ↔ is both ways.
39
+ const LINK_LINE = /^\s*[-*]?\s*(?:enlace|link)\s*:?\s+@?(.+?)\s*(↔|<->|→|->)\s*@?(.+?)\s*[::]\s*(.+)$/i;
40
+ const KIND_WORDS = new Map([
41
+ ['decision', 'decision'], ['decisions', 'decision'], ['decisión', 'decision'], ['decision', 'decision'], ['decisiones', 'decision'],
42
+ ['fact', 'fact'], ['facts', 'fact'], ['hecho', 'fact'], ['hechos', 'fact'],
43
+ ['preference', 'preference'], ['preferences', 'preference'], ['preferencia', 'preference'], ['preferencias', 'preference'],
44
+ ['question', 'question'], ['questions', 'question'], ['pregunta', 'question'], ['preguntas', 'question'],
45
+ ]);
46
+ const isLine = (line) => ORDER_LINE.test(line) || LINK_LINE.test(line);
47
+
48
+ function linkTerms(rest) {
49
+ const [first, ...others] = String(rest).split(/[;·|]/).map((part) => part.trim()).filter(Boolean);
50
+ const words = String(first ?? '').toLowerCase().split(/\s*,\s*|\s+y\s+|\s+and\s+/).map((word) => word.trim()).filter(Boolean);
51
+ const kinds = [...new Set(words.map((word) => KIND_WORDS.get(word)).filter(Boolean))];
52
+ const unknown = words.filter((word) => !KIND_WORDS.has(word));
53
+ let days = null;
54
+ let orders = null;
55
+ for (const part of others) {
56
+ const span = part.match(/(\d{1,3})\s*(?:d|días|dias|day|days)\b/i);
57
+ if (span) days = Number(span[1]);
58
+ const ceiling = part.match(/(?:órdenes|ordenes|orders?)\s*(?:hasta|up to)?\s*#?\s*([0-4])/i);
59
+ if (ceiling) orders = Number(ceiling[1]);
60
+ }
61
+ return { kinds, unknown, days, orders };
62
+ }
63
+
64
+ // The last ```pulse-salas block of a reply, read the way the room reads ```pulse: the reason on the
65
+ // first line, then up to 4 orders and up to 2 links, and nothing after the block. `null` when the
66
+ // reply has no such block; otherwise `{ reason, orders, links, ignored }`, where `ignored` says why
67
+ // each line (or the whole block) was not taken.
68
+ export function parsePlan(text) {
69
+ const lines = String(text ?? '').replace(/\r\n/g, '\n').split('\n');
70
+ // Fences as the page reads them (renderMarkdown): a fence opens with an optional language and
71
+ // only a bare ``` closes it.
72
+ const blocks = [];
73
+ let open = null;
74
+ for (let index = 0; index < lines.length; index += 1) {
75
+ if (open === null) {
76
+ const fence = lines[index].match(/^\s*```\s*([\w+-]*)\s*$/);
77
+ if (fence) open = { start: index, lang: fence[1].toLowerCase() };
78
+ continue;
79
+ }
80
+ if (/^\s*```\s*$/.test(lines[index])) { blocks.push({ ...open, end: index }); open = null; }
81
+ }
82
+ const none = (why) => ({ reason: '', orders: [], links: [], ignored: [{ line: '', why }] });
83
+ if (open?.lang === PLAN_FENCE) return none('the block is not closed');
84
+ const found = blocks.filter((block) => block.lang === PLAN_FENCE).at(-1);
85
+ if (!found) return null;
86
+ if (lines.slice(found.end + 1).join('\n').trim()) return none('text after the block');
87
+ const body = lines.slice(found.start + 1, found.end).map((line) => line.trim()).filter(Boolean);
88
+ const reason = body[0] && !isLine(body[0]) ? clean(body.shift(), 300) : '';
89
+ if (!reason) return none('the first line says why');
90
+ const orders = [];
91
+ const links = [];
92
+ const ignored = [];
93
+ for (const line of body) {
94
+ const link = line.match(LINK_LINE);
95
+ if (link) {
96
+ if (links.length >= MAX_LINKS) { ignored.push({ line: clean(line, 120), why: 'more than 2 connections' }); continue; }
97
+ const terms = linkTerms(link[4]);
98
+ links.push({ from: clean(link[1], 80), to: clean(link[3], 80), both: link[2] === '↔' || link[2] === '<->', ...terms });
99
+ continue;
100
+ }
101
+ const match = line.match(ORDER_LINE);
102
+ if (!match) { ignored.push({ line: clean(line, 120), why: 'not an order line' }); continue; }
103
+ const mode = Number(match[3]);
104
+ if (mode > 4) { ignored.push({ line: clean(line, 120), why: 'no such mode' }); continue; }
105
+ if (orders.length >= MAX_ORDERS) { ignored.push({ line: clean(line, 120), why: 'more than 4 orders' }); continue; }
106
+ orders.push({ room: clean(match[1], 80), agent: match[2].toLowerCase(), mode, text: String(match[4]).replace(/[\u0000-\u001f\u007f]/g, ' ').trim().slice(0, ORDER_CHARS) });
107
+ }
108
+ return { reason, orders, links, ignored };
109
+ }
110
+
111
+ /* ---------- what each line means on this Mac ---------- */
112
+
113
+ // A room by its id, or by a name only one room on this Mac has.
114
+ function findRoom(rooms, wanted) {
115
+ const byId = rooms.filter((room) => room.id === wanted);
116
+ if (byId.length) return byId;
117
+ const lower = String(wanted).toLowerCase();
118
+ return rooms.filter((room) => String(room.name).toLowerCase() === lower);
119
+ }
120
+
121
+ // One parsed order, checked: the room, an agent of this crew, a contract in force that lets BISHOP
122
+ // give orders to it and up to which mode. An order that fails a check stays on the card with its
123
+ // reason and no button; #3 and #4 become a proposal for that room.
124
+ export function resolveOrder(order, step, { rooms, contracts, salas, agents }) {
125
+ const crew = new Set((agents ?? []).filter((agent) => agent.detected).map((agent) => agent.id));
126
+ const named = findRoom(rooms, order.room);
127
+ const base = { step, room: named[0]?.id ?? null, name: named[0]?.name ?? order.room, agent: order.agent, mode: order.mode, text: order.text, contract: null, maxMode: null, asked: order.room };
128
+ if (!named.length) return { ...base, status: 'blocked', why: 'MADRE does not know that project on this Mac.' };
129
+ if (named.length > 1) return { ...base, room: null, status: 'blocked', why: 'Two projects on this Mac have that name; use the project id.' };
130
+ if (named[0].id === salas) return { ...base, status: 'blocked', why: 'The Coordinator lives in this project; it gives orders to the others.' };
131
+ if (!crew.has(order.agent)) return { ...base, status: 'blocked', why: 'No such agent on this Mac.' };
132
+ const contract = contracts.find((one) => receivesOrders(one, salas) === named[0].id) ?? null;
133
+ if (!contract) return { ...base, status: 'blocked', why: 'No signed connection lets the Coordinator give orders to that project.' };
134
+ const maxMode = Math.min(contract.orders.maxMode, MAX_ORDER_MODE);
135
+ if (order.mode >= 3) return { ...base, contract: contract.id, maxMode, status: 'proposal', why: '#3 and #4 never travel: unlock it yourself in that project.' };
136
+ if (order.mode > maxMode) return { ...base, contract: contract.id, maxMode, status: 'blocked', why: 'That connection allows orders up to #{n}.', whyArgs: { n: maxMode } };
137
+ return { ...base, contract: contract.id, maxMode, status: 'ready', why: null };
138
+ }
139
+ export const resolveOrders = ({ orders, ...where }) => orders.map((order, index) => resolveOrder(order, index + 1, where));
140
+
141
+ // One proposed link, checked the way the sheet of Salas and `normalizeContract` would: two rooms
142
+ // this Mac knows, the room of Salas never handing over, kinds that may cross, orders only with
143
+ // Salas at one end. A pair already connected, either way round, says so. Signing it is the human's press.
144
+ export function resolveLinks({ links, rooms, contracts, salas }) {
145
+ return links.map((link, index) => {
146
+ const from = findRoom(rooms, link.from);
147
+ const to = findRoom(rooms, link.to);
148
+ const base = { index, from: from[0]?.id ?? null, fromName: from[0]?.name ?? link.from, to: to[0]?.id ?? null, toName: to[0]?.name ?? link.to, direction: link.both ? 'both' : 'a→b', kinds: link.kinds, days: link.days, orders: null, receives: null };
149
+ if (from.length !== 1 || to.length !== 1) return { ...base, status: 'blocked', why: from.length > 1 || to.length > 1 ? 'Two projects on this Mac have that name; use the project id.' : 'MADRE does not know that project on this Mac.' };
150
+ if (base.from === base.to) return { ...base, status: 'blocked', why: 'A project cannot be connected to itself.' };
151
+ if (base.from === salas || (link.both && base.to === salas)) return { ...base, status: 'blocked', why: 'The Coordinator\'s project never hands its notes over.' };
152
+ if (!link.kinds.length || link.unknown.length) return { ...base, status: 'blocked', why: 'Name what crosses: decision, fact, preference or question.' };
153
+ if (!link.kinds.every((kind) => LINK_KINDS.includes(kind))) return { ...base, status: 'blocked', why: 'Name what crosses: decision, fact, preference or question.' };
154
+ let orders = null;
155
+ if (link.orders !== null) {
156
+ if (base.from !== salas && base.to !== salas) return { ...base, status: 'blocked', why: 'Orders go only on a connection with the Coordinator\'s project.' };
157
+ orders = { allowed: true, maxMode: Math.min(link.orders, MAX_ORDER_MODE), perHour: 6 };
158
+ }
159
+ const receives = orders ? (base.from === salas ? base.to : base.from) : null;
160
+ // Two projects are connected once (2026-10-11): any connection in force between them, whichever
161
+ // way it goes, is the one there is; signing another is refused (src/server.mjs).
162
+ const same = contracts.find((one) => [one.rooms.a, one.rooms.b].sort().join() === [base.from, base.to].sort().join());
163
+ if (same) return { ...base, orders, receives, status: 'exists', why: 'That connection is already in force.', contract: same.id };
164
+ return { ...base, orders, receives, status: 'ready', why: null };
165
+ });
166
+ }
167
+
168
+ /* ---------- the plans, kept beside the room of Salas ---------- */
169
+
170
+ // What the buttons act on is read from here, never from the page: the page names a plan and a step.
171
+ const plansFile = (stateRoot) => join(stateRoot, 'rooms', salasRoomId(stateRoot), 'bishop-plans.json');
172
+ const writes = new Map();
173
+ async function readPlans(stateRoot) {
174
+ try { const data = JSON.parse(await readFile(plansFile(stateRoot), 'utf8')); return data && typeof data.plans === 'object' ? data : { plans: {} }; } catch { return { plans: {} }; }
175
+ }
176
+ // One change at a time per file, written whole and renamed into place.
177
+ export function changePlans(stateRoot, change) {
178
+ const file = plansFile(stateRoot);
179
+ const run = (writes.get(file) ?? Promise.resolve()).then(async () => {
180
+ const data = await readPlans(stateRoot);
181
+ const result = await change(data);
182
+ const kept = Object.values(data.plans).sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt))).slice(0, KEEP_PLANS);
183
+ data.plans = Object.fromEntries(kept.map((plan) => [plan.id, plan]));
184
+ await mkdir(join(file, '..'), { recursive: true });
185
+ const temp = `${file}.${process.pid}.${randomBytes(3).toString('hex')}.tmp`;
186
+ await writeFile(temp, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 });
187
+ await rename(temp, file);
188
+ return result;
189
+ });
190
+ writes.set(file, run.catch(() => null));
191
+ return run;
192
+ }
193
+ export const planById = async (stateRoot, id) => (await readPlans(stateRoot)).plans[id] ?? null;
194
+ export const newPlanId = () => `p-${randomBytes(3).toString('hex')}`;
195
+
196
+ // A plan closes once, when no order is still waiting for a press or an answer and at least one
197
+ // was answered, failed or skipped. The close is a line; it never opens another plan.
198
+ const OPEN_STATES = new Set(['ready', 'sending', 'sent']);
199
+ export function closing(plan) {
200
+ if (plan.closed || plan.orders.some((order) => OPEN_STATES.has(order.status))) return null;
201
+ if (!plan.orders.some((order) => ['done', 'failed', 'skipped'].includes(order.status))) return null;
202
+ plan.closed = true;
203
+ const count = (...statuses) => plan.orders.filter((order) => statuses.includes(order.status)).length;
204
+ return { plan: plan.id, done: count('done'), failed: count('failed'), skipped: count('skipped'), blocked: count('blocked', 'proposal', 'delivered') };
205
+ }
206
+ export const publicOrder = ({ step, room, name, agent, mode, text, contract, maxMode, status, why, whyArgs }) => ({ step, room, name, agent, mode, text, contract, maxMode, status, why, ...(whyArgs ? { whyArgs } : {}) });
207
+ export const publicLink = ({ index, from, fromName, to, toName, direction, kinds, days, orders, receives, status, why, contract }) => ({ index, from, fromName, to, toName, direction, kinds, days, orders, receives, status, why, ...(contract ? { contract } : {}) });