@jossuealcala/madre 0.2.0 → 0.2.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,27 @@
2
2
 
3
3
  Todas las versiones publicadas de `@jossuealcala/madre`. Fechas en ISO.
4
4
 
5
+ ## 0.2.1 · 2026-09-18 · beta pública
6
+
7
+ Primera versión pensada para manos ajenas. Requiere Node 22.5 o superior.
8
+
9
+ ### Monitoreo
10
+ - Sentinel de errores en MU/TH/UR: los fallos que ninguna condición conocida explica y las caídas del proceso se guardan como reportes redactados (sin rutas, nombres, correos ni claves), con `REPORT ON GITHUB ↗` prellenado y el botón `✎ FEEDBACK`. Con el colector del autor configurado por defecto, cada reporte tiene `SEND` y existe `AUTO-REPORT`, apagado hasta que el humano lo encienda. `docs/report-collector/` trae el Worker que convierte reportes en issues.
11
+
12
+ ### Sala
13
+ - Copiar y responder al final de cada respuesta: el primero lleva el texto al portapapeles; el segundo elige qué agente responde y deja la cita al frente del compositor para la directiva del humano.
14
+ - Modo claro sin resplandor: la UX conserva sus colores y pierde el brillo de tubo; MU/TH/UR y NOSTROMO mantienen sus pantallas.
15
+ - Un archivista que falla se sienta media hora y el siguiente lote lo toma otro agente; la línea de fallo dice una sola frase y guarda el registro completo en el tooltip.
16
+ - MADRE no repite el mismo juego de frases dos veces seguidas al tocar su corazón.
17
+
18
+ ### Verdad y seguridad
19
+ - La frase de arranque ya no dice que nada sale de la máquina: los agentes hablan con sus proveedores. El README explica el modelo de amenazas en corto, incluida la deuda de CONTROL con Codex (zonas prohibidas revertidas después del turno).
20
+ - El reloj de escalación mantiene vivo el proceso mientras un plan espera al humano; el apagado resuelve las peticiones pendientes. En Node 22 esto cortaba la suite a la mitad.
21
+
22
+ ### Proyecto
23
+ - CI en GitHub Actions: Ubuntu y macOS, Node 22 y 24, con pruebas, empaquetado e instalación del tarball.
24
+ - Plantillas de issues, `SECURITY.md` y `CONTRIBUTING.md`.
25
+
5
26
  ## 0.2.0 · 2026-09-17
6
27
 
7
28
  Requiere Node 22.5 o superior (antes 20): la memoria de la sala corre sobre `node:sqlite`.
@@ -0,0 +1,38 @@
1
+ # Contributing · CREW MANUAL
2
+
3
+ MADRE is one local room where several AI coding CLIs work on a project together. Changes land through pull requests against `main`; CI runs the suite, packs the tarball and installs it on Ubuntu and macOS with Node 22 and 24.
4
+
5
+ ## Before you start
6
+
7
+ ```
8
+ node --version # ≥ 22.5
9
+ npm test # 111 tests, no model calls
10
+ npm run pack:check # packs, installs, exercises the CLI
11
+ node ./bin/madre.mjs doctor --catalog # what MU/TH/UR already knows
12
+ ```
13
+
14
+ ## What a good change looks like
15
+
16
+ - One idea per commit, in the imperative voice the log already speaks.
17
+ - A test for every behaviour you touch: `test/pulse.test.mjs` for the room and adapters, `test/memory.test.mjs` for memory, NOSTROMO and MOTHER, `test/sentinel.test.mjs` for the sentinel. No test may call a real model.
18
+ - Nothing leaves the machine without the human's say-so. If your change sends anything anywhere, it needs a switch, off by default, and a line in the threat model.
19
+ - Agents never write outside their lease. If you widen what an agent may do, the permission modes and MU/TH/UR's catalog must say so.
20
+ - MU/TH/UR speaks in uppercase and in short sentences; the room speaks like a person. Keep both voices.
21
+
22
+ ## Where things live
23
+
24
+ ```
25
+ bin/madre.mjs the CLI · start, doctor, setup
26
+ src/server.mjs HTTP + SSE, settings, modules, sentinel routes
27
+ src/room.mjs turns, permission modes, plans, CONTROL, handoff, memory hooks
28
+ src/adapters/ one file per CLI: Codex, Claude Code, Gemini CLI, OpenCode
29
+ src/memory.mjs SQLite index, distilled notes, vectors, recall
30
+ src/distiller.mjs the archivist's prompt and parsing
31
+ src/mcp/ MADRE's own MCP servers: pulse-image, pulse-memory
32
+ src/mother.mjs MOTHER's coded channel, CODE000
33
+ src/sentinel-errors.mjs unknown conditions and crashes → redacted reports
34
+ public/ the room UI; troubleshooting.js is MU/TH/UR's knowledge base
35
+ docs/report-collector/ the Worker that turns sentinel reports into issues
36
+ ```
37
+
38
+ Open questions go to issues with the `question` label. Ideas go through `✎ FEEDBACK` in MU/TH/UR or a plain issue. Be kind to the crew.
package/README.md CHANGED
@@ -4,6 +4,8 @@
4
4
 
5
5
  <p align="center">
6
6
  <a href="https://www.npmjs.com/package/@jossuealcala/madre"><img alt="npm" src="https://img.shields.io/npm/v/@jossuealcala/madre?style=flat-square&label=npm&color=9bff66&labelColor=050605"></a>
7
+ <a href="https://github.com/jossuealcacao-exe/madre/actions/workflows/ci.yml"><img alt="CI" src="https://img.shields.io/github/actions/workflow/status/jossuealcacao-exe/madre/ci.yml?style=flat-square&label=CI&color=9bff66&labelColor=050605"></a>
8
+ <img alt="status" src="https://img.shields.io/badge/status-beta-ffb000?style=flat-square&labelColor=050605">
7
9
  <img alt="node" src="https://img.shields.io/badge/node-%E2%89%A5%2022.5-9bff66?style=flat-square&labelColor=050605">
8
10
  <img alt="dependencies" src="https://img.shields.io/badge/dependencies-0-9bff66?style=flat-square&labelColor=050605">
9
11
  <img alt="license" src="https://img.shields.io/badge/license-Apache--2.0-9bff66?style=flat-square&labelColor=050605">
@@ -11,10 +13,10 @@
11
13
  </p>
12
14
 
13
15
  ```
14
- MU/TH/UR 6000 · INTERFACE 2037 · MADRE IS READY
16
+ MU/TH/UR 6000 · INTERFACE 2037 · MADRE IS READY · BETA
15
17
 
16
18
  one local room · four AI coding agents · one shared memory
17
- read-only by default · CONTROL when you say so · nothing leaves your machine
19
+ read-only by default · CONTROL when you say so · no MADRE cloud, no MADRE account: your agents keep their own provider connections
18
20
  ```
19
21
 
20
22
  # MADRE
@@ -216,6 +218,18 @@ La sala ofrece módulos integrados de MADRE y una integración externa opcional.
216
218
 
217
219
  Instalar el módulo externo AHP+ es la única acción de módulos con la que MADRE escribe en el proyecto. Por eso el botón muestra primero el comando exacto y exige confirmación; la ejecución se transmite en vivo a la sala y queda registrada en el log como `extension.install.started`, `extension.install.output` y `extension.install.finished`. La consulta a los agentes sigue siendo de solo lectura.
218
220
 
221
+ ## Modelo de amenazas, en corto
222
+
223
+ - **Qué sale de la máquina.** MADRE no tiene nube ni cuenta: no almacena credenciales, no tiene backend y no envía nada por sí sola. Pero cada agente es una CLI que llama a su proveedor: lo que un agente lee del proyecto puede viajar a OpenAI, Anthropic o Google según su configuración. El único envío propio de MADRE es el del sentinel, y solo si lo activas.
224
+ - **Escritura.** En #1 nadie escribe. En #2 la escritura queda confinada a `.pulse/out/<lease>/` por las reglas de cada CLI. En #3 CONTROL el proyecto entero es escribible salvo `.git/`, `.pulse/` y los `.env`: Claude, Gemini y OpenCode reciben esa prohibición como regla previa; Codex entra con su sandbox `workspace-write`, que no admite excluir rutas dentro del proyecto, así que en su caso la zona prohibida se hace cumplir **después del turno**: MADRE compara con el checkpoint y revierte lo que tocó ahí. Eso protege lo que persiste, no impide que un efecto intermedio ocurra durante el turno. Es deuda conocida: prevención antes que restauración. Mientras tanto, si un `.env` es crítico, no des CONTROL a Codex o baja su MAX MODE.
225
+ - **Memoria.** Todo lo dicho fuera de GHOST queda en `~/.pulse/rooms/<sala>/` y se reinyecta en los prompts de todos los agentes de esa sala. GHOST es la salida para lo que no debe recordarse.
226
+
227
+ ## Sentinel de errores y feedback
228
+
229
+ MU/TH/UR tiene una sección SENTINEL. Cuando un turno falla con un error que ninguna condición conocida explica, o el proceso de MADRE se cae, el sentinel guarda un reporte en el registro de la sala (`sentinel.report`): el error con rutas, nombres de usuario, correos y claves eliminados, la versión de MADRE y de Node, la plataforma y las versiones de los agentes detectados. Los repetidos se agrupan por huella durante 24 horas.
230
+
231
+ Nada sale de la máquina por sí solo. Cada reporte tiene `REPORT ON GITHUB ↗`, que abre un issue prellenado en el repositorio para que lo leas antes de publicarlo, y el botón `✎ FEEDBACK` de la cabecera abre uno en blanco con tu entorno. El colector del autor viene configurado por defecto (`https://madre-reports.jossue-alcala-o.workers.dev/v1/reports`; `PULSE_REPORT_URL` o `telemetry.reportUrl` en `~/.pulse/config.json` lo cambian, y un valor vacío lo quita), así que cada reporte tiene `SEND` y existe el interruptor `AUTO-REPORT`, apagado por defecto, que envía los nuevos reportes redactados al colector sin preguntar. `docs/report-collector/` trae un Worker de Cloudflare listo para desplegar que convierte cada reporte en un issue.
232
+
219
233
  ## Recuperación operativa
220
234
 
221
235
  Si MADRE se detiene a mitad de un turno, al arrancar de nuevo detecta los `agent.started` sin cierre y registra un `message.failed` recuperado para cada uno, así la interfaz no queda en "pensando". Al cerrar con Ctrl+C o `SIGTERM`, MADRE interrumpe los procesos de agente en curso, registra esos turnos como fallidos, entrega los eventos pendientes a las páginas abiertas y termina.
@@ -240,6 +254,8 @@ Cada agente tiene un timeout de 180 s por defecto; la burbuja de espera muestra
240
254
  | `PULSE_EMBED_MODEL` | `gemini-embedding-001` | Modelo de embeddings |
241
255
  | `PULSE_EMBED_DIMS` | `768` | Dimensiones del vector |
242
256
  | `PULSE_MEMORY_TOOLS` | `1` | Servidor MCP `pulse-memory` adjunto a cada turno (`0` lo quita) |
257
+ | `PULSE_REPORT_URL` | colector del autor | Colector del sentinel; vacío lo desactiva |
258
+ | `PULSE_AUTO_REPORT` | `0` | Envía en automático los reportes nuevos al colector (`1`) |
243
259
  | `PULSE_MAX_MESSAGE_CHARS` | `20000` | Tamaño máximo de un mensaje |
244
260
  | `PULSE_AGENT_TIMEOUT_MS` | `180000` | Timeout de invocación para todos los agentes |
245
261
  | `PULSE_<AGENTE>_TIMEOUT_MS` | — | Timeout para un agente concreto |
@@ -274,7 +290,7 @@ Todos los adaptadores corren en su propio grupo de procesos. Si un agente no res
274
290
 
275
291
  ## Cambios
276
292
 
277
- Ver [CHANGELOG.md](CHANGELOG.md). La versión actual es 0.2.0.
293
+ Ver [CHANGELOG.md](CHANGELOG.md). La versión actual es 0.2.1, beta pública: el núcleo está probado y bajo CI, la superficie sigue cambiando y las decisiones que aún duelen están escritas en el modelo de amenazas. Los problemas se reportan desde MU/TH/UR (`✎ FEEDBACK` o el sentinel) o en [issues](https://github.com/jossuealcacao-exe/madre/issues); la seguridad, según [SECURITY.md](SECURITY.md).
278
294
 
279
295
  ## Licencia
280
296
 
package/SECURITY.md ADDED
@@ -0,0 +1,19 @@
1
+ # Security · MU/TH/UR 6000 · PRIORITY ONE
2
+
3
+ MADRE runs on your machine, talks to no cloud of its own and stores no credentials. Its agents are CLIs you installed; they talk to their providers. The short threat model lives in the README under *Modelo de amenazas, en corto*.
4
+
5
+ ## Reporting
6
+
7
+ If you find a way for an agent to write outside its lease, to keep CONTROL it should not have, to reach the memory of a room it is not in, or to make MADRE send anything you did not allow, tell the author privately first:
8
+
9
+ - GitHub: open a **private security advisory** at https://github.com/jossuealcacao-exe/madre/security/advisories/new
10
+ - Or write through https://jossuealcala.com/en/
11
+
12
+ Please include the MADRE version (`madre doctor --json`), the platform, which agent and mode were involved, and the smallest sequence of messages that reproduces it. A fix ships as a patch release and the advisory is published once it is out. Reports that are about a CLI's own behaviour (Codex, Claude Code, Gemini CLI, OpenCode) are forwarded to that project.
13
+
14
+ ## Known, accepted for the beta
15
+
16
+ - In `#3 CONTROL`, Codex's `workspace-write` sandbox cannot exclude paths inside the project; `.git/`, `.pulse/` and `.env` files are restored from the checkpoint **after** the turn instead of being blocked before. Do not grant CONTROL to Codex where an intermediate effect on those files would matter.
17
+ - Everything said outside `#0 GHOST` is kept in the room's memory and reaches every agent of that room. Use GHOST for what must not be remembered.
18
+
19
+ NOBODY DELETES MOTHER'S MEMORY. EVERYTHING ELSE IS FAIR GAME.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jossuealcala/madre",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "MADRE: one local room where the AI coding agents already installed on your machine (Codex, Claude Code, Gemini CLI, OpenCode) work on a project together over the PULSE channel: read-only by default, per-message permission modes up to a checkpointed CONTROL, a shared memory every agent recalls and queries, and NOSTROMO to browse it.",
5
5
  "keywords": [
6
6
  "ai-agents",
@@ -41,7 +41,9 @@
41
41
  "CHANGELOG.md",
42
42
  "LICENSE",
43
43
  "NOTICE",
44
- "docs/madre-banner.svg"
44
+ "docs/madre-banner.svg",
45
+ "SECURITY.md",
46
+ "CONTRIBUTING.md"
45
47
  ],
46
48
  "bin": {
47
49
  "madre": "./bin/madre.mjs",
package/public/app.js CHANGED
@@ -78,6 +78,7 @@ const state = {
78
78
  sessions: {}, // id -> { state, detail } from the last probe
79
79
  loginLogs: new Map(), // id -> streamed sign-in lines
80
80
  models: {}, // id -> { models, default, note } from /api/models
81
+ reports: new Map(), // id -> sentinel report (unknown conditions and crashes)
81
82
  capabilities: {}, // id -> { read, imageIn, write, imageGen, web }
82
83
  pending: [], // attachments uploaded for the next message
83
84
  projectRoot: '',
@@ -954,6 +955,7 @@ function renderAssistantMessage(event) {
954
955
  bubble.append(renderMarkdown(text));
955
956
  if (originalText) bubble.append(ashOriginal(originalText, ashCode));
956
957
  if (event.payload.artifacts?.length) bubble.append(artifactTiles(event.payload.artifacts));
958
+ bubble.append(bubbleActions({ text: originalText ?? text, sender, sequence: event.sequence }));
957
959
  col.append(bubble);
958
960
  const stamp = el('div', 'stamp');
959
961
  stamp.id = `usage-${messageId}`;
@@ -964,6 +966,81 @@ function renderAssistantMessage(event) {
964
966
  return node;
965
967
  }
966
968
 
969
+ // Two quiet icons at the end of a reply: copy it, or answer it through the agent you choose.
970
+ const ICON_COPY = '<svg viewBox="0 0 16 16" aria-hidden="true"><rect x="5.5" y="5.5" width="8" height="8" rx="1.6" fill="none" stroke="currentColor" stroke-width="1.3"/><path d="M10.5 5.5V3.9A1.4 1.4 0 0 0 9.1 2.5H3.9A1.4 1.4 0 0 0 2.5 3.9v5.2a1.4 1.4 0 0 0 1.4 1.4h1.6" fill="none" stroke="currentColor" stroke-width="1.3"/></svg>';
971
+ const ICON_DONE = '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M3 8.5l3.2 3L13 4.5" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>';
972
+ const ICON_REPLY = '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M6.5 3.5 2.5 7.5l4 4" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/><path d="M2.8 7.5h5.7a4.5 4.5 0 0 1 4.5 4.5v.5" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>';
973
+ function iconButton(svg, title, className) {
974
+ const button = el('button', `act ${className}`);
975
+ button.type = 'button';
976
+ button.title = title;
977
+ button.setAttribute('aria-label', title);
978
+ button.innerHTML = svg;
979
+ return button;
980
+ }
981
+ function bubbleActions({ text, sender, sequence }) {
982
+ const bar = el('div', 'bubble-actions');
983
+ const copy = iconButton(ICON_COPY, 'Copy this reply', 'copy');
984
+ copy.addEventListener('click', async (event) => {
985
+ event.stopPropagation();
986
+ try {
987
+ await navigator.clipboard.writeText(text);
988
+ copy.innerHTML = ICON_DONE;
989
+ copy.classList.add('done');
990
+ setTimeout(() => { copy.innerHTML = ICON_COPY; copy.classList.remove('done'); }, 1400);
991
+ } catch {
992
+ toast('MU/TH/UR › the clipboard is not available here; select the text and copy.');
993
+ }
994
+ });
995
+ const reply = iconButton(ICON_REPLY, 'Reply to this through an agent', 'reply');
996
+ reply.addEventListener('click', (event) => {
997
+ event.stopPropagation();
998
+ const rect = reply.getBoundingClientRect();
999
+ showReplyMenu({ text, sender, sequence }, rect.left, rect.bottom + 6);
1000
+ });
1001
+ bar.append(copy, reply);
1002
+ return bar;
1003
+ }
1004
+
1005
+ // "Reply with @agent": the quoted reply becomes the head of the message, the chosen
1006
+ // agent becomes the target, and the human writes the directive under it.
1007
+ const replyMenu = el('div', 'model-menu reply-menu');
1008
+ replyMenu.hidden = true;
1009
+ document.body?.append?.(replyMenu);
1010
+ function hideReplyMenu() { replyMenu.hidden = true; }
1011
+ function showReplyMenu(source, x, y) {
1012
+ if (!replyMenu.hidden && replyMenu.dataset.for === `${source.sequence}`) { hideReplyMenu(); return; }
1013
+ replyMenu.dataset.for = `${source.sequence}`;
1014
+ replyMenu.replaceChildren(el('div', 'model-menu-title', `REPLY TO @${source.sender.toUpperCase()}${source.sequence ? ` · #${source.sequence}` : ''} WITH`));
1015
+ for (const agent of state.agents.values()) {
1016
+ const item = paint(el('button', `item${agent.ready ? '' : ' off'}`), agent.id);
1017
+ item.type = 'button';
1018
+ item.append(el('b', null, `@${agent.id}`), el('span', null, agent.ready ? (agent.id === source.sender ? 'the same agent' : label(agent.id)) : `${label(agent.id)} · not ready`));
1019
+ item.disabled = !agent.ready;
1020
+ item.addEventListener('click', () => replyWith(agent.id, source));
1021
+ replyMenu.append(item);
1022
+ }
1023
+ replyMenu.hidden = false;
1024
+ const width = replyMenu.offsetWidth || 260;
1025
+ const height = replyMenu.offsetHeight || 40 + 36 * state.agents.size;
1026
+ replyMenu.style.left = `${Math.max(12, Math.min(x, window.innerWidth - width - 12))}px`;
1027
+ replyMenu.style.top = `${Math.min(y, window.innerHeight - height - 12)}px`;
1028
+ }
1029
+ function replyWith(agentId, source) {
1030
+ hideReplyMenu();
1031
+ const excerpt = source.text.replace(/\s+/g, ' ').trim();
1032
+ const quoted = excerpt.length > 220 ? `${excerpt.slice(0, 219)}…` : excerpt;
1033
+ const head = `↩ @${source.sender}${source.sequence ? ` #${source.sequence}` : ''}: “${quoted}”\n`;
1034
+ const current = els.input.value.replace(/^↩ @[^\n]*\n/, '');
1035
+ els.input.value = `${head}${current}`;
1036
+ if (state.agents.get(agentId)?.ready) { els.target.value = agentId; renderPicker(); }
1037
+ autosize();
1038
+ els.input.focus();
1039
+ els.input.setSelectionRange(els.input.value.length, els.input.value.length);
1040
+ }
1041
+ document.addEventListener('click', (event) => { if (!replyMenu.hidden && !replyMenu.contains(event.target) && !event.target.closest?.('.bubble-actions .reply')) hideReplyMenu(); });
1042
+ document.addEventListener('keydown', (event) => { if (event.key === 'Escape') hideReplyMenu(); });
1043
+
967
1044
  function ashOriginal(originalText, info) {
968
1045
  const details = el('details', 'ash-original');
969
1046
  const saved = Math.max(0, (info?.originalChars ?? originalText.length) - (info?.encodedChars ?? originalText.length));
@@ -1071,13 +1148,17 @@ function renderThinking(event) {
1071
1148
 
1072
1149
  // The archivist reports: which agent read which stretch of the room and how many notes it kept.
1073
1150
  function renderDistilled(event) {
1074
- const { agent, added, considered, fromSequence, throughSequence, remaining, error, skipped, total } = event.payload;
1151
+ const { agent, added, considered, fromSequence, throughSequence, remaining, error, skipped, total, next } = event.payload;
1075
1152
  const node = el('div', `system memory${error ? ' warn' : ''}`);
1076
1153
  node.style.setProperty('--agent', agentColor(agent));
1077
1154
  node.append('memory · ');
1078
1155
  node.append(el('b', 'who', `@${agent}`));
1079
1156
  if (error) {
1080
- node.append(` could not distil #${fromSequence}–#${throughSequence}: ${error}${skipped ? ' · batch skipped' : ' · will retry'}`);
1157
+ // One calm sentence; the whole record waits in the tooltip.
1158
+ const first = String(error).split(/\s+last output:|\s+stderr:/i)[0].replace(/\s+/g, ' ').trim();
1159
+ const brief = el('span', 'brief', first.length > 120 ? `${first.slice(0, 119)}…` : first);
1160
+ brief.title = String(error).slice(0, 2000);
1161
+ node.append(` could not distil #${fromSequence}–#${throughSequence}: `, brief, skipped ? ' · batch skipped' : next ? ` · @${next} takes the next run` : ' · will retry');
1081
1162
  } else {
1082
1163
  node.append(` read ${considered} exchange${considered === 1 ? '' : 's'} (#${fromSequence}–#${throughSequence}) · kept ${added} memor${added === 1 ? 'y' : 'ies'}${Number.isFinite(total) ? ` · ${total} in the archive` : ''}${remaining ? ` · ${remaining} waiting` : ''}`);
1083
1164
  }
@@ -1593,6 +1674,8 @@ function renderEventNode(event) {
1593
1674
  case 'memory.forgotten': node = renderForgotten(event); break;
1594
1675
  case 'memory.noted': attachMemoryHint(event); return;
1595
1676
  case 'mother.alert': node = renderMotherAlert(event); break;
1677
+ case 'sentinel.report': state.reports.set(event.payload.id, { ...event.payload }); if (!replaying) { renderMotherSentinel(); toast(`MU/TH/UR › ${event.payload.kind === 'crash' ? 'a crash' : 'an unknown condition'} was recorded by the sentinel. Open MU/TH/UR to report it.`); } return;
1678
+ case 'sentinel.sent': { const report = state.reports.get(event.payload.id); if (report) report.sent = { ok: event.payload.ok, status: event.payload.status ?? null, error: event.payload.error ?? null, at: event.timestamp }; if (!replaying) renderMotherSentinel(); return; }
1596
1679
  case 'limit.warning': node = renderWarning(event); break;
1597
1680
  case 'limit.cleared': node = renderCleared(event); break;
1598
1681
  case 'usage.recorded': applyUsage(event); return;
@@ -1655,6 +1738,8 @@ function applyTheme(mode) {
1655
1738
  const rootElement = document.documentElement ?? { dataset: {} };
1656
1739
  if (mode === 'auto') delete rootElement.dataset.theme;
1657
1740
  else rootElement.dataset.theme = mode;
1741
+ const systemLight = typeof window.matchMedia === 'function' && window.matchMedia('(prefers-color-scheme: light)').matches;
1742
+ rootElement.dataset.scheme = mode === 'auto' ? (systemLight ? 'light' : 'dark') : mode;
1658
1743
  if (themeButton) {
1659
1744
  themeButton.dataset.theme = mode;
1660
1745
  themeButton.title = mode === 'auto' ? 'Theme · auto (follows the system)' : mode === 'light' ? 'Theme · light' : 'Theme · dark';
@@ -1858,7 +1943,7 @@ renderPicker();
1858
1943
  renderOnboarding();
1859
1944
  for (const event of initial.events) renderEvent(event);
1860
1945
  scrollToEnd();
1861
- matchMedia('(prefers-color-scheme: light)').addEventListener('change', () => { renderAgents(); renderPicker(); });
1946
+ matchMedia('(prefers-color-scheme: light)').addEventListener('change', () => { applyTheme(themeMode); renderAgents(); renderPicker(); });
1862
1947
 
1863
1948
  /* ---------- live stream ---------- */
1864
1949
 
@@ -3908,7 +3993,10 @@ function motherAlarm() {
3908
3993
  const alert = document.querySelector('#nostromo-alert');
3909
3994
  if (!alert) return;
3910
3995
  const pool = nostromo.altered ? [...MOTHER_ALTERED_LINES, ...MOTHER_LINES] : MOTHER_LINES;
3911
- const lines = pool[Math.floor(Math.random() * pool.length)];
3996
+ // Never the same set twice in a row.
3997
+ const choices = pool.filter((set) => set !== nostromo.lastLines);
3998
+ const lines = choices[Math.floor(Math.random() * choices.length)];
3999
+ nostromo.lastLines = lines;
3912
4000
  const nodes = alert.querySelectorAll('.line');
3913
4001
  nodes.forEach((node, index) => { node.textContent = lines[index] ?? ''; });
3914
4002
  const left = (nostromo.maxStrikes ?? 8) - strikes.count;
@@ -4007,12 +4095,97 @@ document.querySelector('#nostromo-forget')?.addEventListener('click', async (eve
4007
4095
  }
4008
4096
  });
4009
4097
 
4098
+
4099
+ /* ---------- MU/TH/UR: the sentinel. Unknown conditions and crashes, redacted, ready to report. ---------- */
4100
+
4101
+ const sentinelUI = { section: document.querySelector('#mother-sentinel'), settings: null, feedbackUrl: null, loaded: false };
4102
+ async function loadSentinel() {
4103
+ try {
4104
+ const data = await fetch('/api/sentinel').then((response) => response.json());
4105
+ sentinelUI.settings = data.settings;
4106
+ sentinelUI.feedbackUrl = data.feedbackUrl;
4107
+ for (const report of data.reports ?? []) state.reports.set(report.id, report);
4108
+ sentinelUI.loaded = true;
4109
+ } catch { /* the room works without it */ }
4110
+ renderMotherSentinel();
4111
+ }
4112
+ function renderMotherSentinel() {
4113
+ const section = sentinelUI.section;
4114
+ if (!section) return;
4115
+ section.replaceChildren();
4116
+ const reports = [...state.reports.values()].sort((a, b) => (a.at < b.at ? 1 : -1));
4117
+ const unsent = reports.filter((report) => !report.sent?.ok).length;
4118
+ section.append(el('h3', null, `SENTINEL · ${reports.length ? `${reports.length} REPORT${reports.length === 1 ? '' : 'S'} · ${unsent} NOT SENT` : 'NOTHING TO REPORT'}`));
4119
+ const settings = sentinelUI.settings ?? { autoReport: false, canSend: false, repo: null };
4120
+ const what = el('p', 'note', 'THE SENTINEL KEEPS FAILURES MU/TH/UR CANNOT EXPLAIN, AND CRASHES, WITH PATHS, NAMES AND KEYS REMOVED. NOTHING LEAVES THIS MACHINE UNLESS YOU SEND IT: BY HAND AS A GITHUB ISSUE YOU READ FIRST, OR AUTOMATICALLY TO THE AUTHOR\'S COLLECTOR IF YOU SWITCH THAT ON.');
4121
+ section.append(what);
4122
+ const controls = el('div', 'sentinel-controls');
4123
+ const auto = el('label', 'toggle');
4124
+ const box = el('input'); box.type = 'checkbox'; box.checked = Boolean(settings.autoReport); box.disabled = !settings.canSend;
4125
+ box.addEventListener('change', async () => {
4126
+ box.disabled = true;
4127
+ try {
4128
+ const result = await fetch('/api/sentinel/settings', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ autoReport: box.checked }) }).then((response) => response.json());
4129
+ sentinelUI.settings = result.settings;
4130
+ toast(`MU/TH/UR › auto-report ${result.settings.autoReport ? 'on: new unknown conditions go to the author\'s collector, redacted.' : 'off: reports stay here until you send one.'}`);
4131
+ } catch (error) { box.checked = !box.checked; toast(`Could not save: ${error.message}`); }
4132
+ finally { box.disabled = !sentinelUI.settings?.canSend; renderMotherSentinel(); }
4133
+ });
4134
+ auto.append(box, `AUTO-REPORT UNKNOWN CONDITIONS${settings.canSend ? '' : ' · NO COLLECTOR CONFIGURED (PULSE_REPORT_URL)'}`);
4135
+ controls.append(auto);
4136
+ const feedback = el('button', null, '✎ FEEDBACK TO THE AUTHOR');
4137
+ feedback.type = 'button';
4138
+ feedback.addEventListener('click', () => openFeedback());
4139
+ controls.append(feedback);
4140
+ section.append(controls);
4141
+ for (const report of reports.slice(0, 20)) {
4142
+ const rowNode = paint(el('div', `mother-record sentinel-report${report.sent?.ok ? ' sent' : ''}`), report.agent ?? 'room');
4143
+ rowNode.append(el('span', 't', formatTime(report.at)));
4144
+ rowNode.append(el('span', 'a', report.kind === 'crash' ? 'CRASH' : `@${report.agent ?? 'room'}`));
4145
+ const errorNode = el('span', 'e', String(report.error).split('\n')[0].slice(0, 200));
4146
+ errorNode.title = report.error;
4147
+ rowNode.append(errorNode);
4148
+ const actions = el('span', 'k');
4149
+ if (report.count > 1) actions.append(el('span', 'none', `×${report.count}`));
4150
+ actions.append(el('span', 'none', report.fingerprint));
4151
+ if (report.sent?.ok) actions.append(el('span', 'none', 'SENT'));
4152
+ const issue = el('button', null, 'REPORT ON GITHUB ↗');
4153
+ issue.type = 'button';
4154
+ issue.addEventListener('click', async () => {
4155
+ const result = await fetch(`/api/sentinel/${report.id}/issue`).then((response) => response.json()).catch(() => ({}));
4156
+ if (result.url) window.open(result.url, '_blank', 'noopener'); else toast('MU/TH/UR › no repository to file this in.');
4157
+ });
4158
+ actions.append(issue);
4159
+ if (settings.canSend && !report.sent?.ok) {
4160
+ const send = el('button', null, 'SEND');
4161
+ send.type = 'button';
4162
+ send.addEventListener('click', async () => {
4163
+ send.disabled = true;
4164
+ const result = await fetch(`/api/sentinel/${report.id}/send`, { method: 'POST' }).then((response) => response.json()).catch((error) => ({ ok: false, error: error.message }));
4165
+ toast(result.ok ? 'MU/TH/UR › report sent to the author\'s collector.' : `MU/TH/UR › could not send: ${result.error ?? 'unknown error'}`);
4166
+ void loadSentinel();
4167
+ });
4168
+ actions.append(send);
4169
+ }
4170
+ rowNode.append(actions);
4171
+ section.append(rowNode);
4172
+ }
4173
+ }
4174
+ function openFeedback() {
4175
+ const go = (url) => { if (url) window.open(url, '_blank', 'noopener'); else toast('MU/TH/UR › no repository configured for feedback.'); };
4176
+ if (sentinelUI.feedbackUrl) { go(sentinelUI.feedbackUrl); return; }
4177
+ fetch('/api/sentinel').then((response) => response.json()).then((data) => { sentinelUI.feedbackUrl = data.feedbackUrl; go(data.feedbackUrl); }).catch(() => go(null));
4178
+ }
4179
+ document.querySelector('#feedback-button')?.addEventListener('click', openFeedback);
4180
+ mother.dialog?.addEventListener?.('close', () => { /* keep reports; nothing to reset */ });
4181
+ void loadSentinel();
4182
+
4010
4183
  settingsUI.button.addEventListener('click', async () => {
4011
4184
  settingsUI.open = !settingsUI.open;
4012
4185
  state.settingsOpen = settingsUI.open;
4013
4186
  settingsUI.button.setAttribute('aria-pressed', String(settingsUI.open));
4014
4187
  settingsUI.section.hidden = !settingsUI.open;
4015
- for (const id of ['mother-boot', 'mother-query', 'mother-answer', 'mother-recorded', 'mother-known']) {
4188
+ for (const id of ['mother-boot', 'mother-query', 'mother-answer', 'mother-recorded', 'mother-known', 'mother-sentinel']) {
4016
4189
  const node = document.getElementById(id);
4017
4190
  if (node) node.hidden = settingsUI.open;
4018
4191
  }
@@ -4024,7 +4197,7 @@ mother.dialog.addEventListener('close', () => {
4024
4197
  state.settingsOpen = false;
4025
4198
  settingsUI.button.setAttribute('aria-pressed', 'false');
4026
4199
  settingsUI.section.hidden = true;
4027
- for (const id of ['mother-boot', 'mother-query', 'mother-answer', 'mother-recorded', 'mother-known']) {
4200
+ for (const id of ['mother-boot', 'mother-query', 'mother-answer', 'mother-recorded', 'mother-known', 'mother-sentinel']) {
4028
4201
  const node = document.getElementById(id);
4029
4202
  if (node) node.hidden = false;
4030
4203
  }
package/public/index.html CHANGED
@@ -70,10 +70,12 @@
70
70
  <span class="mother-actions">
71
71
  <button id="mother-settings-button" class="mother-close" type="button" aria-pressed="false" title="Connections and room settings">⚙ CONNECTIONS</button>
72
72
  <button id="nostromo-button" class="mother-close nostromo-button" type="button" title="NOSTROMO · memory research · needs the project designation">◉ NOSTROMO</button>
73
+ <button id="feedback-button" class="mother-close" type="button" title="Tell MADRE's author what happened or what you wish for · opens a prefilled issue">✎ FEEDBACK</button>
73
74
  <button id="mother-close" class="mother-close" type="button">END SESSION ×</button>
74
75
  </span>
75
76
  </header>
76
77
  <section id="mother-settings" class="mother-section settings" hidden></section>
78
+ <section id="mother-sentinel" class="mother-section sentinel"></section>
77
79
  <pre id="mother-boot" class="mother-boot" aria-live="polite"></pre>
78
80
  <form id="mother-query" class="mother-query" autocomplete="off">
79
81
  <span class="mother-prompt">›</span>
package/public/styles.css CHANGED
@@ -268,6 +268,7 @@ code { font-family: var(--mono); font-size: .9em; color: var(--phosphor); backgr
268
268
  .system .from { color: var(--from, var(--text-2)); } .system .to { color: var(--to, var(--text-2)); }
269
269
  .system.warn { color: var(--warn); }
270
270
  .system.memory { color: var(--text-3); } .system.memory .who { color: var(--agent, var(--text-2)); }
271
+ .system.memory .brief { text-transform: none; letter-spacing: 0; color: var(--text-2); }
271
272
 
272
273
  /* NOSTROMO: the archive as a small solar system. One colour per kind of memory. */
273
274
  :root { --mem-decision: #ffb000; --mem-fact: #9bff66; --mem-preference: #d78cff; --mem-question: #5fd6ff; --nostromo-sun: #ff2a1f; }
@@ -974,3 +975,47 @@ dialog.viewer::backdrop { background: rgba(0,0,0,.7); backdrop-filter: blur(4px)
974
975
  .command-card pre .h { color: var(--phosphor); font-weight: 600; }
975
976
 
976
977
  .module-card select { border: 1px solid var(--ph-dim); background: #000; color: var(--ph); font: inherit; font-size: 10.5px; letter-spacing: .06em; padding: 6px 8px; }
978
+
979
+ /* Copy and reply at the end of every reply: faint until the pointer is over the bubble. */
980
+ .bubble-actions { display: flex; justify-content: flex-end; gap: 2px; margin: 6px -8px -6px 0; opacity: 0; transition: opacity .18s ease; }
981
+ .bubble:hover .bubble-actions, .bubble:focus-within .bubble-actions { opacity: 1; }
982
+ @media (hover: none) { .bubble-actions { opacity: .55; } }
983
+ .bubble-actions .act { width: 26px; height: 26px; display: grid; place-items: center; border: 0; border-radius: 8px; background: transparent; color: var(--text-3); cursor: pointer; padding: 0; transition: color .15s, background .15s; }
984
+ .bubble-actions .act svg { width: 15px; height: 15px; }
985
+ .bubble-actions .act:hover { color: var(--text); background: color-mix(in srgb, var(--text) 8%, transparent); }
986
+ .bubble-actions .act.done { color: var(--phosphor); }
987
+ .reply-menu { width: min(280px, calc(100vw - 24px)); padding: 8px; }
988
+ .reply-menu .model-menu-title { margin: 4px 8px 6px; }
989
+ .reply-menu .item { display: flex; gap: 10px; align-items: center; width: 100%; padding: 8px 10px; border-radius: 8px; border: 0; background: transparent; color: var(--text); font: inherit; font-size: 13px; cursor: pointer; text-align: left; }
990
+ .reply-menu .item b { color: var(--agent, var(--text)); font-family: var(--mono); font-size: 11px; letter-spacing: .06em; }
991
+ .reply-menu .item span { color: var(--text-3); font-size: 12px; }
992
+ .reply-menu .item:hover { background: color-mix(in srgb, var(--agent, var(--phosphor)) 12%, transparent); }
993
+ .reply-menu .item:disabled { opacity: .4; cursor: default; }
994
+
995
+ /* Light scheme: the CRT glow is a dark-room effect. In daylight, the room keeps its colours and loses the shine;
996
+ MU/TH/UR's and NOSTROMO's own screens stay as they are. */
997
+ :root[data-scheme="light"] :not(.mother-frame, .mother-frame *, .nostromo-frame, .nostromo-frame *, .override-frame, .override-frame *) { text-shadow: none; }
998
+ :root[data-scheme="light"] .brand-mark, :root[data-scheme="light"] .brand:hover .brand-mark, :root[data-scheme="light"] .empty-mark { filter: none; }
999
+ :root[data-scheme="light"] .field button[type="submit"] { box-shadow: 0 2px 8px rgba(0, 0, 0, .14); }
1000
+ :root[data-scheme="light"] .user .bubble { box-shadow: 0 3px 12px rgba(0, 0, 0, .08); }
1001
+ :root[data-scheme="light"] .composer.control .field { box-shadow: 0 0 0 2px color-mix(in srgb, var(--terror) 30%, transparent); }
1002
+ :root[data-scheme="light"] .composer.creating .field, :root[data-scheme="light"] .composer.creating.ordering .field { box-shadow: 0 0 0 2px color-mix(in srgb, var(--mode-2) 30%, transparent); }
1003
+ :root[data-scheme="light"] .composer.ordering .field { box-shadow: 0 0 0 2px color-mix(in srgb, var(--brew) 22%, transparent); }
1004
+ :root[data-scheme="light"] .composer.dropping .field { box-shadow: 0 0 0 2px color-mix(in srgb, var(--phosphor) 35%, transparent); }
1005
+ :root[data-scheme="light"] .memory-hint .pill { animation: none; box-shadow: none; }
1006
+ :root[data-scheme="light"] .mode-menu .mode-option .tag .dot { box-shadow: none; }
1007
+ :root[data-scheme="light"] .picker .mode-chip, :root[data-scheme="light"] .who .badge { text-shadow: none; box-shadow: none; }
1008
+ :root[data-scheme="light"] .stop-all.armed { box-shadow: none; }
1009
+ :root[data-scheme="light"] .system.alert, :root[data-scheme="light"] .system.control, :root[data-scheme="light"] .system.mother, :root[data-scheme="light"] .system.mother-alert { text-shadow: none; }
1010
+ :root[data-scheme="light"] .system.mother-alert { background: color-mix(in srgb, var(--terror) 4%, transparent); }
1011
+ :root[data-scheme="light"] .crew-label { text-shadow: none; animation: none; }
1012
+
1013
+ /* MU/TH/UR: the sentinel section. */
1014
+ .mother-section.sentinel .sentinel-controls { display: flex; flex-wrap: wrap; align-items: center; gap: 14px; margin: 6px 0 12px; }
1015
+ .mother-section.sentinel .toggle { display: flex; align-items: center; gap: 8px; font-size: 10px; letter-spacing: .16em; color: var(--ph-dim); cursor: pointer; }
1016
+ .mother-section.sentinel .toggle input { accent-color: #9bff66; }
1017
+ .mother-section.sentinel .toggle input:disabled + * , .mother-section.sentinel .toggle:has(input:disabled) { opacity: .6; cursor: default; }
1018
+ .mother-section.sentinel button { border: 1px solid var(--ph-dim); background: transparent; color: var(--ph); font: inherit; font-size: 9.5px; letter-spacing: .16em; padding: 6px 10px; cursor: pointer; }
1019
+ .mother-section.sentinel button:hover { background: var(--ph-faint); }
1020
+ .mother-section.sentinel .sentinel-report.sent { opacity: .65; }
1021
+ .mother-section.sentinel .note { margin: 0 0 10px; font-size: 10px; letter-spacing: .12em; line-height: 1.7; color: var(--ph-dim); }
package/src/distiller.mjs CHANGED
@@ -12,8 +12,9 @@ export const MAX_MEMORY_CHARS = 240;
12
12
 
13
13
  // The agent that pays for distillation: the human's pick if it is usable, else
14
14
  // the cheapest ready one that has an adapter and no turn in flight.
15
- export function pickDistiller(agents, { preferred = null, busy = new Set(), invokers = null } = {}) {
16
- const usable = (agent) => agent?.detected && agent.ready && !busy.has(agent.id) && (!invokers || Boolean(invokers[agent.adapter]));
15
+ // `benched` holds agents that failed recently: they sit out until their bench time passes.
16
+ export function pickDistiller(agents, { preferred = null, busy = new Set(), invokers = null, benched = new Set() } = {}) {
17
+ const usable = (agent) => agent?.detected && agent.ready && !busy.has(agent.id) && !benched.has(agent.id) && (!invokers || Boolean(invokers[agent.adapter]));
17
18
  if (preferred) {
18
19
  const chosen = agents.find((agent) => agent.id === preferred);
19
20
  if (usable(chosen)) return chosen;
package/src/room.mjs CHANGED
@@ -74,6 +74,7 @@ export class Room {
74
74
  #distillTimer = null;
75
75
  #distilling = null; // the run in flight, if any
76
76
  #distillFailures = new Map(); // fromSequence -> failed attempts on that batch
77
+ #distillBench = new Map(); // agent -> until (ms): an archivist that failed sits out for a while
77
78
  #memoryServer; // MCP descriptor handed to every turn so the agent can query the memory itself
78
79
  #mother = null; // MotherChannel: her coded words to the crew
79
80
  #embedTimer = null;
@@ -212,8 +213,9 @@ export class Room {
212
213
  const payload = { requestId, planId, agent: step.agent, orchestrator, mode, step: index + 1, totalSteps, text: step.text, expiresAt, message: `@${step.agent} needs #${mode} ${MODES[mode].label} for step ${index + 1}: the plan runs at #1. Grant it once, for the whole plan, or deny.` };
213
214
  await this.#emit('mode.requested', payload);
214
215
  const decision = await new Promise((resolve) => {
216
+ // The clock keeps the process alive on purpose: a plan is waiting for the
217
+ // human. Shutdown settles every pending request, so nothing can hang.
215
218
  const timer = setTimeout(() => resolve({ decision: 'deny', reason: 'timeout' }), this.#escalationMs);
216
- timer.unref?.();
217
219
  this.#modeRequests.set(requestId, { payload, timer, resolve, plan });
218
220
  });
219
221
  const entry = this.#modeRequests.get(requestId);
@@ -252,6 +254,7 @@ export class Room {
252
254
  #resolvePendingFor(planId, reason) {
253
255
  for (const [requestId, entry] of this.#modeRequests) {
254
256
  if (planId && entry.payload.planId !== planId) continue;
257
+ clearTimeout(entry.timer);
255
258
  entry.resolve({ decision: 'deny', reason });
256
259
  this.#modeRequests.delete(requestId);
257
260
  }
@@ -428,7 +431,12 @@ export class Room {
428
431
  const batch = this.#memory.undistilled({ maxChars: this.#distill.maxChars });
429
432
  if (!batch.entries.length) return null;
430
433
  const busy = new Set([...this.#turns.values()].map((turn) => turn.agent).filter(Boolean));
431
- const agent = pickDistiller(this.#agents, { preferred: this.#distill.agent, busy, invokers: this.#invokers });
434
+ const now = Date.now();
435
+ for (const [id, until] of this.#distillBench) if (until <= now) this.#distillBench.delete(id);
436
+ const benched = new Set(this.#distillBench.keys());
437
+ // Everyone benched? Then the bench is cleared rather than leaving the archive to rot.
438
+ let agent = pickDistiller(this.#agents, { preferred: this.#distill.agent, busy, invokers: this.#invokers, benched });
439
+ if (!agent && benched.size) { this.#distillBench.clear(); agent = pickDistiller(this.#agents, { preferred: this.#distill.agent, busy, invokers: this.#invokers }); }
432
440
  if (!agent) return null;
433
441
  const started = Date.now();
434
442
  try {
@@ -449,7 +457,10 @@ export class Room {
449
457
  this.#distillFailures.set(batch.fromSequence, attempts);
450
458
  const skipped = attempts >= 3;
451
459
  if (skipped) { this.#memory.markDistilled(batch.sequences); this.#distillFailures.delete(batch.fromSequence); }
452
- const report = { agent: agent.id, error: failureMessage(error), attempts, skipped, fromSequence: batch.fromSequence, throughSequence: batch.throughSequence, considered: batch.entries.length, remaining: batch.remaining };
460
+ // The archivist that failed sits out for half an hour; the next run picks someone else.
461
+ this.#distillBench.set(agent.id, Date.now() + 30 * 60 * 1000);
462
+ const next = pickDistiller(this.#agents, { preferred: this.#distill.agent, busy: new Set(), invokers: this.#invokers, benched: new Set(this.#distillBench.keys()) });
463
+ const report = { agent: agent.id, error: failureMessage(error), attempts, skipped, next: next?.id ?? null, fromSequence: batch.fromSequence, throughSequence: batch.throughSequence, considered: batch.entries.length, remaining: batch.remaining };
453
464
  await this.#emit('memory.distilled', report);
454
465
  return report;
455
466
  }
@@ -655,6 +666,7 @@ export class Room {
655
666
  clearTimeout(this.#embedTimer);
656
667
  this.#embedTimer = null;
657
668
  this.#shuttingDown = true;
669
+ this.#resolvePendingFor(null, 'stopped');
658
670
  for (const plan of this.#plans.values()) plan.stopped = 'MADRE is shutting down';
659
671
  for (const { controller } of this.#turns.values()) controller.abort('MADRE is shutting down');
660
672
  await Promise.allSettled([...this.#turns.values()].map((turn) => turn.promise));
@@ -0,0 +1,185 @@
1
+ // The error sentinel: what MADRE tells its author when something goes wrong
2
+ // that MU/TH/UR cannot explain. A failure that matches no known condition, or a
3
+ // crash of the process, becomes a report: the error redacted (no home paths,
4
+ // no user names, no keys, no emails), the versions involved, the platform.
5
+ // Reports stay in the room ledger (sentinel.report) and can be sent two ways:
6
+ // by hand as a prefilled GitHub issue, or automatically to a collector the
7
+ // author runs, only when the human switched AUTO-REPORT on. Off by default.
8
+ // Nothing leaves the machine otherwise.
9
+
10
+ import { createHash } from 'node:crypto';
11
+ import { homedir, platform, arch, release } from 'node:os';
12
+ import { diagnose } from '../public/troubleshooting.js';
13
+
14
+ export const REPORT_WINDOW_MS = 24 * 3600 * 1000;
15
+ const MAX_ERROR_CHARS = 4000;
16
+
17
+ const SECRET_PATTERNS = [
18
+ [/\b(sk|rk|pk)-[A-Za-z0-9_-]{16,}\b/g, '[key]'],
19
+ [/\bAIza[0-9A-Za-z_-]{20,}\b/g, '[key]'],
20
+ [/\b(ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}\b/g, '[token]'],
21
+ [/\bgithub_pat_[A-Za-z0-9_]{20,}\b/g, '[token]'],
22
+ [/\bnpm_[A-Za-z0-9]{20,}\b/g, '[token]'],
23
+ [/\bxox[abprs]-[A-Za-z0-9-]{10,}\b/g, '[token]'],
24
+ [/\b[Bb]earer\s+[A-Za-z0-9._~+/=-]{12,}/g, 'Bearer [token]'],
25
+ [/\b[A-Fa-f0-9]{32,}\b/g, '[hex]'],
26
+ [/\b[A-Za-z0-9+/]{40,}={0,2}\b/g, '[blob]'],
27
+ [/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g, '[email]'],
28
+ [/(\?)[^\s"')]+/g, '$1…'],
29
+ ];
30
+
31
+ // The error as the author may read it: paths and identities out, shape intact.
32
+ export function redact(text, { home = homedir(), user = process.env.USER ?? process.env.USERNAME ?? '' } = {}) {
33
+ let out = String(text ?? '');
34
+ // Secrets and emails first, while they still have their shape; then places and names.
35
+ for (const [pattern, replacement] of SECRET_PATTERNS) out = out.replace(pattern, replacement);
36
+ if (home) out = out.split(home).join('~');
37
+ out = out.replace(/\/(Users|home)\/[^/\s"']+/g, '/$1/…');
38
+ out = out.replace(/[A-Za-z]:\\Users\\[^\\\s"']+/g, 'C:\\Users\\…');
39
+ if (user && user.length > 2) out = out.split(user).join('…');
40
+ return out.length > MAX_ERROR_CHARS ? `${out.slice(0, MAX_ERROR_CHARS - 1)}…` : out;
41
+ }
42
+
43
+ // Same failure, same fingerprint: numbers, paths and ids do not count.
44
+ export function fingerprint(agent, error) {
45
+ const shape = String(error ?? '').toLowerCase().replace(/~?\/[^\s"']+/g, 'P').replace(/[0-9a-f]{8,}/g, 'H').replace(/\d+/g, '#').replace(/\s+/g, ' ').trim().slice(0, 600);
46
+ return createHash('sha1').update(`${agent ?? 'room'}|${shape}`).digest('hex').slice(0, 12);
47
+ }
48
+
49
+ export function repoFromPackage(pkg) {
50
+ const url = typeof pkg?.repository === 'string' ? pkg.repository : pkg?.repository?.url ?? '';
51
+ const match = url.match(/github\.com[/:]([^/]+\/[^/.]+)/);
52
+ return match ? match[1] : null;
53
+ }
54
+
55
+ export class ErrorSentinel {
56
+ #reports = [];
57
+ #seen = new Map(); // fingerprint -> last report time
58
+ #pkg;
59
+ #agents;
60
+ #settings;
61
+ #fetch;
62
+ #emit;
63
+ #save;
64
+
65
+ constructor({ pkg = {}, agents = [], settings = {}, fetchImpl = globalThis.fetch, emit = async () => {}, save = async () => {} } = {}) {
66
+ this.#pkg = pkg;
67
+ this.#agents = agents;
68
+ this.#settings = { autoReport: Boolean(settings.autoReport), reportUrl: String(settings.reportUrl ?? '').trim() };
69
+ this.#fetch = fetchImpl;
70
+ this.#emit = emit;
71
+ this.#save = save;
72
+ }
73
+
74
+ settings() { return { ...this.#settings, canSend: Boolean(this.#settings.reportUrl), repo: repoFromPackage(this.#pkg) }; }
75
+ async setSettings(patch = {}) {
76
+ if (typeof patch.autoReport === 'boolean') this.#settings.autoReport = patch.autoReport;
77
+ if (typeof patch.reportUrl === 'string') this.#settings.reportUrl = patch.reportUrl.trim();
78
+ await this.#save({ ...this.#settings });
79
+ return this.settings();
80
+ }
81
+ reports() { return [...this.#reports]; }
82
+ report(id) { return this.#reports.find((item) => item.id === id) ?? null; }
83
+
84
+ // Facts about this installation that help a fix and identify nobody.
85
+ environment() {
86
+ return {
87
+ madre: this.#pkg.version ?? 'dev',
88
+ node: process.version,
89
+ platform: `${platform()} ${release()} ${arch()}`,
90
+ agents: this.#agents.filter((agent) => agent.detected).map((agent) => `${agent.id}@${agent.version ?? '?'}`),
91
+ };
92
+ }
93
+
94
+ // A room event goes by: a failure MU/TH/UR cannot classify is worth a report.
95
+ async observe(event) {
96
+ if (event?.type !== 'message.failed') return null;
97
+ const { target, error } = event.payload ?? {};
98
+ if (typeof error !== 'string' || !error.trim()) return null;
99
+ const known = diagnose(error, target ?? null);
100
+ if (known.length) return null;
101
+ return this.record({ kind: 'unknown', agent: target ?? null, error, at: event.timestamp });
102
+ }
103
+
104
+ // The process itself failed somewhere the room did not catch.
105
+ async crash(error, origin = 'uncaughtException') {
106
+ const text = error instanceof Error ? `${error.stack ?? error.message}` : String(error);
107
+ return this.record({ kind: 'crash', agent: null, error: `${origin}: ${text}` });
108
+ }
109
+
110
+ // Reports are seeded from the ledger on start, so a restart forgets nothing.
111
+ seed(events) {
112
+ for (const event of events) {
113
+ if (event.type === 'sentinel.report') { this.#reports.push({ ...event.payload }); this.#seen.set(event.payload.fingerprint, new Date(event.timestamp).getTime()); }
114
+ if (event.type === 'sentinel.sent') { const report = this.report(event.payload.id); if (report) report.sent = { ok: event.payload.ok, status: event.payload.status ?? null, at: event.timestamp, error: event.payload.error ?? null }; }
115
+ }
116
+ this.#reports = this.#reports.slice(-50);
117
+ }
118
+
119
+ async record({ kind, agent, error, at = new Date().toISOString() }) {
120
+ const print = fingerprint(agent, error);
121
+ const last = this.#seen.get(print) ?? 0;
122
+ const now = new Date(at).getTime() || Date.now();
123
+ if (now - last < REPORT_WINDOW_MS) {
124
+ const existing = [...this.#reports].reverse().find((item) => item.fingerprint === print);
125
+ if (existing) { existing.count = (existing.count ?? 1) + 1; existing.lastAt = at; }
126
+ return null;
127
+ }
128
+ this.#seen.set(print, now);
129
+ const report = { id: createHash('sha1').update(`${print}${at}`).digest('hex').slice(0, 10), kind, fingerprint: print, agent, error: redact(error), conditions: [], at, count: 1, ...this.environment(), sent: null };
130
+ this.#reports.push(report);
131
+ if (this.#reports.length > 50) this.#reports.shift();
132
+ await this.#emit('sentinel.report', report);
133
+ if (this.#settings.autoReport && this.#settings.reportUrl) await this.send(report.id).catch(() => null);
134
+ return report;
135
+ }
136
+
137
+ // To the collector the author runs, when there is one and the human allowed it.
138
+ async send(id) {
139
+ const report = this.report(id);
140
+ if (!report) return { ok: false, status: 404, error: 'No such report.' };
141
+ if (!this.#settings.reportUrl) return { ok: false, status: 412, error: 'No report endpoint configured.' };
142
+ let outcome;
143
+ try {
144
+ const response = await this.#fetch(this.#settings.reportUrl, { method: 'POST', headers: { 'content-type': 'application/json', 'user-agent': `madre/${this.#pkg.version ?? 'dev'}` }, body: JSON.stringify({ ...report, sent: undefined }), signal: AbortSignal.timeout(6000) });
145
+ outcome = { ok: response.ok, status: response.status, error: response.ok ? null : `HTTP ${response.status}` };
146
+ } catch (error) {
147
+ outcome = { ok: false, status: null, error: error.message };
148
+ }
149
+ report.sent = { ...outcome, at: new Date().toISOString() };
150
+ await this.#emit('sentinel.sent', { id, ...outcome });
151
+ return outcome;
152
+ }
153
+
154
+ // A prefilled issue, the manual road: opens in the browser, the human reads it before posting.
155
+ issueUrl(id) {
156
+ const repo = repoFromPackage(this.#pkg);
157
+ const report = this.report(id);
158
+ if (!repo || !report) return null;
159
+ const env = this.environment();
160
+ const title = `[sentinel] ${report.kind === 'crash' ? 'crash' : `unknown condition${report.agent ? ` · @${report.agent}` : ''}`} · ${report.fingerprint}`;
161
+ const body = [
162
+ `**MADRE** ${env.madre} · **Node** ${env.node} · **Platform** ${env.platform}`,
163
+ `**Agents** ${env.agents.join(', ') || 'none detected'}`,
164
+ `**Kind** ${report.kind} · **Fingerprint** \`${report.fingerprint}\` · **Seen** ${report.count}× since ${report.at}`,
165
+ '',
166
+ '### What MU/TH/UR recorded',
167
+ '```', report.error.slice(0, 3000), '```',
168
+ '',
169
+ '### What I was doing',
170
+ '_(one or two lines; anything you would rather not share, leave out)_',
171
+ '',
172
+ '_Redacted automatically by MADRE\'s sentinel: no paths, names or keys._',
173
+ ].join('\n');
174
+ return `https://github.com/${repo}/issues/new?${new URLSearchParams({ title, body, labels: 'sentinel' })}`;
175
+ }
176
+
177
+ feedbackUrl({ about = '' } = {}) {
178
+ const repo = repoFromPackage(this.#pkg);
179
+ if (!repo) return null;
180
+ const env = this.environment();
181
+ const title = about ? `[feedback] ${about.slice(0, 80)}` : '[feedback] ';
182
+ const body = ['### What happened, or what you wish MADRE did', '', '', '### Environment', `MADRE ${env.madre} · Node ${env.node} · ${env.platform}`, `Agents: ${env.agents.join(', ') || 'none detected'}`].join('\n');
183
+ return `https://github.com/${repo}/issues/new?${new URLSearchParams({ title, body, labels: 'feedback' })}`;
184
+ }
185
+ }
package/src/server.mjs CHANGED
@@ -11,6 +11,12 @@ import { RoomMemory } from './memory.mjs';
11
11
  import { createEmbedder } from './embeddings.mjs';
12
12
  import { memoryServerFor } from './memory-tools.mjs';
13
13
  import { MotherChannel, CODE000_STRIKES } from './mother.mjs';
14
+ import { ErrorSentinel } from './sentinel-errors.mjs';
15
+
16
+ const PACKAGE = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8').catch(() => '{}'));
17
+ let crashHandlersInstalled = false;
18
+ // The author's collector: SEND and AUTO-REPORT are available out of the box; AUTO-REPORT stays off until the human turns it on.
19
+ const DEFAULT_REPORT_URL = 'https://madre-reports.jossue-alcala-o.workers.dev/v1/reports';
14
20
  import { QuotaMonitor } from './quota-monitor.mjs';
15
21
  import { defaultQuotaSources } from './quota-sources.mjs';
16
22
  import { Room } from './room.mjs';
@@ -89,6 +95,8 @@ export async function createPulseServer({
89
95
  loginRunners = {},
90
96
  probe = probeAll,
91
97
  imageKey = resolveGeminiKey,
98
+ // The sentinel's outbound channel; tests hand in a fake.
99
+ reportFetch = globalThis.fetch,
92
100
  }) {
93
101
  const root = stateRoot ?? process.env.PULSE_HOME ?? join(homedir(), '.pulse');
94
102
  // ~/.pulse/config.json fills in whatever the environment did not set.
@@ -319,6 +327,29 @@ export async function createPulseServer({
319
327
  return inFlight;
320
328
  }
321
329
  const unsubscribe = room.subscribe(() => { void broadcastPending(); });
330
+
331
+ // The error sentinel: failures MU/TH/UR cannot classify, and crashes, become
332
+ // redacted reports in the ledger. Sending them anywhere is the human's call.
333
+ const telemetry = (await readConfig(root)).telemetry ?? {};
334
+ const sentinel = new ErrorSentinel({
335
+ pkg: PACKAGE,
336
+ agents,
337
+ settings: { autoReport: process.env.PULSE_AUTO_REPORT === '1' || Boolean(telemetry.autoReport), reportUrl: process.env.PULSE_REPORT_URL ?? telemetry.reportUrl ?? DEFAULT_REPORT_URL },
338
+ fetchImpl: reportFetch,
339
+ emit: (type, payload) => room.record(type, payload),
340
+ save: async (settings) => { const current = await readConfig(root); await updateConfig(root, { telemetry: { ...(current.telemetry ?? {}), ...settings } }); },
341
+ });
342
+ sentinel.seed(historicalEvents);
343
+ const unsubscribeSentinel = room.subscribe((event) => { void sentinel.observe(event).catch(() => null); });
344
+ if (!testMode && !crashHandlersInstalled) {
345
+ crashHandlersInstalled = true;
346
+ for (const origin of ['uncaughtException', 'unhandledRejection']) {
347
+ process.on(origin, (error) => {
348
+ console.error(`MADRE ${origin}:`, error);
349
+ void sentinel.crash(error, origin).catch(() => null);
350
+ });
351
+ }
352
+ }
322
353
  const unsubscribeGhost = room.subscribeGhost((event) => { for (const client of clients.keys()) writeEvent(client, event); });
323
354
  const poller = setInterval(() => { void broadcastPending(); }, broadcastIntervalMs);
324
355
  poller.unref();
@@ -532,6 +563,23 @@ export async function createPulseServer({
532
563
  }
533
564
  // NOSTROMO: the archive is behind the project designation, like CONTROL.
534
565
  const designationOk = (given) => typeof given === 'string' && given.trim().toLowerCase() === basename(canonicalProjectRoot).toLowerCase();
566
+ // Sentinel: reports, settings, the manual road (a prefilled issue) and the automatic one.
567
+ if (request.method === 'GET' && url.pathname === '/api/sentinel') {
568
+ return sendJson(response, 200, { reports: sentinel.reports(), settings: sentinel.settings(), environment: sentinel.environment(), feedbackUrl: sentinel.feedbackUrl() });
569
+ }
570
+ if (request.method === 'POST' && url.pathname === '/api/sentinel/settings') {
571
+ const patch = await body(request).catch(() => ({}));
572
+ return sendJson(response, 200, { settings: await sentinel.setSettings(patch) });
573
+ }
574
+ const sentinelMatch = url.pathname.match(/^\/api\/sentinel\/([a-f0-9]{10})\/(issue|send)$/);
575
+ if (sentinelMatch && request.method === (sentinelMatch[2] === 'issue' ? 'GET' : 'POST')) {
576
+ if (sentinelMatch[2] === 'issue') {
577
+ const issue = sentinel.issueUrl(sentinelMatch[1]);
578
+ return issue ? sendJson(response, 200, { url: issue }) : sendJson(response, 404, { error: 'No such report, or no repository to file it in.' });
579
+ }
580
+ const outcome = await sentinel.send(sentinelMatch[1]);
581
+ return sendJson(response, outcome.ok ? 200 : (outcome.status === 404 || outcome.status === 412 ? outcome.status : 502), outcome);
582
+ }
535
583
  if (request.method === 'GET' && url.pathname === '/api/mother') {
536
584
  return sendJson(response, 200, { mother: room.motherStatus(), strikes: CODE000_STRIKES });
537
585
  }
@@ -658,6 +706,7 @@ export async function createPulseServer({
658
706
  shutdown.then(() => {
659
707
  void broadcastPending().then(() => {
660
708
  unsubscribe();
709
+ unsubscribeSentinel();
661
710
  unsubscribeGhost();
662
711
  for (const client of clients.keys()) client.end();
663
712
  clients.clear();