@jossuealcala/madre 0.7.2 → 1.0.0

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 (163) hide show
  1. package/CHANGELOG.md +773 -1
  2. package/README.md +92 -28
  3. package/bin/madre.mjs +30 -10
  4. package/docs/REFERENCE.md +157 -81
  5. package/docs/SDK.md +23 -19
  6. package/package.json +1 -1
  7. package/public/app.js +11207 -4196
  8. package/public/assets/brand/Geist-latin-ext.woff2 +0 -0
  9. package/public/assets/brand/Geist-latin.woff2 +0 -0
  10. package/public/assets/brand/GeistMono-latin-ext.woff2 +0 -0
  11. package/public/assets/brand/GeistMono-latin.woff2 +0 -0
  12. package/public/assets/brand/JetBrainsMono-latin-ext.woff2 +0 -0
  13. package/public/assets/brand/JetBrainsMono-latin.woff2 +0 -0
  14. package/public/assets/brand/OFL-Geist.txt +93 -0
  15. package/public/assets/brand/OFL-GeistMono.txt +93 -0
  16. package/public/assets/brand/OFL-JetBrainsMono.txt +93 -0
  17. package/public/assets/brand/OFL-Sora.txt +93 -0
  18. package/public/assets/brand/Sora-latin-ext.woff2 +0 -0
  19. package/public/assets/brand/Sora-latin.woff2 +0 -0
  20. package/public/assets/brand/madre-app-180.png +0 -0
  21. package/public/assets/brand/madre-app-192.png +0 -0
  22. package/public/assets/brand/madre-app-512.png +0 -0
  23. package/public/assets/brand/madre-app.svg +1 -0
  24. package/public/assets/brand/madre-favicon.svg +1 -0
  25. package/public/assets/news/1.0/alerts.webp +0 -0
  26. package/public/assets/news/1.0/bar.webp +0 -0
  27. package/public/assets/news/1.0/finder.webp +0 -0
  28. package/public/assets/news/1.0/github.webp +0 -0
  29. package/public/assets/news/1.0/intents.webp +0 -0
  30. package/public/assets/news/1.0/looks.webp +0 -0
  31. package/public/assets/news/1.0/modules.webp +0 -0
  32. package/public/assets/news/1.0/mother.webp +0 -0
  33. package/public/assets/news/1.0/nostromo.webp +0 -0
  34. package/public/assets/news/1.0/permissions.webp +0 -0
  35. package/public/assets/news/1.0/room.webp +0 -0
  36. package/public/assets/news/1.0/settings.webp +0 -0
  37. package/public/assets/wallpapers/biznaga.webp +0 -0
  38. package/public/assets/wallpapers/canela.webp +0 -0
  39. package/public/assets/wallpapers/jarrito.webp +0 -0
  40. package/public/assets/wallpapers/magna.webp +0 -0
  41. package/public/assets/wallpapers/oceano.webp +0 -0
  42. package/public/assets/wallpapers/paraiso.webp +0 -0
  43. package/public/assets/wallpapers/quila.webp +0 -0
  44. package/public/assets/wallpapers/tradicion.webp +0 -0
  45. package/public/assets/wallpapers/tropical.webp +0 -0
  46. package/public/assets/wallpapers/urbania.webp +0 -0
  47. package/public/assets/wallpapers/ventura.webp +0 -0
  48. package/public/assets/wallpapers/xico.webp +0 -0
  49. package/public/brands.js +28 -2
  50. package/public/editor.js +744 -0
  51. package/public/es.js +2784 -1026
  52. package/public/i18n.js +1 -1
  53. package/public/index.html +201 -175
  54. package/public/inquiry.js +11 -11
  55. package/public/loader.js +79 -0
  56. package/public/macroverso.js +1497 -0
  57. package/public/manifest.webmanifest +14 -0
  58. package/public/nostromo-engine.js +2222 -0
  59. package/public/resay.js +24 -0
  60. package/public/review-ask.js +85 -0
  61. package/public/salas-mac.css +787 -0
  62. package/public/salas-mac.html +154 -0
  63. package/public/salas-mac.js +3264 -0
  64. package/public/salas.css +85 -0
  65. package/public/salas.html +47 -0
  66. package/public/salas.js +200 -0
  67. package/public/styles.css +6207 -425
  68. package/public/touch.js +83 -0
  69. package/public/tour-chapters.js +342 -0
  70. package/public/tour.js +510 -0
  71. package/public/troubleshooting.js +159 -140
  72. package/src/adapters/codex.mjs +46 -6
  73. package/src/adapters/gemini.mjs +30 -10
  74. package/src/adapters/madre.mjs +110 -18
  75. package/src/adapters/process.mjs +42 -2
  76. package/src/auth-probe.mjs +2 -2
  77. package/src/bridge/devices.mjs +202 -0
  78. package/src/bridge/handoff.mjs +45 -0
  79. package/src/bridge/host.mjs +82 -0
  80. package/src/bridge/network.mjs +133 -0
  81. package/src/bridge/policy.mjs +89 -0
  82. package/src/bridge/qr.mjs +287 -0
  83. package/src/bridge/server.mjs +269 -0
  84. package/src/capabilities.mjs +5 -9
  85. package/src/chats.mjs +59 -22
  86. package/src/checkpoint.mjs +137 -35
  87. package/src/choices.mjs +18 -3
  88. package/src/cli-args.mjs +12 -1
  89. package/src/commands.mjs +15 -15
  90. package/src/config.mjs +73 -9
  91. package/src/conversation-context.mjs +5 -2
  92. package/src/dataset.mjs +3 -1
  93. package/src/directives.mjs +0 -5
  94. package/src/enlaces.mjs +451 -0
  95. package/src/event-store.mjs +55 -10
  96. package/src/exam.mjs +5 -5
  97. package/src/extensions.mjs +0 -2
  98. package/src/file-ops.mjs +23 -0
  99. package/src/files.mjs +52 -2
  100. package/src/github.mjs +407 -0
  101. package/src/json-file.mjs +63 -0
  102. package/src/lease.mjs +2 -2
  103. package/src/lobby-work.mjs +103 -0
  104. package/src/looks.mjs +55 -0
  105. package/src/maturity.mjs +3 -3
  106. package/src/mcp/github-server.mjs +164 -0
  107. package/src/mcp/image-server.mjs +1 -1
  108. package/src/mcp/memory-server.mjs +11 -3
  109. package/src/memory.mjs +38 -12
  110. package/src/models.mjs +89 -5
  111. package/src/modules/ahp.mjs +4 -4
  112. package/src/modules/ash.mjs +1 -1
  113. package/src/modules/bishop-plan.mjs +207 -0
  114. package/src/modules/bishop.mjs +645 -0
  115. package/src/modules/choices.mjs +2 -2
  116. package/src/modules/correo.mjs +1 -1
  117. package/src/modules/enlaces.mjs +255 -0
  118. package/src/modules/git-pulse.mjs +2 -2
  119. package/src/modules/github.mjs +322 -0
  120. package/src/modules/image-studio.mjs +3 -3
  121. package/src/modules/index.mjs +18 -4
  122. package/src/modules/intents-calibration.mjs +338 -0
  123. package/src/modules/intents-desk.mjs +481 -0
  124. package/src/modules/intents-observe.mjs +266 -0
  125. package/src/modules/intents-policy.mjs +414 -0
  126. package/src/modules/intents-try.mjs +91 -0
  127. package/src/modules/intents.mjs +145 -0
  128. package/src/modules/ollama.mjs +4 -4
  129. package/src/modules/playwright.mjs +121 -11
  130. package/src/modules/ripley.mjs +1 -1
  131. package/src/modules/runs.mjs +2 -2
  132. package/src/modules/sdk.mjs +10 -4
  133. package/src/ollama.mjs +6 -1
  134. package/src/outbound.mjs +19 -17
  135. package/src/override.mjs +100 -0
  136. package/src/ports.mjs +23 -1
  137. package/src/prices.mjs +72 -0
  138. package/src/privacy.mjs +30 -11
  139. package/src/quota-sources.mjs +0 -8
  140. package/src/request-guard.mjs +1 -1
  141. package/src/review-ask.mjs +111 -0
  142. package/src/room/archivist.mjs +55 -37
  143. package/src/room/budget.mjs +17 -5
  144. package/src/room/control.mjs +70 -7
  145. package/src/room/economy.mjs +35 -7
  146. package/src/room/escalation.mjs +2 -0
  147. package/src/room/guard.mjs +26 -2
  148. package/src/room/prompt.mjs +22 -15
  149. package/src/room/runs.mjs +84 -21
  150. package/src/room/vectors.mjs +19 -4
  151. package/src/room.mjs +351 -65
  152. package/src/router.mjs +3 -1
  153. package/src/runs.mjs +23 -5
  154. package/src/salas.mjs +313 -0
  155. package/src/sentinel-errors.mjs +1 -1
  156. package/src/server-sentinel.mjs +124 -0
  157. package/src/server.mjs +1526 -163
  158. package/src/setup.mjs +1 -0
  159. package/src/tour.mjs +159 -0
  160. package/src/updates.mjs +48 -8
  161. package/src/usage-sentinel.mjs +1 -1
  162. package/src/verdict.mjs +7 -7
  163. package/src/whats-new.mjs +160 -0
@@ -0,0 +1,202 @@
1
+ // The phones that may reach this Mac, and the networks where they may.
2
+ //
3
+ // One file, `~/.pulse/devices.json`, owner-only, shared by every room on this Mac: any of them can
4
+ // make an invitation and whichever hosts the bridge redeems it. What is kept of a phone is a
5
+ // fingerprint of its token, never the token; what is kept of an invitation is a fingerprint of its
6
+ // code. Both fingerprints are HMACs under one key that lives in `~/.pulse/credentials/bridge.key`
7
+ // (docs/MOBILE.md, D-M01): MADRE's only key of its own, never in the config, a ledger or a log.
8
+ //
9
+ // Several rooms are separate processes, so every change takes a lock file, reads, changes and
10
+ // writes the whole file back by rename. A reader never sees half a file.
11
+
12
+ import { createHmac, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
13
+ import { mkdir, open, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
14
+ import { join } from 'node:path';
15
+
16
+ const OWNER_ONLY = 0o600;
17
+ export const INVITE_MS = 10 * 60 * 1000;
18
+ export const SESSION_MS = 30 * 24 * 60 * 60 * 1000;
19
+ // A phone's last visit is written at most this often: every request would be a write.
20
+ const SEEN_EVERY_MS = 60 * 60 * 1000;
21
+
22
+ const storePath = (root) => join(root, 'devices.json');
23
+ const lockPath = (root) => join(root, 'devices.lock');
24
+ const keyPath = (root) => join(root, 'credentials', 'bridge.key');
25
+
26
+ const empty = () => ({ version: 1, enabled: true, devices: [], invites: [], networks: [] });
27
+
28
+ // The key is made the first time it is needed and never leaves this file.
29
+ export async function bridgeKey(root) {
30
+ const file = keyPath(root);
31
+ try {
32
+ const hex = (await readFile(file, 'utf8')).trim();
33
+ if (/^[0-9a-f]{64}$/.test(hex)) return Buffer.from(hex, 'hex');
34
+ } catch (error) {
35
+ if (error.code !== 'ENOENT') throw error;
36
+ }
37
+ await mkdir(join(root, 'credentials'), { recursive: true, mode: 0o700 });
38
+ const key = randomBytes(32);
39
+ // `wx`: two rooms making it at once keep the one that landed first.
40
+ try {
41
+ await writeFile(file, `${key.toString('hex')}\n`, { mode: OWNER_ONLY, flag: 'wx' });
42
+ return key;
43
+ } catch (error) {
44
+ if (error.code !== 'EEXIST') throw error;
45
+ return Buffer.from((await readFile(file, 'utf8')).trim(), 'hex');
46
+ }
47
+ }
48
+
49
+ const fingerprint = (key, purpose, secret) => createHmac('sha256', key).update(`${purpose}:${secret}`).digest('hex');
50
+ const same = (a, b) => typeof a === 'string' && typeof b === 'string' && a.length === b.length && timingSafeEqual(Buffer.from(a), Buffer.from(b));
51
+ const token = () => randomBytes(32).toString('base64url');
52
+
53
+ function normalise(raw) {
54
+ const base = empty();
55
+ if (!raw || typeof raw !== 'object') return base;
56
+ return {
57
+ version: 1,
58
+ enabled: raw.enabled !== false,
59
+ devices: Array.isArray(raw.devices) ? raw.devices.filter((one) => one && typeof one.id === 'string' && typeof one.hash === 'string') : [],
60
+ invites: Array.isArray(raw.invites) ? raw.invites.filter((one) => one && typeof one.hash === 'string' && Number.isFinite(one.expiresAt)) : [],
61
+ networks: Array.isArray(raw.networks) ? raw.networks.filter((one) => one && typeof one.id === 'string') : [],
62
+ };
63
+ }
64
+
65
+ export async function readStore(root) {
66
+ try {
67
+ return normalise(JSON.parse(await readFile(storePath(root), 'utf8')));
68
+ } catch (error) {
69
+ if (error.code === 'ENOENT' || error instanceof SyntaxError) return empty();
70
+ throw error;
71
+ }
72
+ }
73
+
74
+ async function lock(root) {
75
+ await mkdir(root, { recursive: true });
76
+ for (let attempt = 0; attempt < 100; attempt += 1) {
77
+ try {
78
+ const handle = await open(lockPath(root), 'wx');
79
+ await handle.close();
80
+ return;
81
+ } catch (error) {
82
+ if (error.code !== 'EEXIST') throw error;
83
+ // A lock left by a process that died mid-write is stale after a few seconds.
84
+ const held = await stat(lockPath(root)).catch(() => null);
85
+ if (held && Date.now() - held.mtimeMs > 5000) { await rm(lockPath(root), { force: true }); continue; }
86
+ await new Promise((resolve) => setTimeout(resolve, 20));
87
+ }
88
+ }
89
+ throw new Error('The list of phones is busy; try again.');
90
+ }
91
+
92
+ // Read, change, write, under the lock. `change` mutates the store and returns what the caller wants.
93
+ export async function withStore(root, change, { now = Date.now() } = {}) {
94
+ await lock(root);
95
+ try {
96
+ const store = await readStore(root);
97
+ // Lapsed invitations and phones are dropped on every write, so the file never grows.
98
+ store.invites = store.invites.filter((one) => (one.redeemedAt ? now - one.redeemedAt < INVITE_MS : one.expiresAt > now));
99
+ store.devices = store.devices.filter((one) => now - (one.seenAt ?? one.createdAt ?? 0) < SESSION_MS);
100
+ const result = await change(store);
101
+ const file = storePath(root);
102
+ const temporary = `${file}.${process.pid}.${randomUUID()}.tmp`;
103
+ await writeFile(temporary, `${JSON.stringify(store, null, 2)}\n`, { mode: OWNER_ONLY });
104
+ await rename(temporary, file);
105
+ return result;
106
+ } finally {
107
+ await rm(lockPath(root), { force: true });
108
+ }
109
+ }
110
+
111
+ // An invitation: a code that goes only into the QR, good once, for ten minutes.
112
+ export async function createInvite(root, { now = Date.now(), ttlMs = INVITE_MS } = {}) {
113
+ const key = await bridgeKey(root);
114
+ const code = token();
115
+ const hash = fingerprint(key, 'invite', code);
116
+ const id = hash.slice(0, 12);
117
+ await withStore(root, (store) => { store.invites.push({ id, hash, createdAt: now, expiresAt: now + ttlMs }); }, { now });
118
+ return { id, code, expiresAt: now + ttlMs };
119
+ }
120
+
121
+ // What a phone says it is, from its user agent: only the kind, never the string itself.
122
+ export function deviceKind(userAgent = '') {
123
+ if (/iPhone/i.test(userAgent)) return 'iphone';
124
+ if (/iPad/i.test(userAgent) || (/Macintosh/i.test(userAgent) && /Mobile/i.test(userAgent))) return 'ipad';
125
+ if (/Android/i.test(userAgent)) return /Mobile/i.test(userAgent) ? 'android' : 'android-tablet';
126
+ return 'other';
127
+ }
128
+
129
+ // The phone hands the code back; it becomes a phone with a token of its own. A code is spent the
130
+ // moment it is used, even by a request that fails afterwards.
131
+ export async function redeemInvite(root, code, { userAgent = '', now = Date.now() } = {}) {
132
+ if (typeof code !== 'string' || code.length < 20 || code.length > 100) return null;
133
+ const key = await bridgeKey(root);
134
+ const hash = fingerprint(key, 'invite', code);
135
+ return withStore(root, (store) => {
136
+ const invite = store.invites.find((one) => same(one.hash, hash));
137
+ if (!invite || invite.redeemedAt || invite.expiresAt <= now) return null;
138
+ const secret = token();
139
+ const device = { id: randomUUID(), kind: deviceKind(userAgent), createdAt: now, seenAt: now, hash: fingerprint(key, 'device', secret) };
140
+ store.devices.push(device);
141
+ invite.redeemedAt = now;
142
+ invite.device = device.id;
143
+ return { device: publicDevice(device), token: secret };
144
+ }, { now });
145
+ }
146
+
147
+ export const publicDevice = ({ id, kind, createdAt, seenAt }) => ({ id, kind, createdAt, seenAt });
148
+
149
+ // The phone behind a token, or null. Its last visit is written at most once an hour.
150
+ export async function deviceForToken(root, secret, { now = Date.now(), store = null } = {}) {
151
+ if (typeof secret !== 'string' || secret.length < 20 || secret.length > 100) return null;
152
+ const key = await bridgeKey(root);
153
+ const hash = fingerprint(key, 'device', secret);
154
+ const current = store ?? await readStore(root);
155
+ const device = current.devices.find((one) => same(one.hash, hash));
156
+ if (!device || now - (device.seenAt ?? device.createdAt) >= SESSION_MS) return null;
157
+ if (now - (device.seenAt ?? 0) >= SEEN_EVERY_MS) {
158
+ await withStore(root, (fresh) => {
159
+ const mine = fresh.devices.find((one) => one.id === device.id);
160
+ if (mine) mine.seenAt = now;
161
+ }, { now }).catch(() => null);
162
+ }
163
+ return publicDevice({ ...device, seenAt: Math.max(device.seenAt ?? 0, now) });
164
+ }
165
+
166
+ export async function forgetDevice(root, id) {
167
+ return withStore(root, (store) => {
168
+ const before = store.devices.length;
169
+ store.devices = store.devices.filter((one) => one.id !== id);
170
+ return store.devices.length < before;
171
+ });
172
+ }
173
+
174
+ export async function setEnabled(root, on) {
175
+ return withStore(root, (store) => { store.enabled = Boolean(on); return store.enabled; });
176
+ }
177
+
178
+ // A network is known by the fingerprint network.mjs makes of it; its name is the person's.
179
+ export async function trustNetwork(root, { id, name = null, now = Date.now() }) {
180
+ if (typeof id !== 'string' || !id) throw new Error('No network to trust.');
181
+ return withStore(root, (store) => {
182
+ if (!store.networks.some((one) => one.id === id)) store.networks.push({ id, name: typeof name === 'string' ? name.slice(0, 60) : null, addedAt: now });
183
+ return store.networks;
184
+ }, { now });
185
+ }
186
+
187
+ export async function forgetNetwork(root, id) {
188
+ return withStore(root, (store) => {
189
+ store.networks = store.networks.filter((one) => one.id !== id);
190
+ return store.networks;
191
+ });
192
+ }
193
+
194
+ // What the Mac's own page may see: never a hash.
195
+ export function storeView(store, { now = Date.now() } = {}) {
196
+ return {
197
+ enabled: store.enabled,
198
+ devices: store.devices.map(publicDevice),
199
+ invites: store.invites.filter((one) => one.expiresAt > now || one.redeemedAt).map(({ id, expiresAt, redeemedAt = null, device = null }) => ({ id, expiresAt, redeemedAt, device })),
200
+ networks: store.networks.map(({ id, name, addedAt }) => ({ id, name, addedAt })),
201
+ };
202
+ }
@@ -0,0 +1,45 @@
1
+ // Where the person was, on the Mac and on the phone (docs/MOBILE.md, MOV-005).
2
+ //
3
+ // One small file for the whole Mac, `~/.pulse/handoff.json`, owner-only: the last place each side
4
+ // stood — which room, which conversation, the message at the top of the screen and what was left
5
+ // in the box. The other side offers to pick it up from there. A draft is the person's own words,
6
+ // so it is kept the way the ledger is kept, beside it, and only the latest one.
7
+
8
+ import { randomUUID } from 'node:crypto';
9
+ import { readFile, rename, writeFile } from 'node:fs/promises';
10
+ import { join } from 'node:path';
11
+
12
+ export const DRAFT_LIMIT = 4000;
13
+ const SIDES = new Set(['mac', 'phone']);
14
+ const file = (root) => join(root, 'handoff.json');
15
+
16
+ export async function readHandoff(root) {
17
+ try {
18
+ const raw = JSON.parse(await readFile(file(root), 'utf8'));
19
+ return { mac: raw?.mac ?? null, phone: raw?.phone ?? null };
20
+ } catch {
21
+ return { mac: null, phone: null };
22
+ }
23
+ }
24
+
25
+ const text = (value, limit) => (typeof value === 'string' ? value.slice(0, limit) : null);
26
+
27
+ export async function writeHandoff(root, side, entry, { now = Date.now() } = {}) {
28
+ if (!SIDES.has(side)) throw new Error('Unknown side.');
29
+ const current = await readHandoff(root);
30
+ current[side] = {
31
+ room: text(entry.room, 120),
32
+ project: text(entry.project, 120),
33
+ port: Number.isInteger(entry.port) ? entry.port : null,
34
+ chat: text(entry.chat, 40),
35
+ chatTitle: text(entry.chatTitle, 120),
36
+ anchor: text(entry.anchor, 80),
37
+ draft: text(entry.draft, DRAFT_LIMIT) ?? '',
38
+ kind: side === 'phone' ? text(entry.kind, 20) : null,
39
+ at: now,
40
+ };
41
+ const temporary = `${file(root)}.${process.pid}.${randomUUID()}.tmp`;
42
+ await writeFile(temporary, `${JSON.stringify(current, null, 2)}\n`, { mode: 0o600 });
43
+ await rename(temporary, file(root));
44
+ return current[side];
45
+ }
@@ -0,0 +1,82 @@
1
+ // Who keeps the bridge up. Every room on this Mac offers to; the port decides.
2
+ //
3
+ // No process of its own and nothing to start: each room, every few seconds, asks whether a phone
4
+ // should be able to reach this Mac right now — the phone is on, a phone is paired or a code is
5
+ // waiting, and the network is one the person trusts — and if so tries to open the bridge's port.
6
+ // The first room that gets it hosts the bridge; the others find it taken and leave it be. When the
7
+ // hosting room closes, the next one to ask takes over. On a network nobody trusted, whoever hosts
8
+ // it closes it.
9
+
10
+ import { readStore } from './devices.mjs';
11
+ import { currentNetwork } from './network.mjs';
12
+ import { BRIDGE_PORT, createBridge } from './server.mjs';
13
+
14
+ // `allowed`: whether this Mac offers the phone at all (MADRE 1.0 keeps it a preview, src/server.mjs).
15
+ export function hostBridge({ root, publicDirectory, port = BRIDGE_PORT, every = 15_000, network = currentNetwork, listenHost = '0.0.0.0', allowed = async () => true, log = (line) => console.error(line) }) {
16
+ let server = null;
17
+ let running = null;
18
+ let timer = null;
19
+ let last = { network: null, trusted: false, wanted: false };
20
+
21
+ const open = () => new Promise((resolve) => {
22
+ const candidate = createBridge({ root, port, publicDirectory });
23
+ candidate.once('error', (error) => resolve(error.code === 'EADDRINUSE' ? null : (log(`MADRE bridge: ${error.message}`), null)));
24
+ candidate.listen(port, listenHost, () => resolve(candidate));
25
+ });
26
+ const close = () => new Promise((resolve) => {
27
+ if (!server) return resolve();
28
+ const closing = server;
29
+ server = null;
30
+ closing.closeAllConnections?.();
31
+ closing.close(() => resolve());
32
+ });
33
+
34
+ async function settle() {
35
+ const store = await readStore(root);
36
+ const now = Date.now();
37
+ const waiting = store.invites.some((one) => !one.redeemedAt && one.expiresAt > now);
38
+ const wanted = (await allowed()) && store.enabled && (store.devices.length > 0 || waiting);
39
+ const here = wanted ? await network().catch(() => null) : null;
40
+ const trusted = Boolean(here && store.networks.some((one) => one.id === here.id));
41
+ last = { network: here, trusted, wanted };
42
+ if (wanted && trusted && !server) server = await open();
43
+ else if ((!wanted || !trusted) && server) await close();
44
+ return status();
45
+ }
46
+
47
+ // One at a time: a tick asked for while another runs waits for it and runs again.
48
+ function tick() {
49
+ running = (running ?? Promise.resolve()).then(settle, settle).catch((error) => { log(`MADRE bridge: ${error.message}`); return status(); });
50
+ return running;
51
+ }
52
+
53
+ function status() {
54
+ return { hosting: Boolean(server), port, network: last.network, trusted: last.trusted, wanted: last.wanted };
55
+ }
56
+
57
+ return {
58
+ start() {
59
+ if (timer) return;
60
+ timer = setInterval(() => { void tick(); }, every);
61
+ timer.unref?.();
62
+ void tick();
63
+ },
64
+ tick,
65
+ status,
66
+ async stop() {
67
+ clearInterval(timer);
68
+ timer = null;
69
+ await close();
70
+ },
71
+ };
72
+ }
73
+
74
+ // Is a bridge up on this Mac, whichever room hosts it?
75
+ export async function bridgeAnswers(port = BRIDGE_PORT) {
76
+ try {
77
+ const response = await fetch(`http://127.0.0.1:${port}/bridge/ping`, { signal: AbortSignal.timeout(600) });
78
+ return response.ok && (await response.json()).madre === true;
79
+ } catch {
80
+ return false;
81
+ }
82
+ }
@@ -0,0 +1,133 @@
1
+ // Which network this Mac is on, and how a phone on it reaches the Mac.
2
+ //
3
+ // A network is known by its router: the hardware address of the default gateway, hashed. Two
4
+ // cafés both handing out 192.168.1.1 are still two routers. Nothing here asks for a permission:
5
+ // macOS answers `route`, `arp` and `ipconfig` for anyone, and Linux keeps both tables in /proc.
6
+ // Where MADRE cannot read the router's address the network is unknown, and an unknown network is
7
+ // never a trusted one, so the phone simply is not offered there.
8
+
9
+ import { createHash } from 'node:crypto';
10
+ import { execFile } from 'node:child_process';
11
+ import { createSocket } from 'node:dgram';
12
+ import { readFile } from 'node:fs/promises';
13
+ import { networkInterfaces } from 'node:os';
14
+
15
+ const run = (file, args) => new Promise((resolve) => {
16
+ execFile(file, args, { timeout: 2000, windowsHide: true }, (error, stdout) => resolve(error ? '' : String(stdout)));
17
+ });
18
+
19
+ // `0:1a:2b:…` and `00:1A:2B:…` are the same router.
20
+ export function normaliseMac(mac) {
21
+ if (typeof mac !== 'string') return null;
22
+ const parts = mac.trim().toLowerCase().split(/[:-]/);
23
+ if (parts.length !== 6 || parts.some((part) => !/^[0-9a-f]{1,2}$/.test(part))) return null;
24
+ const out = parts.map((part) => part.padStart(2, '0')).join(':');
25
+ return out === '00:00:00:00:00:00' || out === 'ff:ff:ff:ff:ff:ff' ? null : out;
26
+ }
27
+
28
+ export function parseDarwinRoute(raw) {
29
+ const text = String(raw ?? '');
30
+ const gateway = text.match(/^\s*gateway:\s*(\S+)/m)?.[1] ?? null;
31
+ const device = text.match(/^\s*interface:\s*(\S+)/m)?.[1] ?? null;
32
+ return gateway && /^\d+\.\d+\.\d+\.\d+$/.test(gateway) ? { gateway, device } : null;
33
+ }
34
+
35
+ export const parseDarwinArp = (text) => normaliseMac(String(text ?? '').match(/ at ([0-9a-f:]+) /i)?.[1] ?? null);
36
+
37
+ // `ipconfig getsummary` names the router by its hardware address twice when the network speaks
38
+ // IPv6: in the router advertisement's source link-address and in the DHCPv6 server's DUID. A
39
+ // process macOS will not show the neighbour table to (the case for anything Node starts) can
40
+ // still read this.
41
+ export function parseDarwinSummary(raw) {
42
+ const text = String(raw ?? '');
43
+ const advertised = text.match(/source link-address option[^\n]*?:\s*([0-9a-f]{1,2}(?::[0-9a-f]{1,2}){5})/i)?.[1];
44
+ const server = text.match(/SERVERID[^\n]*?Addr\s+([0-9a-f]{1,2}(?::[0-9a-f]{1,2}){5})/i)?.[1];
45
+ return normaliseMac(advertised ?? null) ?? normaliseMac(server ?? null);
46
+ }
47
+
48
+ // /proc/net/route keeps the gateway as little-endian hex.
49
+ export function parseLinuxRoute(text) {
50
+ for (const line of text.split('\n').slice(1)) {
51
+ const [device, destination, gateway] = line.trim().split(/\s+/);
52
+ if (destination !== '00000000' || !gateway || gateway === '00000000') continue;
53
+ const bytes = gateway.match(/../g).map((pair) => parseInt(pair, 16)).reverse();
54
+ return { gateway: bytes.join('.'), device };
55
+ }
56
+ return null;
57
+ }
58
+
59
+ export function parseLinuxArp(text, ip) {
60
+ for (const line of text.split('\n').slice(1)) {
61
+ const [address, , , mac] = line.trim().split(/\s+/);
62
+ if (address === ip) return normaliseMac(mac);
63
+ }
64
+ return null;
65
+ }
66
+
67
+ // The system forgets a router it has not talked to in a while; one packet to it makes the system
68
+ // look it up again. On macOS one ping (it needs no privilege there); elsewhere one empty datagram
69
+ // to the discard port, where nothing listens and nothing answers.
70
+ const datagram = (gateway) => new Promise((resolve) => {
71
+ try {
72
+ const socket = createSocket('udp4');
73
+ socket.once('error', () => { socket.close(); resolve(); });
74
+ socket.send(Buffer.alloc(1), 9, gateway, () => { socket.close(); setTimeout(resolve, 300); });
75
+ } catch {
76
+ resolve();
77
+ }
78
+ });
79
+
80
+ const nudge = (gateway, platform = process.platform) => (platform === 'darwin' ? run('/sbin/ping', ['-c', '1', '-t', '1', gateway]) : datagram(gateway));
81
+
82
+ const networkId = (mac) => createHash('sha256').update(`network:${mac}`).digest('hex').slice(0, 16);
83
+
84
+ // The network this Mac is on now: { id, gateway, device } or null when it cannot be told.
85
+ export async function currentNetwork({ platform = process.platform, exec = run, read = (file) => readFile(file, 'utf8'), poke = nudge } = {}) {
86
+ let route = null;
87
+ let lookup = null;
88
+ if (platform === 'darwin') {
89
+ route = parseDarwinRoute(await exec('/sbin/route', ['-n', 'get', 'default']));
90
+ lookup = async () => parseDarwinArp(await exec('/usr/sbin/arp', ['-n', route.gateway]))
91
+ ?? (/^[a-z0-9]+$/.test(route.device ?? '') ? parseDarwinSummary(await exec('/usr/sbin/ipconfig', ['getsummary', route.device])) : null);
92
+ } else if (platform === 'linux') {
93
+ route = parseLinuxRoute(await read('/proc/net/route').catch(() => ''));
94
+ lookup = async () => parseLinuxArp(await read('/proc/net/arp').catch(() => ''), route.gateway);
95
+ }
96
+ if (!route) return null;
97
+ let mac = await lookup();
98
+ if (!mac) {
99
+ await poke(route.gateway, platform);
100
+ mac = await lookup();
101
+ }
102
+ if (!mac) return null;
103
+ return { id: networkId(mac), gateway: route.gateway, device: route.device };
104
+ }
105
+
106
+ const IPV4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
107
+
108
+ // Loopback, the private ranges and link-local: the only addresses a phone on the same Wi-Fi has.
109
+ // Anything else reached the bridge from outside the network, through a forwarded port or worse.
110
+ export function isPrivateAddress(raw) {
111
+ if (typeof raw !== 'string') return false;
112
+ const address = raw.startsWith('::ffff:') ? raw.slice(7) : raw;
113
+ const v4 = address.match(IPV4);
114
+ if (v4) {
115
+ const [a, b] = [Number(v4[1]), Number(v4[2])];
116
+ return a === 10 || a === 127 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 169 && b === 254);
117
+ }
118
+ const v6 = address.toLowerCase();
119
+ return v6 === '::1' || /^f[cd][0-9a-f]{2}:/.test(v6) || /^fe[89ab][0-9a-f]:/.test(v6);
120
+ }
121
+
122
+ // This Mac's addresses on the local network, the one on the default route's interface first.
123
+ export function lanAddresses({ device = null, interfaces = networkInterfaces() } = {}) {
124
+ const out = [];
125
+ for (const [name, entries] of Object.entries(interfaces)) {
126
+ for (const entry of entries ?? []) {
127
+ if (entry.internal || entry.family !== 'IPv4' || !isPrivateAddress(entry.address) || entry.address.startsWith('169.254.')) continue;
128
+ out.push({ name, address: entry.address });
129
+ }
130
+ }
131
+ out.sort((a, b) => Number(b.name === device) - Number(a.name === device));
132
+ return out.map((one) => one.address);
133
+ }
@@ -0,0 +1,89 @@
1
+ // What a phone may do in a room (docs/MOBILE.md, section 4).
2
+ //
3
+ // A list of what passes, not of what does not: a route the room grows tomorrow stays on the Mac
4
+ // until somebody decides it belongs on the phone too. Reading is wide; acting is everything a
5
+ // conversation needs. From the phone a person asks up to #2 CREATE, answers what an agent asks
6
+ // (a mode up to #2), runs a line an agent proposed, undoes, stops anything, opens or starts a
7
+ // conversation, changes settings and switches MADRE's own modules on or off. What stays on the
8
+ // Mac: #3 CONTROL (its key is checked with crypto.subtle, which a page on plain http does not
9
+ // have) and #4 AIRLOCK, permissions and the key that opens them, the keys of a provider,
10
+ // installing code somebody wrote, forgetting memories and editing files. The room checks the
11
+ // parts a path cannot say — which module, which mode was asked — on its own side as well.
12
+
13
+ import { normalizeMode } from '../capabilities.mjs';
14
+ import { t } from '../i18n.mjs';
15
+
16
+ // Every file the room's page loads: one missing here and app.js cannot import it, so the whole page
17
+ // stops on the phone (test/phone-bridge.test.mjs reads app.js's imports to keep this whole).
18
+ const STATIC = new Set(['/', '/app.js', '/nostromo-engine.js', '/brands.js', '/troubleshooting.js', '/inquiry.js', '/i18n.js', '/es.js', '/resay.js', '/syntax.js', '/editor.js', '/routing.js', '/abduction.js', '/styles.css', '/review-ask.js', '/loader.js', '/tour.js', '/tour-chapters.js', '/touch.js']);
19
+
20
+ const READ = new Set([
21
+ '/api/state', '/api/events', '/api/chats', '/api/version', '/api/whats-new', '/api/glance', '/api/handoff',
22
+ '/api/memory', '/api/memory/about', '/api/memory/dashboard',
23
+ '/api/tree', '/api/tree/search', '/api/files',
24
+ '/api/questions', '/api/decisions', '/api/turns', '/api/models', '/api/commands', '/api/mother', '/api/briefing',
25
+ '/api/settings', '/api/privacy', '/api/extensions', '/api/sentinel', '/api/maturity', '/api/maturity/exams',
26
+ // The servers on the Mac, to be seen; they open on the Mac.
27
+ '/api/ports',
28
+ // Modules that only read on GET.
29
+ '/api/ollama', '/api/intents',
30
+ ]);
31
+ const READ_PATTERNS = [
32
+ /^\/assets\/[a-z0-9/_.-]+$/i,
33
+ /^\/preview\/(project|attachments)\/.+$/,
34
+ /^\/api\/memory\/\d+\/traffic$/,
35
+ ];
36
+
37
+ const ACT = new Set([
38
+ 'POST /api/messages', 'POST /api/messages/rate', 'POST /api/attachments',
39
+ 'POST /api/stop-all', 'POST /api/stop-turn', 'POST /api/runs', 'POST /api/runs/stop',
40
+ 'POST /api/chats', 'POST /api/whats-new/seen', 'POST /api/memory/ask/dismiss', 'POST /api/handoff',
41
+ 'POST /api/intents/suggest',
42
+ // Settings, minus what the body asks about permissions (see below).
43
+ 'POST /api/settings', 'POST /api/turns', 'POST /api/language', 'POST /api/privacy', 'POST /api/updates/settings', 'POST /api/sentinel/settings',
44
+ 'POST /api/ollama/settings', 'POST /api/ollama/probe', 'POST /api/ollama/start',
45
+ ]);
46
+ const ACT_PATTERNS = [
47
+ ['POST', /^\/api\/plans\/[0-9a-f-]+\/stop$/],
48
+ ['POST', /^\/api\/chats\/[a-z0-9]+$/],
49
+ ['PATCH', /^\/api\/chats\/[a-z0-9]+$/],
50
+ ['POST', /^\/api\/eyecat\/(confirm|dismiss)$/],
51
+ ['POST', /^\/api\/control\/[\w-]+\/undo$/],
52
+ // A mode an agent asked for: the room refuses a grant above #2 from a phone.
53
+ ['POST', /^\/api\/modes\/[\w-]+\/decide$/],
54
+ // MADRE's own modules, on or off: the room refuses anything that would install code.
55
+ ['POST', /^\/api\/extensions\/[a-z0-9-]+\/(install|settings)$/],
56
+ ['POST', /^\/api\/intents(\/[a-z0-9/-]+)?$/],
57
+ ];
58
+
59
+ // The highest mode a phone may arm, and the rule's own words.
60
+ export const PHONE_MAX_MODE = 2;
61
+ const ON_THE_MAC = 'This is done on the Mac.';
62
+ const MODE_ON_THE_MAC = '#3 EDIT and #4 FULL ACCESS are opened from the Mac.';
63
+ const PERMISSIONS_ON_THE_MAC = 'Permissions are changed on the Mac.';
64
+
65
+ // The only bodies the bridge reads before passing them on.
66
+ export const inspects = (method, pathname) => method === 'POST' && (pathname === '/api/messages' || pathname === '/api/settings');
67
+
68
+ // A mode written in the message is the mode, as on the Mac: «#3» at a word's edge — in what the
69
+ // person wrote, not in the answer they quote above it (the page's «↩ @agent: “…”» line).
70
+ const MODE_TOKEN = /(^|\s)#([0-4])(?=\s|$)/;
71
+ export const writtenMode = (text) => (typeof text === 'string' ? Number(text.replace(/^↩ [^\n]*\n/, '').match(MODE_TOKEN)?.[2] ?? -1) : -1);
72
+
73
+ export function phonePolicy({ method = 'GET', pathname = '/', body = null } = {}) {
74
+ const verb = String(method).toUpperCase();
75
+ if (verb === 'GET' || verb === 'HEAD') {
76
+ if (STATIC.has(pathname) || READ.has(pathname) || READ_PATTERNS.some((pattern) => pattern.test(pathname))) return { allow: true };
77
+ return { allow: false, status: 403, error: t(ON_THE_MAC) };
78
+ }
79
+ const acts = ACT.has(`${verb} ${pathname}`) || ACT_PATTERNS.some(([want, pattern]) => want === verb && pattern.test(pathname));
80
+ if (!acts) return { allow: false, status: 403, error: t(ON_THE_MAC) };
81
+ if (pathname === '/api/settings' && body && (body.scopes !== undefined || body.override !== undefined)) return { allow: false, status: 403, error: t(PERMISSIONS_ON_THE_MAC) };
82
+ if (verb === 'POST' && pathname === '/api/messages') {
83
+ // Read as the room reads it (src/capabilities.mjs): «"4"» is #4 there, so it is #4 here (TEL-01).
84
+ const asked = normalizeMode(body?.mode, body?.create === true ? 2 : 1);
85
+ const written = writtenMode(body?.text);
86
+ if (Math.max(asked, written) > PHONE_MAX_MODE) return { allow: false, status: 403, error: t(MODE_ON_THE_MAC) };
87
+ }
88
+ return { allow: true };
89
+ }