@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
@@ -0,0 +1,451 @@
1
+ // Links between rooms (docs/MACROVERSO.md, phase 3b): the contract two rooms sign, the key that
2
+ // proves a caller is a MADRE process on this Mac, the side that asks, and the counters of what
3
+ // crossed. A link reads; nothing here writes in another room.
4
+ //
5
+ // The contract lives in `~/.pulse/config.json` under `enlaces`, keyed by id, written with
6
+ // `updateConfig` so every room on this Mac reads the same one and two rooms signing at once keep
7
+ // both (S-001, S-007). What may cross is said once, in src/memory.mjs: `travels()` and
8
+ // `RECALLED_ZONES` are cited here, never copied (docs/ZONAS.md). The locks live in the room that
9
+ // serves (src/server.mjs, the ENLACES section), never in the room that asks.
10
+ //
11
+ // The key: one secret in `~/.pulse/credentials/enlaces.json`, the same shape the vault keeps for a
12
+ // module (`{ name: value }`, 0600 inside 0700), made by the first room that needs it, carried in a
13
+ // header and never in a URL, a body or a log. Of the key, the ledger may know its name and its
14
+ // length, never its value (docs/SDK.md, the vault). It proves «a MADRE process on this Mac», not
15
+ // which room: that limit is written down, not papered over.
16
+
17
+ import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
18
+ import { appendFile, chmod, link, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
19
+ import { join } from 'node:path';
20
+ import { withConfig } from './config.mjs';
21
+ import { RECALLED_ZONES, STANDING_KINDS } from './memory.mjs';
22
+ import { knownRooms } from './salas.mjs';
23
+
24
+ // The kinds a contract may name: knowledge that stands. An aberration is the one kind that is
25
+ // not knowledge and it never crosses, whatever a contract says.
26
+ export const LINK_KINDS = [...STANDING_KINDS];
27
+ // The zones a link may read from are the zones a turn may read from, and nothing else.
28
+ export const LINK_ZONES = [...RECALLED_ZONES];
29
+ export const DIRECTIONS = ['a→b', 'b→a', 'both'];
30
+ export const MAX_LIMIT = 20;
31
+ export const DEFAULT_PER_HOUR = 6;
32
+ export const MAX_ORDER_MODE = 2;
33
+ export const HOUR_MS = 60 * 60 * 1000;
34
+ // The header the asking room presents, and the one answer every refused contract gets: the side
35
+ // that asks is never told whether the link is missing, revoked, expired or facing the other way.
36
+ export const CHANNEL_HEADER = 'x-madre-enlace';
37
+ export const NO_SUCH_LINK = 'No such link.';
38
+ // `order` is one of BISHOP's orders that reached this room (fase 4b). It is counted apart from the
39
+ // readings, each kind against the contract's own `perHour`: asking BISHOP a question, which reads,
40
+ // never uses up the orders, and an order never uses up a reading.
41
+ export const CROSSINGS = ['read', 'received', 'order'];
42
+ // What one room may tell another about a link, by the channel: that it was signed or revoked.
43
+ // Each lands in the other room's ledger as a line, never as a message (phase 3c).
44
+ export const NOTICES = ['enlace.signed', 'enlace.revoked'];
45
+ export const DAY_MS = 24 * HOUR_MS;
46
+ // The longest link BISHOP may sign on its own (D-S13).
47
+ export const BISHOP_SIGN_DAYS = 7;
48
+ // The most notes a link with automatic recall brings to one turn, across every such link (3e).
49
+ export const RECALL_NOTES = 3;
50
+
51
+ const OWNER_ONLY = 0o600;
52
+ const OWNER_DIR = 0o700;
53
+ const KEY_HEX = /^[0-9a-f]{64}$/;
54
+ const ID = /^[0-9a-f]{12}-[0-9a-f]{6}$/;
55
+ // A room id is a folder name under ~/.pulse/rooms: never a path.
56
+ const ROOM = /^[^/\\\0]{1,200}$/;
57
+
58
+ const fail = (why, code = null) => { throw Object.assign(new Error(why), code ? { code } : {}); };
59
+ const pairHash = (a, b) => createHash('sha256').update([String(a), String(b)].sort().join('\n')).digest('hex').slice(0, 12);
60
+ const asDate = (value) => { const date = new Date(value); return Number.isNaN(date.getTime()) ? null : date; };
61
+
62
+ // Stable for the pair of rooms, plus a short nonce so the same two rooms can sign again after a
63
+ // revocation and the old id never comes back to life.
64
+ export function contractId({ a, b }, { nonce = randomBytes(3).toString('hex') } = {}) {
65
+ return `${pairHash(a, b)}-${nonce}`;
66
+ }
67
+
68
+ function isoOrNull(value, field) {
69
+ if (value === null || value === undefined || value === '') return null;
70
+ const date = typeof value === 'string' || value instanceof Date || typeof value === 'number' ? asDate(value) : null;
71
+ if (!date) fail(`${field} is not a date.`);
72
+ return date.toISOString();
73
+ }
74
+
75
+ // The rooms that hand their notes over under a contract: the arrow's tail, or both ends.
76
+ export const servingRooms = (contract) => (contract.direction === 'both' ? [contract.rooms.a, contract.rooms.b] : contract.direction === 'a→b' ? [contract.rooms.a] : [contract.rooms.b]);
77
+
78
+ // The room BISHOP's orders may reach under a contract: the end that is not the room of Salas.
79
+ // `null` when the contract allows no orders, or when neither end is that room.
80
+ export function receivesOrders(contract, salas) {
81
+ if (!contract?.orders?.allowed || !salas) return null;
82
+ const { a, b } = contract.rooms;
83
+ return a === salas ? b : b === salas ? a : null;
84
+ }
85
+
86
+ // The contract, whole and checked, or the reason it is not one. The shape is §4B of the encargo:
87
+ // two rooms, a direction, the kinds that may cross, the zones (fixed: what travels into a turn),
88
+ // an expiry, a revocation, the ceiling for BISHOP's orders, and the human's signature.
89
+ //
90
+ // With `salas` (the id of the room of Salas, src/salas.mjs) two more rules hold: that room never
91
+ // serves — BISHOP gathers every room and is nobody's source (§4C.7) — and orders are allowed only
92
+ // on a link with that room at one end, which is then the end that gives them.
93
+ export function normalizeContract(raw, { now = new Date(), salas = null } = {}) {
94
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) fail('A link is an object.');
95
+ const rooms = raw.rooms && typeof raw.rooms === 'object' ? raw.rooms : {};
96
+ const a = typeof rooms.a === 'string' ? rooms.a.trim() : '';
97
+ const b = typeof rooms.b === 'string' ? rooms.b.trim() : '';
98
+ if (!ROOM.test(a) || !ROOM.test(b)) fail('A link names two rooms, a and b.');
99
+ if (a === b) fail('A project cannot be connected to itself.');
100
+
101
+ const id = raw.id === undefined || raw.id === null ? contractId({ a, b }) : String(raw.id);
102
+ if (!ID.test(id)) fail('That is not a link id.');
103
+ if (id.slice(0, 12) !== pairHash(a, b)) fail('The link id does not belong to these rooms.');
104
+
105
+ const direction = String(raw.direction ?? '').replace('->', '→');
106
+ if (!DIRECTIONS.includes(direction)) fail('The direction is a→b, b→a or both.');
107
+
108
+ if (!Array.isArray(raw.kinds) || !raw.kinds.length) fail('A connection names the kinds that may cross.');
109
+ const kinds = [...new Set(raw.kinds.map(String))];
110
+ for (const kind of kinds) {
111
+ if (kind === 'aberration') fail('An aberration never crosses a connection.');
112
+ if (!LINK_KINDS.includes(kind)) fail(`"${kind}" is not a kind of memory.`);
113
+ }
114
+ kinds.sort((x, y) => LINK_KINDS.indexOf(x) - LINK_KINDS.indexOf(y));
115
+
116
+ // Not configurable: a contract that names any other set of zones is refused rather than
117
+ // trimmed, so nobody believes they signed something the room will not do.
118
+ if (raw.zones !== undefined && raw.zones !== null) {
119
+ const asked = Array.isArray(raw.zones) ? [...new Set(raw.zones.map(String))].sort() : null;
120
+ if (!asked || asked.length !== LINK_ZONES.length || asked.some((zone) => !LINK_ZONES.includes(zone))) fail('The zones of a link are not configurable: only what travels into a turn crosses.');
121
+ }
122
+
123
+ const expires = isoOrNull(raw.expires, 'expires');
124
+ const revoked = isoOrNull(raw.revoked, 'revoked');
125
+ // Fase 3e: the room that reads also gets, on each turn, up to RECALL_NOTES of these notes without
126
+ // asking. Off unless the human ticks it; a reading like any other, counted against `perHour`.
127
+ if (raw.recall !== undefined && raw.recall !== null && typeof raw.recall !== 'boolean') fail('recall is on or off.');
128
+ const recall = raw.recall === true;
129
+
130
+ const orders = raw.orders && typeof raw.orders === 'object' ? raw.orders : {};
131
+ const maxMode = orders.maxMode === undefined ? 1 : Number(orders.maxMode);
132
+ if (!Number.isInteger(maxMode) || maxMode < 0) fail('orders.maxMode is a mode, #0 to #2.');
133
+ if (maxMode > MAX_ORDER_MODE) fail(`orders.maxMode never passes #${MAX_ORDER_MODE}: #3 and #4 are armed by the human, in that room.`);
134
+ const perHour = orders.perHour === undefined ? DEFAULT_PER_HOUR : Number(orders.perHour);
135
+ if (!Number.isInteger(perHour) || perHour < 1 || perHour > 1000) fail('orders.perHour is a whole number above zero.');
136
+ const allowed = Boolean(orders.allowed);
137
+ if (salas) {
138
+ const serves = direction === 'both' ? [a, b] : direction === 'a→b' ? [a] : [b];
139
+ if (serves.includes(salas)) fail('The Coordinator\'s project never serves a connection: the Coordinator gathers every project and is nobody\'s source.');
140
+ if (allowed && a !== salas && b !== salas) fail('Orders need the Coordinator\'s project at one end of the connection.');
141
+ }
142
+
143
+ // Who signed it (D-S13): the human always may; BISHOP only a link to read, with no orders, that
144
+ // expires within BISHOP_SIGN_DAYS of its signature. A stored contract that breaks this is not a
145
+ // contract: `activeContracts` skips it.
146
+ const signed = raw.signed && typeof raw.signed === 'object' ? raw.signed : {};
147
+ const by = signed.by === undefined || signed.by === null ? 'you' : signed.by;
148
+ if (by !== 'you' && by !== 'bishop') fail('Only you or the Coordinator sign a connection.');
149
+ const at = signed.at === undefined || signed.at === null ? new Date(now).toISOString() : isoOrNull(signed.at, 'signed.at');
150
+ if (by === 'bishop') {
151
+ if (allowed) fail('The Coordinator signs only connections to read; a connection with orders is signed by you.');
152
+ if (expires === null || Date.parse(expires) - Date.parse(at) > BISHOP_SIGN_DAYS * DAY_MS + 60000) fail('The Coordinator signs only connections that expire within 7 days.'); // BISHOP_SIGN_DAYS, said literally for the catalogue
153
+ }
154
+
155
+ return {
156
+ id,
157
+ rooms: { a, b },
158
+ direction,
159
+ kinds,
160
+ zones: [...LINK_ZONES],
161
+ expires,
162
+ revoked,
163
+ orders: { allowed, maxMode, perHour },
164
+ signed: { at, by },
165
+ recall,
166
+ };
167
+ }
168
+
169
+ // Revoked or expired is the same as never signed.
170
+ export const inForce = (contract, now = new Date()) => Boolean(contract) && contract.revoked === null && (contract.expires === null || Date.parse(contract.expires) > new Date(now).getTime());
171
+
172
+ // Every contract in force, read from the config every room shares. A malformed entry is skipped,
173
+ // never repaired: a link the room cannot read is a link that does not exist.
174
+ export function activeContracts(config, { now = new Date(), salas = null } = {}) {
175
+ const raw = config?.enlaces;
176
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return [];
177
+ const out = [];
178
+ for (const [id, entry] of Object.entries(raw)) {
179
+ let contract;
180
+ try { contract = normalizeContract({ ...entry, id: entry?.id ?? id }, { now, salas }); } catch { continue; }
181
+ if (contract.id === id && inForce(contract, now)) out.push(contract);
182
+ }
183
+ return out;
184
+ }
185
+
186
+ // The contracts that let `asking` read, each with the room it reads from. What a module, a tool
187
+ // and the asking side of the channel all need to know, said once.
188
+ export function readsFrom(contracts, asking, { now = new Date() } = {}) {
189
+ const out = [];
190
+ for (const contract of contracts) {
191
+ for (const from of [contract.rooms.a, contract.rooms.b]) {
192
+ if (from !== asking && mayServe({ contract, serving: from, asking, now }).ok) out.push({ contract, from });
193
+ }
194
+ }
195
+ return out;
196
+ }
197
+
198
+ // May `serving` hand its notes to `asking` under this contract, now? The arrow says which way
199
+ // notes travel: `a→b` lets b read from a, so a serves and b asks.
200
+ export function mayServe({ contract, serving, asking, now = new Date() }) {
201
+ if (!contract) return { ok: false, why: 'no-contract' };
202
+ if (contract.revoked !== null) return { ok: false, why: 'revoked' };
203
+ if (contract.expires !== null && Date.parse(contract.expires) <= new Date(now).getTime()) return { ok: false, why: 'expired' };
204
+ const { a, b } = contract.rooms;
205
+ const from = serving === a ? 'a' : serving === b ? 'b' : null;
206
+ const to = asking === a ? 'a' : asking === b ? 'b' : null;
207
+ if (!from || !to || from === to) return { ok: false, why: 'not-a-party' };
208
+ if (contract.direction !== 'both' && contract.direction !== `${from}→${to}`) return { ok: false, why: 'direction' };
209
+ return { ok: true };
210
+ }
211
+
212
+ // Signing writes one key under `enlaces`: `updateConfig` merges that level by key, so two rooms
213
+ // signing at once keep both contracts, as two modules saving at once keep both (S-007). Both
214
+ // rooms must be known on this Mac — asleep is fine; it is read when it wakes. `rooms` may be the
215
+ // ids, or the rooms as `knownRooms` lists them (with their names).
216
+ //
217
+ // A link that lets orders through is signed the way #3 is armed: the human types the designation
218
+ // of the room that will receive them — its name, whichever letters — or nothing is signed
219
+ // (§4B). The error carries `code: 'DESIGNATION'` so the route can answer 403 and not 422.
220
+ export async function signContract(stateRoot, raw, { now = new Date(), rooms = null, salas = null, designation = null } = {}) {
221
+ const contract = normalizeContract(raw, { now, salas });
222
+ if (contract.revoked !== null) fail('A link is not signed already revoked.');
223
+ if (contract.expires !== null && Date.parse(contract.expires) <= new Date(now).getTime()) fail('That connection would already have expired.');
224
+ const known = new Map((rooms ?? await knownRooms(stateRoot)).map((room) => (typeof room === 'string' ? [room, { id: room, name: room }] : [room.id, room])));
225
+ for (const id of [contract.rooms.a, contract.rooms.b]) if (!known.has(id)) fail(`MADRE does not know the room ${id} on this Mac.`, 'UNKNOWN_ROOM');
226
+ const ordered = receivesOrders(contract, salas);
227
+ if (ordered) {
228
+ const name = String(known.get(ordered).name ?? ordered);
229
+ if (typeof designation !== 'string' || designation.trim().toLowerCase() !== name.toLowerCase()) fail('UNABLE TO COMPUTE. UNABLE TO CLARIFY.', 'DESIGNATION');
230
+ }
231
+ // Two projects are connected once (2026-10-11): while one connection between them is in force,
232
+ // whichever way it goes, no other is signed. Checked and written in one step (2026-10-12): checked
233
+ // before the lock, two signatures at once both passed and both stood.
234
+ const pair = [contract.rooms.a, contract.rooms.b].sort().join('\n');
235
+ await withConfig(stateRoot, (config) => {
236
+ if (activeContracts(config, { now, salas }).some((one) => [one.rooms.a, one.rooms.b].sort().join('\n') === pair)) fail('These two are already connected. To change it, revoke it in Connections and make a new one.', 'ALREADY');
237
+ config.enlaces = config.enlaces && typeof config.enlaces === 'object' && !Array.isArray(config.enlaces) ? config.enlaces : {};
238
+ config.enlaces[contract.id] = contract;
239
+ return config;
240
+ });
241
+ return contract;
242
+ }
243
+
244
+ // Revoking stamps the moment and keeps the contract: a link that was revoked is a fact of the
245
+ // record, and the id never comes back (a new signature gets a new nonce).
246
+ // Only an id that is one: `__proto__` and its kind wrote junk into the shared file (INI-06).
247
+ export async function revokeContract(stateRoot, id, { now = new Date() } = {}) {
248
+ if (typeof id !== 'string' || !ID.test(id)) return null;
249
+ let revoked = null;
250
+ await withConfig(stateRoot, (config) => {
251
+ const current = config.enlaces && Object.hasOwn(config.enlaces, id) ? config.enlaces[id] : null;
252
+ if (!current || typeof current !== 'object') return null;
253
+ revoked = { ...current, id, revoked: current.revoked ?? new Date(now).toISOString() };
254
+ config.enlaces[id] = revoked;
255
+ return config;
256
+ });
257
+ return revoked;
258
+ }
259
+
260
+ // «Include always» on a link, ticked or cleared: only that field, and only on a link still in
261
+ // force, read and written in one step — written from a copy read before, it crossed a revocation
262
+ // and put the revoked link back in force (INI-02).
263
+ export async function recallContract(stateRoot, id, recall, { now = new Date(), salas = null } = {}) {
264
+ if (typeof id !== 'string' || !ID.test(id)) return null;
265
+ let changed = null;
266
+ await withConfig(stateRoot, (config) => {
267
+ if (!activeContracts(config, { now, salas }).some((one) => one.id === id)) return null;
268
+ config.enlaces[id] = { ...config.enlaces[id], recall: recall === true };
269
+ changed = activeContracts(config, { now, salas }).find((one) => one.id === id) ?? null;
270
+ return config;
271
+ });
272
+ return changed;
273
+ }
274
+
275
+ /* ---------- the key of the channel ---------- */
276
+
277
+ export const channelKeyFile = (stateRoot) => join(stateRoot, 'credentials', 'enlaces.json');
278
+
279
+ async function readKey(file) {
280
+ const parsed = JSON.parse(await readFile(file, 'utf8'));
281
+ const key = parsed && typeof parsed === 'object' ? parsed.key : null;
282
+ if (typeof key !== 'string' || !KEY_HEX.test(key)) fail('credentials/enlaces.json does not hold a key.');
283
+ return key;
284
+ }
285
+
286
+ // The key is made the first time a room needs it: a whole file written beside the name and
287
+ // linked under it, which lands only if nothing is there yet — two rooms making it at once keep
288
+ // the one that landed first, and nobody ever reads half a key. Read from disk on every call, so
289
+ // every room on this Mac holds the same one.
290
+ export async function channelKey(stateRoot) {
291
+ const file = channelKeyFile(stateRoot);
292
+ try {
293
+ return await readKey(file);
294
+ } catch (error) {
295
+ if (error.code !== 'ENOENT') throw error;
296
+ }
297
+ const dir = join(stateRoot, 'credentials');
298
+ await mkdir(dir, { recursive: true, mode: OWNER_DIR });
299
+ // An older credentials directory may predate this mode (src/vault.mjs does the same).
300
+ await chmod(dir, OWNER_DIR).catch(() => {});
301
+ const draft = `${file}.${process.pid}.${randomUUID()}.tmp`;
302
+ const content = `${JSON.stringify({ key: randomBytes(32).toString('hex') })}\n`;
303
+ await writeFile(draft, content, { mode: OWNER_ONLY });
304
+ try {
305
+ await link(draft, file);
306
+ } catch (error) {
307
+ if (error.code !== 'EEXIST') {
308
+ // A filesystem without hard links: the bridge's way (src/bridge/devices.mjs), one write
309
+ // that lands only if nothing is there yet.
310
+ try { await writeFile(file, content, { mode: OWNER_ONLY, flag: 'wx' }); } catch (again) { if (again.code !== 'EEXIST') throw again; }
311
+ }
312
+ } finally {
313
+ await rm(draft, { force: true });
314
+ }
315
+ await chmod(file, OWNER_ONLY).catch(() => {});
316
+ return readKey(file);
317
+ }
318
+
319
+ // Constant time, and never a throw on a header that is not a string.
320
+ export function sameKey(a, b) {
321
+ if (typeof a !== 'string' || typeof b !== 'string' || !a || a.length !== b.length) return false;
322
+ return timingSafeEqual(Buffer.from(a), Buffer.from(b));
323
+ }
324
+
325
+ // What the ledger, a card or a report may know of the key: that it is there, its name, its
326
+ // length. Never the value.
327
+ export async function channelKeySummary(stateRoot) {
328
+ try {
329
+ const key = await readKey(channelKeyFile(stateRoot));
330
+ return { name: 'enlaces.json', bytes: key.length };
331
+ } catch {
332
+ return null;
333
+ }
334
+ }
335
+
336
+ /* ---------- the side that asks ---------- */
337
+
338
+ // Asks another room for notes under a contract. The call goes from this process to that one, on
339
+ // this Mac: no `Origin` (a page did not make it), no `x-madre-device` (a phone did not), the key
340
+ // in its header. What comes back is one of four things: the notes; `outdated` (that room runs a
341
+ // MADRE without the route); `refused` with the reason the serving room gave (no such link, no
342
+ // key, sealed, over its hourly limit); or `unreachable`. A received batch is recorded through
343
+ // `record` when the caller hands one in, and counted beside the room's record when it hands in
344
+ // the room's folder.
345
+ export async function askEnlace({ port, contract, asking, q = '', kinds = null, limit = MAX_LIMIT, key, fetchImpl = globalThis.fetch, timeoutMs = 4000, record = null, roomDir = null, turn = null, agent = null, auto = false, now = new Date() }) {
346
+ const id = typeof contract === 'string' ? contract : contract?.id;
347
+ if (!id || !asking) return { refused: NO_SUCH_LINK, status: 404 };
348
+ const params = new URLSearchParams({ contract: id, asking, limit: String(Math.min(MAX_LIMIT, Math.max(1, Math.trunc(Number(limit)) || MAX_LIMIT))) });
349
+ if (q) params.set('q', String(q));
350
+ // Fewer kinds than the contract names, never more: the serving room intersects again.
351
+ if (Array.isArray(kinds) && kinds.length) params.set('kinds', kinds.map(String).join(','));
352
+ // Which agent of the asking room reads (S-021): the serving room keeps it as that room's word.
353
+ if (agent) params.set('agent', String(agent));
354
+ let response;
355
+ try {
356
+ response = await fetchImpl(`http://127.0.0.1:${port}/api/enlace/memories?${params}`, {
357
+ method: 'GET',
358
+ headers: { [CHANNEL_HEADER]: typeof key === 'string' ? key : '' },
359
+ signal: AbortSignal.timeout(timeoutMs),
360
+ });
361
+ } catch {
362
+ return { unreachable: true };
363
+ }
364
+ const answer = await response.json().catch(() => ({}));
365
+ if (response.status === 404) return answer?.error === NO_SUCH_LINK ? { refused: NO_SUCH_LINK, status: 404 } : { outdated: true };
366
+ if (!response.ok) return { refused: typeof answer?.error === 'string' ? answer.error : `HTTP ${response.status}`, status: response.status };
367
+ const notes = Array.isArray(answer?.notes) ? answer.notes : [];
368
+ const from = typeof answer?.from === 'string' ? answer.from : null;
369
+ if (record) await record('enlace.received', { contract: id, from, count: notes.length, turn, agent, ...(auto ? { auto: true } : {}) });
370
+ if (roomDir) await noteCrossing(roomDir, { kind: 'received', contract: id, other: from, count: notes.length, agent, turn, now });
371
+ return { contract: id, from, notes };
372
+ }
373
+
374
+ // Tells another room, by the channel, that a link of its was signed or revoked. The same call as
375
+ // `askEnlace` makes, the other way: no `Origin`, no device, the key in its header, a short wait.
376
+ // What comes back is `{ ok }`, `{ outdated }` (that room runs a MADRE without the route),
377
+ // `{ refused, status }` or `{ unreachable }`; the caller never lets any of them stop a signature.
378
+ export async function noticeEnlace({ port, type, payload, key, fetchImpl = globalThis.fetch, timeoutMs = 3000 }) {
379
+ if (!NOTICES.includes(type)) fail(`A notice is one of ${NOTICES.join(', ')}.`);
380
+ let response;
381
+ try {
382
+ response = await fetchImpl(`http://127.0.0.1:${port}/api/enlace/notice`, {
383
+ method: 'POST',
384
+ headers: { 'content-type': 'application/json', [CHANNEL_HEADER]: typeof key === 'string' ? key : '' },
385
+ body: JSON.stringify({ type, payload }),
386
+ signal: AbortSignal.timeout(timeoutMs),
387
+ });
388
+ } catch {
389
+ return { unreachable: true };
390
+ }
391
+ if (response.status === 404) return { outdated: true };
392
+ if (!response.ok) {
393
+ const answer = await response.json().catch(() => ({}));
394
+ return { refused: typeof answer?.error === 'string' ? answer.error : `HTTP ${response.status}`, status: response.status };
395
+ }
396
+ return { ok: true };
397
+ }
398
+
399
+ /* ---------- the counters ---------- */
400
+
401
+ // One line per crossing beside the room's record, so the traffic of a link is counted without
402
+ // opening every ledger on every refresh: `{ at, kind: 'read' | 'received', contract, other, count }`.
403
+ export const crossingsFile = (roomDir) => join(roomDir, 'enlaces.jsonl');
404
+
405
+ export async function noteCrossing(roomDir, { kind, contract, other = null, count = 0, agent = null, turn = null, now = new Date() }) {
406
+ if (!CROSSINGS.includes(kind)) fail(`A crossing is one of ${CROSSINGS.join(', ')}.`);
407
+ const id = typeof contract === 'string' ? contract : contract?.id;
408
+ if (!id) fail('A crossing names its contract.');
409
+ await mkdir(roomDir, { recursive: true });
410
+ const line = { at: new Date(now).toISOString(), kind, contract: id, other, count: Number.isInteger(count) ? count : 0, ...(agent ? { agent: String(agent) } : {}), ...(turn ? { turn: String(turn) } : {}) };
411
+ await appendFile(crossingsFile(roomDir), `${JSON.stringify(line)}\n`);
412
+ return line;
413
+ }
414
+
415
+ // The lines of one contract and one kind beside a room's record, oldest first. A torn line is
416
+ // skipped, never fatal; no file is no crossing.
417
+ async function crossings(roomDir, contract, kind) {
418
+ const id = typeof contract === 'string' ? contract : contract?.id;
419
+ let raw = '';
420
+ try { raw = await readFile(crossingsFile(roomDir), 'utf8'); } catch (error) { if (error.code === 'ENOENT') return []; throw error; }
421
+ const out = [];
422
+ for (const text of raw.split('\n')) {
423
+ if (!text) continue;
424
+ let line;
425
+ try { line = JSON.parse(text); } catch { continue; }
426
+ if (line?.contract === id && line.kind === kind && Number.isFinite(Date.parse(line.at))) out.push(line);
427
+ }
428
+ return out;
429
+ }
430
+
431
+ // How many crossings of that kind this contract had in the window: lines, not notes, because the
432
+ // ceiling is on readings.
433
+ export async function countRecent(roomDir, contract, kind, { sinceMs = HOUR_MS, now = new Date() } = {}) {
434
+ const floor = new Date(now).getTime() - sinceMs;
435
+ return (await crossings(roomDir, contract, kind)).filter((line) => Date.parse(line.at) >= floor).length;
436
+ }
437
+
438
+ // What Salas shows of a link's traffic: the readings the serving room (or rooms, on a link that
439
+ // goes both ways) handed over in the last day and the last hour, and when the latest was. Summed
440
+ // across the folders given, with `countRecent`; `lastAt` is the newest line or `null`.
441
+ export async function linkTraffic(roomDirs, contract, { now = new Date() } = {}) {
442
+ const traffic = { reads24h: 0, reads1h: 0, lastAt: null, lastAgent: null, lastRoom: null };
443
+ for (const roomDir of roomDirs) {
444
+ traffic.reads24h += await countRecent(roomDir, contract, 'read', { sinceMs: DAY_MS, now });
445
+ traffic.reads1h += await countRecent(roomDir, contract, 'read', { sinceMs: HOUR_MS, now });
446
+ for (const line of await crossings(roomDir, contract, 'read')) {
447
+ if (traffic.lastAt === null || Date.parse(line.at) > Date.parse(traffic.lastAt)) Object.assign(traffic, { lastAt: line.at, lastAgent: line.agent ?? null, lastRoom: line.other ?? null });
448
+ }
449
+ }
450
+ return traffic;
451
+ }
@@ -3,8 +3,31 @@ import { dirname, join } from 'node:path';
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import { setTimeout as delay } from 'node:timers/promises';
5
5
 
6
+ // A record cut halfway (2026-10-12, LOG-02 of the 1.0 review): a `kill -9`, a power cut or a full
7
+ // disk in the middle of an append leaves half a line. Every read used to throw on it, so the
8
+ // conversation never opened again — and the next event was written glued to the half line, lost
9
+ // with it. Now a line that is not an event is stepped over and counted (`damaged`), never removed:
10
+ // the file keeps it for whoever wants to look. And an append that finds the file not ending in a
11
+ // newline writes one first, so what comes next starts on a line of its own.
12
+ function parseLines(text) {
13
+ const events = [];
14
+ let damaged = 0;
15
+ for (const line of text.split('\n')) {
16
+ if (!line.trim()) continue;
17
+ try {
18
+ const event = JSON.parse(line);
19
+ if (event && typeof event === 'object' && Number.isFinite(event.sequence)) events.push(event);
20
+ else damaged += 1;
21
+ } catch {
22
+ damaged += 1;
23
+ }
24
+ }
25
+ return { events, damaged };
26
+ }
27
+
6
28
  export class EventStore {
7
29
  #file;
30
+ #damaged = 0;
8
31
  #nextSequence = 1;
9
32
  #writeQueue = Promise.resolve();
10
33
  #lockDirectory;
@@ -14,16 +37,29 @@ export class EventStore {
14
37
  // numbering across all of its conversations, so #1411 means one exchange in this project and
15
38
  // not one in each thread — the memory index, every citation and NOSTROMO all lean on that.
16
39
  // Gaps in a single ledger are fine: sequences are an order, not a count.
40
+ // It may be a function, read at every append, when other conversations of the project are
41
+ // writing at the same time as this one: a number fixed when the ledger opened would hand out
42
+ // a sequence another conversation has taken since.
17
43
  constructor(file, { floor = 0 } = {}) {
18
44
  this.#file = file;
19
- this.#floor = Number.isFinite(floor) ? floor : 0;
45
+ this.#floor = typeof floor === 'function' || Number.isFinite(floor) ? floor : 0;
20
46
  this.#lockDirectory = `${file}.lock`;
21
47
  }
22
48
 
49
+ #floorNow() {
50
+ const value = typeof this.#floor === 'function' ? Number(this.#floor()) : this.#floor;
51
+ return Number.isFinite(value) ? value : 0;
52
+ }
53
+
54
+ // The last sequence this ledger handed out (or the floor it opened above).
55
+ get lastSequence() { return this.#nextSequence - 1; }
56
+ // How many lines the last full read stepped over because they were not events.
57
+ get damaged() { return this.#damaged; }
58
+
23
59
  async initialize() {
24
60
  await mkdir(dirname(this.#file), { recursive: true });
25
61
  const existing = await this.readAll();
26
- this.#nextSequence = Math.max(existing.at(-1)?.sequence ?? 0, this.#floor) + 1;
62
+ this.#nextSequence = Math.max(existing.at(-1)?.sequence ?? 0, this.#floorNow()) + 1;
27
63
  return this;
28
64
  }
29
65
 
@@ -59,7 +95,7 @@ export class EventStore {
59
95
  const lastNewline = text.lastIndexOf('\n');
60
96
  if (lastNewline < 0) return { events: [], offset: start };
61
97
  text = text.slice(0, lastNewline + 1);
62
- const events = text.split('\n').filter(Boolean).map((line) => JSON.parse(line));
98
+ const { events } = parseLines(text);
63
99
  return { events, offset: start + Buffer.byteLength(text, 'utf8') };
64
100
  } finally {
65
101
  await handle.close();
@@ -71,8 +107,9 @@ export class EventStore {
71
107
 
72
108
  async #readAllUnlocked() {
73
109
  try {
74
- const text = await readFile(this.#file, 'utf8');
75
- return text.split('\n').filter(Boolean).map((line) => JSON.parse(line));
110
+ const { events, damaged } = parseLines(await readFile(this.#file, 'utf8'));
111
+ this.#damaged = damaged;
112
+ return events;
76
113
  } catch (error) {
77
114
  if (error.code === 'ENOENT') return [];
78
115
  throw error;
@@ -144,7 +181,7 @@ export class EventStore {
144
181
  const temp = `${this.#file}.rewrite-${process.pid}`;
145
182
  await writeFile(temp, next.map((event) => JSON.stringify(event)).join('\n') + (next.length ? '\n' : ''));
146
183
  await rename(temp, this.#file);
147
- this.#nextSequence = Math.max(next.at(-1)?.sequence ?? 0, this.#floor) + 1;
184
+ this.#nextSequence = Math.max(next.at(-1)?.sequence ?? 0, this.#floorNow()) + 1;
148
185
  return { total: next.length, changed };
149
186
  } finally {
150
187
  await release();
@@ -159,7 +196,11 @@ export class EventStore {
159
196
  const release = await this.#acquireLock();
160
197
  try {
161
198
  const existing = await this.#readAllUnlocked();
162
- const sequence = Math.max(existing.at(-1)?.sequence ?? 0, this.#floor) + 1;
199
+ const sequence = Math.max(existing.at(-1)?.sequence ?? 0, this.#floorNow()) + 1;
200
+ // Taken in the same tick it was read: another conversation's ledger asks for the floor
201
+ // while this one is still on its way to the disk, and must already see this number as
202
+ // spent. A write that fails leaves a gap, and gaps are fine.
203
+ this.#nextSequence = sequence + 1;
163
204
  const event = {
164
205
  id: randomUUID(),
165
206
  sequence,
@@ -167,14 +208,18 @@ export class EventStore {
167
208
  timestamp: new Date().toISOString(),
168
209
  payload,
169
210
  };
170
- const handle = await open(this.#file, 'a');
211
+ const handle = await open(this.#file, 'a+');
171
212
  try {
172
- await handle.writeFile(`${JSON.stringify(event)}\n`);
213
+ // A file that does not end in a newline ends in half a line: start on a new one.
214
+ const { size } = await handle.stat();
215
+ const last = Buffer.alloc(1);
216
+ if (size > 0) await handle.read(last, 0, 1, size - 1);
217
+ const lead = size > 0 && last[0] !== 0x0a ? '\n' : '';
218
+ await handle.writeFile(`${lead}${JSON.stringify(event)}\n`);
173
219
  await handle.sync();
174
220
  } finally {
175
221
  await handle.close();
176
222
  }
177
- this.#nextSequence = sequence + 1;
178
223
  return event;
179
224
  } finally {
180
225
  await release();
package/src/exam.mjs CHANGED
@@ -102,7 +102,7 @@ export function spread(items, size) {
102
102
  export async function coverageExam({ events = [], recall, embed = null, sample = COVERAGE_SAMPLE, bar = null } = {}) {
103
103
  const all = exchanges(events);
104
104
  const cases = spread(all, sample);
105
- if (!cases.length) return { id: 'coverage', ran: false, says: t('No exchange in this room is long enough to test with yet.') };
105
+ if (!cases.length) return { id: 'coverage', ran: false, says: t('No exchange in this project is long enough to test with yet.') };
106
106
  const found = [];
107
107
  for (const one of cases) {
108
108
  const held = await recall(one.asked, one.sequence);
@@ -146,7 +146,7 @@ export async function coverageExam({ events = [], recall, embed = null, sample =
146
146
  id: 'coverage', ran: true, at: new Date().toISOString(), n: cases.length, hits, rate, method, bar: used,
147
147
  mean: mean(scores), control: controlled ? mean(controls) : null,
148
148
  passed: rate >= PASS.coverage,
149
- says: t('{hits} of {n} questions this room actually asked had their answer already in the archive, matched by {method}{control}.', { hits, n: cases.length, method: t(method), control: controlled ? t(' and against a control') : '' }),
149
+ says: t('{hits} of {n} questions this project actually asked had their answer already in the archive, matched by {method}{control}.', { hits, n: cases.length, method: t(method), control: controlled ? t(' and against a control') : '' }),
150
150
  };
151
151
  }
152
152
 
@@ -186,10 +186,10 @@ export function consistencyExam({ findings = [], notes = [], now = Date.now() }
186
186
  // `ask(question)` is the local model with this archive behind it. `embed` is needed: comparing
187
187
  // two answers by their words rewards copying the question back.
188
188
  export async function matchExam({ events = [], ask, embed, sample = MATCH_SAMPLE, bar = MATCH_BAR, onProgress = () => {}, stop = () => false } = {}) {
189
- if (!embed) return { id: 'match', ran: false, says: t('This test needs embeddings: two answers cannot be compared by their words alone.') };
189
+ if (!embed) return { id: 'match', ran: false, says: t('This test needs smart search: two answers cannot be compared by their words alone.') };
190
190
  const all = exchanges(events).filter((one) => !one.local); // a model is not tested against itself
191
191
  const cases = spread(all, sample);
192
- if (!cases.length) return { id: 'match', ran: false, says: t('No question in this room was answered by an agent other than the local one yet.') };
192
+ if (!cases.length) return { id: 'match', ran: false, says: t('No question in this project was answered by an agent other than the local one yet.') };
193
193
  const mine = [];
194
194
  for (const [index, one] of cases.entries()) {
195
195
  if (stop()) return { id: 'match', ran: false, stopped: true, says: t('Stopped after {index} of {n}.', { index, n: cases.length }) };
@@ -222,7 +222,7 @@ export async function matchExam({ events = [], ask, embed, sample = MATCH_SAMPLE
222
222
  export function saysFor(result) {
223
223
  if (!result?.ran) return result?.says ?? null;
224
224
  if (result.id === 'coverage' && Number.isFinite(result.hits)) {
225
- return t('{hits} of {n} questions this room actually asked had their answer already in the archive, matched by {method}{control}.',
225
+ return t('{hits} of {n} questions this project actually asked had their answer already in the archive, matched by {method}{control}.',
226
226
  { hits: result.hits, n: result.n, method: t(result.method ?? 'words'), control: result.control === null || result.control === undefined ? '' : t(' and against a control') });
227
227
  }
228
228
  if (result.id === 'match' && Number.isFinite(result.matched)) {