@jossuealcala/madre 0.3.0 → 0.3.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/bin/madre.mjs CHANGED
@@ -112,6 +112,13 @@ if (command === 'doctor' && (has('--catalog') || has('--conditions'))) {
112
112
  agents: report,
113
113
  ollama: { running: Boolean(ollama.running), chatModel: ollama.chatModel ?? null, embedModel: ollama.embedModel ?? null, madre: madreOnline(ollama) },
114
114
  };
115
+ const pkg = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
116
+ const { checkForUpdate, detectInstall, updateCommand } = await import('../src/updates.mjs');
117
+ const { join: joinPath } = await import('node:path');
118
+ const { homedir } = await import('node:os');
119
+ const check = await checkForUpdate({ name: pkg.name, current: pkg.version, cacheFile: joinPath(process.env.PULSE_HOME ?? joinPath(homedir(), '.pulse'), 'updates.json'), enabled: process.env.PULSE_UPDATE_CHECK !== '0' });
120
+ const update = { ...check, command: updateCommand(detectInstall({ projectRoot }), pkg.name, check.latest ?? 'latest') };
121
+ result.update = update;
115
122
 
116
123
  if (has('--json')) {
117
124
  console.log(JSON.stringify(result, null, 2));
@@ -123,6 +130,7 @@ if (command === 'doctor' && (has('--catalog') || has('--conditions'))) {
123
130
  console.log(` ${agent.label.padEnd(10)} ${mark}${agent.version ? ` · ${agent.version}` : ''}${session}`);
124
131
  }
125
132
  console.log(` ${'Ollama'.padEnd(10)} ${ollama.disabled ? 'ignored (PULSE_OLLAMA=0)' : ollama.running ? `running${ollama.chatModel ? ` · @madre with ${ollama.chatModel}` : ' · no chat model yet'}${ollama.embedModel ? ` · embeddings ${ollama.embedModel}` : ''}` : 'not running · optional'}`);
133
+ console.log(` ${'MADRE'.padEnd(10)} ${update.current}${update.available ? ` · ${update.latest} available · ${update.command}` : update.latest ? ' · up to date' : update.enabled ? ' · npm not reachable' : ' · update check off (PULSE_UPDATE_CHECK=0)'}`);
126
134
  console.log(`\n Project ${result.project}`);
127
135
  console.log(result.ok ? '\nReady to start. Known conditions and fixes: `madre doctor --catalog [query]`.\n' : '\nNo agent is online. Run `madre setup`. Known conditions and fixes: `madre doctor --catalog`.\n');
128
136
  }
@@ -0,0 +1,75 @@
1
+ # MADRE por dentro
2
+
3
+ Lo que el README no necesita contar para usar la sala, pero sí quien quiera entenderla, operarla en condiciones raras o escribir un módulo. Cada afirmación aquí corresponde a código en `src/` y tiene prueba en `test/`.
4
+
5
+ ## El ledger
6
+
7
+ Cada proyecto tiene una sala estable bajo `PULSE_HOME` (por defecto `~/.pulse/rooms/<sala>/`). Su fuente de verdad es `events.jsonl`: un evento por línea, secuencia creciente, append-only para la sala. MADRE reconstruye la ventana de conversación desde ahí aunque se reinicie o se abra desde otro IDE.
8
+
9
+ Las escrituras se serializan entre procesos locales con un lock en disco, así dos IDEs sobre la misma sala nunca producen secuencias repetidas. La única operación que reescribe el ledger es el purgado de privacidad, bajo el mismo lock, atómica (archivo temporal y rename), conservando secuencias.
10
+
11
+ Cuando cambia el agente que responde, la sala registra `handoff.created` con origen, destino y el rango de secuencias entregado. El transcript sigue siendo la fuente durable; el proyecto no se toca.
12
+
13
+ Los errores de un agente se guardan acotados en `message.failed`: una línea de hasta 500 caracteres, sin stack traces, y con los términos privados sustituidos.
14
+
15
+ ## El stream
16
+
17
+ `/api/events` se alimenta del ledger, no de la memoria del proceso: los eventos que otro proceso MADRE agregue a la misma sala llegan a las páginas abiertas. El sondeo lee solo los bytes nuevos cada 500 ms (`PULSE_BROADCAST_INTERVAL_MS`); un archivo que encoge, tras un purgado, reinicia el offset. Cada frame lleva `id: <secuencia>`, así un cliente que reconecta pide desde donde se quedó. Un cliente que deja de consumir se desconecta al acumular 1 MiB sin drenar (`PULSE_SSE_MAX_BUFFERED_BYTES`). Los eventos GHOST no tienen secuencia: llegan a las páginas abiertas y nunca al ledger.
18
+
19
+ La ventana de contexto que recibe un agente es de 16 000 caracteres (`PULSE_CONTEXT_MAX_CHARS`); se omiten los mensajes antiguos antes que los recientes. Un mensaje de más de 20 000 caracteres (`PULSE_MAX_MESSAGE_CHARS`) se registra y se rechaza sin invocar al agente.
20
+
21
+ ## La memoria
22
+
23
+ `memory.sqlite` vive junto al ledger y se deriva de él: si falta o su esquema cambió, se reconstruye desde `events.jsonl`. Contiene:
24
+
25
+ - `entries` con FTS5 en trigramas: todo mensaje, fallo y tarjeta de comando fuera de GHOST, con su secuencia. Las respuestas enlatadas de MADRE (`synthetic`) no entran.
26
+ - `memories` con FTS5: las notas destiladas, tipadas (`decision`, `fact`, `preference`, `question`), con las secuencias que las sustentan, quién las escribió y su origen (`distilled` o `noted`).
27
+ - `entry_vectors` y `memory_vectors`: embeddings de 768 dimensiones, calculados en segundo plano y nunca en el camino crítico de un turno.
28
+
29
+ **Recall.** Cuando el transcript excede la ventana, cada turno recibe además, dentro del mismo presupuesto (`PULSE_RECALL_SHARE`, 30 % por defecto), las notas y los intercambios anteriores que coinciden con la petición. La búsqueda léxica pondera cada término por su rareza en la sala: una ruta o un nombre pesan más que una palabra común. Con vectores, se fusiona con la similitud de coseno.
30
+
31
+ **Destilación.** Cada `PULSE_DISTILL_EVERY` intercambios sin destilar, o tras `PULSE_DISTILL_IDLE_MS` de reposo, el archivista lee un lote acotado por `PULSE_DISTILL_MAX_CHARS`, del más reciente hacia atrás, y escribe hasta cinco notas. El archivista es el más barato permitido en el orden Ollama, Gemini, OpenCode, Codex, Claude; el que falla queda en banca treinta minutos y pasa el siguiente. Una sola llamada por lote, nunca durante un turno. Para los modelos locales se pide JSON estricto.
32
+
33
+ **Embeddings.** `PULSE_EMBED_PROVIDER=auto` usa Ollama si corre con un modelo de embeddings (`nomic-embed-text`), si no la key de Gemini (`gemini-embedding-001`, la misma key que usa el Gemini CLI, incluida la del llavero de macOS). Lotes de hasta 100 textos por llamada. `PULSE_EMBED=0` deja el recall léxico.
34
+
35
+ **Herramientas MCP.** Cada turno adjunta a la CLI el servidor `pulse-memory` con configuración aislada por corrida: `memory_search`, `memory_recall`, `memory_notes`, `memory_timeline`, `project_state` y `memory_note`. `memory_note` va firmada por el agente y el mensaje del turno, se rechaza en GHOST y sus notas se marcan `noted`.
36
+
37
+ **NOSTROMO y CODE000.** El servidor exige la designación del proyecto en `GET /api/memory` y `DELETE /api/memory/:id`. Ocho toques al corazón en treinta segundos sellan el archivo diez minutos (ambas rutas responden 423), expulsan la consola y disparan un mensaje cifrado con AES-256-GCM bajo el sello de la sala (`<proyecto>/.pulse/mother.env`) que todos los agentes en línea reciben decodificado como requester `mother`. Si el sello cambia o desaparece, la sala lo detecta y avisa.
38
+
39
+ **Privacidad.** Los términos de `config.json` se sustituyen en cuatro puntos: la respuesta del agente antes de grabarla, el índice al insertar, las notas al guardarlas y el dataset al exportarlo. El purgado reescribe ledger, índice y notas conservando secuencias y la marca de destilado. El servidor relee los términos antes de cada mensaje humano, porque `config.json` es compartido por todas las salas de la máquina.
40
+
41
+ ## El centinela de límites
42
+
43
+ Cada esfera de la barra lleva un anillo. Muestra el límite real del proveedor cuando el CLI lo publica, si no, la ventana local.
44
+
45
+ - **Codex** escribe en cada rollout de sesión (`~/.codex/sessions`) sus ventanas de cuenta, de 5 horas y semanal, con porcentaje usado y hora de reinicio. MADRE lee el más reciente; no hay red de por medio.
46
+ - **Claude Code** publica `/usage` en el endpoint OAuth de Anthropic. Con `PULSE_CLAUDE_USAGE=1`, MADRE lo consulta con el token que Claude Code guarda en el llavero o en `~/.claude/.credentials.json`; el token solo viaja a `api.anthropic.com`, como hace el propio CLI. Es opcional porque implica leer una credencial. `PULSE_OFFICIAL_QUOTA=0` desactiva toda lectura de proveedor.
47
+ - **Gemini y OpenCode** no publican nada localmente: su anillo es la ventana local.
48
+
49
+ La ventana local es un presupuesto blando por agente (500 000 tokens) sobre una ventana rodante de 5 horas; los turnos salen del cómputo al envejecer, las lecturas de caché pesan una décima parte y los tokens de `@madre` pesan cero. Avisa al 80, 90 y 100 % y sugiere otros agentes. Una ventana cuya hora de reinicio ya pasó cuenta como vacía; el monitor relee las fuentes cada minuto y anuncia `limit.cleared` cuando una ventana llena vuelve a la normalidad.
50
+
51
+ Un lector de cuota implementa `{ id, agent, read() }` y `read()` devuelve `null` o `{ usedPercent, resetAt, windows?, stale? }`. MADRE nunca infiere la cuota del proveedor a partir de tokens locales.
52
+
53
+ ## Los adaptadores
54
+
55
+ Cada agente corre como proceso en su propio grupo, con un home aislado que solo recibe sus credenciales existentes. Si no responde antes del timeout (180 s por defecto), MADRE termina el árbol completo: SIGTERM y, tras un periodo de gracia, SIGKILL.
56
+
57
+ - **Codex**: consulta de solo lectura; `-C <carpeta> --sandbox workspace-write` en CREATE; en CONTROL, sandbox de escritura sobre el proyecto. Genera imágenes con `image_generation` usando su cuenta de ChatGPT.
58
+ - **Claude Code**: herramientas locales de lectura, sesiones no persistentes, MCP limitado a los servidores de MADRE. En CREATE, `--allowedTools Write(//<carpeta>/**),Edit(//<carpeta>/**)` bajo `dontAsk`: la doble barra es la forma absoluta; con una sola, Claude lee la ruta como relativa al proyecto y niega toda escritura. En CONTROL, escritura permitida en el proyecto y negada en las zonas prohibidas.
59
+ - **Gemini CLI**: Plan Mode y una política efímera de solo lectura, ampliada en CREATE con reglas `allow` para `write_file` y `edit` cuyo `file_path` empiece por la carpeta. Corre con un `GEMINI_CLI_HOME` temporal: tokens OAuth y `~/.gemini/.env` sí, hooks, extensiones, servidores MCP y memoria del `~/.gemini` real no. Gemini lee `@algo` como un archivo a incluir, así que MADRE escapa cada mención como `\@nombre` y la desescapa en la respuesta. Bajo 503 o 429, el CLI reintenta con backoff durante minutos y solo escribe en stderr: MADRE lo lee en vivo, corta tras `PULSE_GEMINI_IDLE_MS`, reintenta una vez con `PULSE_GEMINI_FALLBACK_MODEL` y, si tampoco, lo dice con nombre y modelo probado.
60
+ - **OpenCode**: permisos efímeros inyectados por corrida, sin tocar la configuración global; `permission.edit` por patrón en CREATE y CONTROL. Si tu configuración no fija modelo, `run` elige el proveedor por defecto, que puede no ser el que tiene sesión: `PULSE_OPENCODE_MODEL=proveedor/modelo` lo fija.
61
+ - **@madre**: no es una CLI. Es un adaptador local sobre Ollama que recibe la transcripción reciente, lo que la memoria recuerda de la pregunta y la pregunta; nunca el briefing de las CLIs. Con una ventana de 8 192 tokens, porque la de 4 096 por defecto recorta el prompt de sistema. Lo que puede resolver sin modelo, lo resuelve: identidad, órdenes que no le corresponden, peticiones de guardar memoria y la mesa redonda, que es un plan escrito por la sala con un paso por CLI en línea y su turno de cierre.
62
+
63
+ Los alcances de imagen de Claude, Gemini y OpenCode se cumplen con **Image Studio**: el servidor MCP `src/mcp/image-server.mjs`, adjunto solo dentro de un lease con el alcance encendido, que genera con la API de imágenes de Gemini y guarda el archivo en la carpeta del turno.
64
+
65
+ ## CONTROL por dentro
66
+
67
+ Antes del turno, MADRE fotografía el proyecto como un commit real bajo `refs/madre/checkpoints/` con un índice temporal: incluye archivos sin seguimiento, respeta `.gitignore`, no toca tu rama, tu índice ni tu stash, y funciona en repositorios sin commits. Mientras dura el turno, `.env*`, `.pulse/`, `.madre/` y `.claude/settings.local.json` quedan en solo lectura a nivel de sistema de archivos y recuperan sus permisos al terminar. Después, `git diff` contra el checkpoint da la lista de cambios; cualquier escritura en una zona prohibida que hubiera pasado se revierte desde el checkpoint y se nombra. `UNDO` restaura el checkpoint completo.
68
+
69
+ ## Recuperación operativa
70
+
71
+ Si MADRE se detiene a mitad de un turno, al arrancar detecta los `agent.started` sin cierre y registra un `message.failed` recuperado para cada uno. Al cerrar con Ctrl+C o `SIGTERM`, interrumpe los procesos de agente en curso, registra esos turnos como fallidos, entrega los eventos pendientes a las páginas abiertas y termina. Los planes se ejecutan en secuencia y los delegados no delegan, así que todo plan termina; `STOPALL` corta cualquier cosa en curso.
72
+
73
+ ## Empaquetado y pruebas
74
+
75
+ `npm test` corre la suite con `PULSE_OLLAMA=0` para no tocar un Ollama real; ninguna prueba llama a un modelo. `npm run pack:check` empaqueta, instala en un directorio vacío y ejecuta el CLI instalado: `--help`, `doctor` y un arranque del servidor que sirve la sala y cierra limpio con `SIGTERM`. `PULSE_TEST_MODE=1` habilita `/api/test/limits` para simular ventanas de cuota.
@@ -0,0 +1,5 @@
1
+ # ollama create madre-<project> -f Modelfile
2
+ FROM ./madre.gguf
3
+ PARAMETER temperature 0.1
4
+ PARAMETER num_ctx 8192
5
+ SYSTEM """You are @madre, the memory of this MADRE project room. Answer only from what the room said and decided; when it never discussed a point, say so. Cite ledger sequences like [#123]."""
@@ -0,0 +1,47 @@
1
+ # MADRE AI · entrenar el modelo del proyecto
2
+
3
+ Roadmap 2c. La sala exporta lo que recuerda como pares de chat; tú entrenas un modelo pequeño con ellos en tu máquina; Ollama lo sirve; `@madre` lo usa en cuanto existe. Todo local.
4
+
5
+ ## 1 · Exportar
6
+
7
+ Desde MU/TH/UR → `⚙ CONNECTIONS` → `MEMORY` → `EXPORT DATASET`, o en terminal:
8
+
9
+ ```
10
+ madre dataset # en la carpeta del proyecto
11
+ ```
12
+
13
+ Escribe `~/.pulse/rooms/<sala>/dataset/{train.jsonl, valid.jsonl, manifest.json}`. Cada línea es `{"messages":[{role:"system"},{role:"user"},{role:"assistant"}]}`: los turnos reales de la sala, redactados (sin rutas, nombres, correos ni claves), más las notas destiladas como pares de recuerdo. GHOST nunca está.
14
+
15
+ Cuánto hace falta: con menos de ~200 pares el modelo aprende el tono y poco más; a partir de ~1000 empieza a valer. Exporta cada tanto; el split es determinista, así que las líneas no cambian de lado.
16
+
17
+ ## 2 · Entrenar (Apple Silicon, mlx-lm)
18
+
19
+ ```
20
+ python3 -m venv ~/.madre-train && source ~/.madre-train/bin/activate
21
+ pip install mlx-lm
22
+ cd ~/.pulse/rooms/<sala>
23
+ mlx_lm.lora --model Qwen/Qwen2.5-1.5B-Instruct --train --data dataset --iters 600 --batch-size 2 --learning-rate 1e-5 --adapter-path adapters
24
+ mlx_lm.lora --model Qwen/Qwen2.5-1.5B-Instruct --adapter-path adapters --data dataset --test
25
+ ```
26
+
27
+ `train.sh` en esta carpeta hace lo mismo con comprobaciones. En 16 GB usa el 1.5B; con 32 GB o más, `Qwen/Qwen2.5-3B-Instruct` o `Qwen/Qwen2.5-Coder-7B-Instruct` con `--batch-size 1`. Una ronda de 600 iteraciones tarda de 10 a 40 minutos.
28
+
29
+ ## 3 · Servir en Ollama
30
+
31
+ ```
32
+ mlx_lm.fuse --model Qwen/Qwen2.5-1.5B-Instruct --adapter-path adapters --save-path fused
33
+ ```
34
+
35
+ Ollama lee GGUF. Para Llama y Mistral `mlx_lm.fuse` acepta `--export-gguf`; para Qwen convierte con llama.cpp:
36
+
37
+ ```
38
+ git clone --depth 1 https://github.com/ggml-org/llama.cpp && pip install -r llama.cpp/requirements.txt
39
+ python3 llama.cpp/convert_hf_to_gguf.py fused --outfile madre.gguf --outtype q8_0
40
+ cp docs/training/Modelfile . && ollama create madre-<proyecto> -f Modelfile
41
+ ```
42
+
43
+ El nombre importa: `madre-<proyecto>` en minúsculas, con guiones. Al siguiente `RECHECK` en MODULES → OLLAMA, `@madre` cambia a ese modelo y la tarjeta MEMORY lo dice.
44
+
45
+ ## 4 · Evaluar antes de confiar
46
+
47
+ Antes de promover un modelo nuevo, pregúntale a `@madre` las diez cosas que la sala sí decidió y las cinco que nunca discutió. Debe acertar las primeras y decir "la sala nunca lo discutió" en las segundas. Si inventa, vuelve al modelo base (`ollama rm madre-<proyecto>`) y entrena con más pares o menos iteraciones.
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env bash
2
+ # MADRE AI · one LoRA round on this room's dataset with mlx-lm (Apple Silicon).
3
+ # docs/training/train.sh <room-dir> [base-model] [iters]
4
+ set -euo pipefail
5
+ ROOM="${1:?room dir, e.g. ~/.pulse/rooms/<sala>}"
6
+ BASE="${2:-Qwen/Qwen2.5-1.5B-Instruct}"
7
+ ITERS="${3:-600}"
8
+ [ -f "$ROOM/dataset/train.jsonl" ] || { echo "no dataset: run 'madre dataset' first"; exit 2; }
9
+ PAIRS=$(wc -l < "$ROOM/dataset/train.jsonl")
10
+ [ "$PAIRS" -ge 50 ] || { echo "only $PAIRS training pairs; let the room grow (aim for a few hundred)"; exit 3; }
11
+ python3 -c "import mlx_lm" 2>/dev/null || { echo "pip install mlx-lm (inside a venv) first"; exit 4; }
12
+ cd "$ROOM"
13
+ echo "training LoRA on $PAIRS pairs · base $BASE · $ITERS iterations"
14
+ mlx_lm.lora --model "$BASE" --train --data dataset --iters "$ITERS" --batch-size 2 --learning-rate 1e-5 --adapter-path adapters
15
+ mlx_lm.lora --model "$BASE" --adapter-path adapters --data dataset --test
16
+ echo
17
+ echo "adapters in $ROOM/adapters · next: mlx_lm.fuse, convert to GGUF, ollama create madre-<project> (see README.md)"
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@jossuealcala/madre",
3
- "version": "0.3.0",
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.",
3
+ "version": "0.3.1",
4
+ "description": "MADRE · one local room where the AI coding agents already on your machine (Codex, Claude Code, Gemini CLI, OpenCode) work on a project together, with a shared memory every one of them recalls and a local @madre that speaks for it. Read-only by default, per-message permission modes up to a checkpointed CONTROL. Nothing leaves your machine on its own.",
5
5
  "keywords": [
6
6
  "ai-agents",
7
7
  "codex",
@@ -42,6 +42,8 @@
42
42
  "LICENSE",
43
43
  "NOTICE",
44
44
  "docs/madre-banner.svg",
45
+ "docs/training",
46
+ "docs/INTERNALS.md",
45
47
  "SECURITY.md",
46
48
  "CONTRIBUTING.md"
47
49
  ],
package/public/app.js CHANGED
@@ -64,7 +64,9 @@ const state = {
64
64
  budget: null,
65
65
  timeouts: {},
66
66
  seen: new Set(),
67
- userMessages: new Map(), // messageId -> { text, target }
67
+ userMessages: new Map(),
68
+ ratings: new Map(), // messageId → good | bad, from the ledger
69
+ ratingNodes: new Map(), // messageId → the buttons of that bubble // messageId -> { text, target }
68
70
  lastSequence: 0,
69
71
  lastSender: null, // for iMessage-style grouping of consecutive bubbles
70
72
  failures: [], // recorded conditions for MU/TH/UR
@@ -729,8 +731,8 @@ function renderPicker() {
729
731
  text.append('to ');
730
732
  text.append(el('b', null, `@${current.id}`));
731
733
  if (current.local) {
732
- const note = el('span', 'pick-note', 'memory · answers, does not act');
733
- note.title = '@madre runs on this machine and speaks only for what the room said. To convene the crew or change files, write to a CLI agent.';
734
+ const note = el('span', 'pick-note', 'memory · answers & asks the crew · never writes');
735
+ note.title = '@madre runs on this machine and speaks for what the room remembers. "@madre, ask the crew …" opens a round with every agent online. To change files, write to a CLI agent.';
734
736
  text.append(note);
735
737
  }
736
738
  const modeChip = el('button', `mode-chip m${state.mode}`);
@@ -1059,7 +1061,7 @@ function renderAssistantMessage(event) {
1059
1061
  bubble.append(renderMarkdown(text));
1060
1062
  if (originalText) bubble.append(ashOriginal(originalText, ashCode));
1061
1063
  if (event.payload.artifacts?.length) bubble.append(artifactTiles(event.payload.artifacts));
1062
- bubble.append(bubbleActions({ text: originalText ?? text, sender, sequence: event.sequence }));
1064
+ bubble.append(bubbleActions({ text: originalText ?? text, sender, sequence: event.sequence, messageId: delegated ? null : messageId }));
1063
1065
  col.append(bubble);
1064
1066
  const stamp = el('div', 'stamp');
1065
1067
  stamp.id = `usage-${messageId}`;
@@ -1082,7 +1084,27 @@ function iconButton(svg, title, className) {
1082
1084
  button.innerHTML = svg;
1083
1085
  return button;
1084
1086
  }
1085
- function bubbleActions({ text, sender, sequence }) {
1087
+ const ICON_GOOD = '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M5.5 7.5v5.5H3.2a.7.7 0 0 1-.7-.7V8.2a.7.7 0 0 1 .7-.7h2.3Zm0 0 2.6-4.6a1.3 1.3 0 0 1 2.4.8L10 6.8h2.6a1.3 1.3 0 0 1 1.3 1.5l-.8 3.9a1.3 1.3 0 0 1-1.3 1H5.5" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/></svg>';
1088
+ const ICON_BAD = '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M10.5 8.5V3H12.8a.7.7 0 0 1 .7.7v4.1a.7.7 0 0 1-.7.7h-2.3Zm0 0-2.6 4.6a1.3 1.3 0 0 1-2.4-.8L6 9.2H3.4a1.3 1.3 0 0 1-1.3-1.5l.8-3.9A1.3 1.3 0 0 1 4.2 3h6.3" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/></svg>';
1089
+ // The human's verdict on a reply. Saved in the ledger; the dataset drops what is marked bad.
1090
+ function ratingButtons(messageId, sender) {
1091
+ const good = iconButton(ICON_GOOD, 'Good reply · keep it for MADRE AI', 'rate good');
1092
+ const bad = iconButton(ICON_BAD, 'Bad reply · keep it out of the dataset', 'rate bad');
1093
+ const apply = (rating) => { good.classList.toggle('on', rating === 'good'); bad.classList.toggle('on', rating === 'bad'); };
1094
+ apply(state.ratings.get(messageId) ?? null);
1095
+ const send = async (rating) => {
1096
+ const current = state.ratings.get(messageId) ?? null;
1097
+ const next = current === rating ? 'none' : rating;
1098
+ try {
1099
+ const payload = await fetch('/api/messages/rate', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ messageId, rating: next }) }).then((response) => response.json());
1100
+ if (payload.error) throw new Error(payload.error);
1101
+ } catch (error) { toast(`Rating was not saved: ${error.message}`); }
1102
+ };
1103
+ good.addEventListener('click', (event) => { event.stopPropagation(); void send('good'); });
1104
+ bad.addEventListener('click', (event) => { event.stopPropagation(); void send('bad'); });
1105
+ return { good, bad, apply, sender };
1106
+ }
1107
+ function bubbleActions({ text, sender, sequence, messageId = null }) {
1086
1108
  const bar = el('div', 'bubble-actions');
1087
1109
  const copy = iconButton(ICON_COPY, 'Copy this reply', 'copy');
1088
1110
  copy.addEventListener('click', async (event) => {
@@ -1102,6 +1124,11 @@ function bubbleActions({ text, sender, sequence }) {
1102
1124
  const rect = reply.getBoundingClientRect();
1103
1125
  showReplyMenu({ text, sender, sequence }, rect.left, rect.bottom + 6);
1104
1126
  });
1127
+ if (messageId && sender !== 'madre') {
1128
+ const rating = ratingButtons(messageId, sender);
1129
+ state.ratingNodes.set(messageId, rating);
1130
+ bar.append(rating.good, rating.bad);
1131
+ }
1105
1132
  bar.append(copy, reply);
1106
1133
  return bar;
1107
1134
  }
@@ -1251,6 +1278,20 @@ function renderThinking(event) {
1251
1278
  }
1252
1279
 
1253
1280
  // The archivist reports: which agent read which stretch of the room and how many notes it kept.
1281
+ // privacy · @agent · 2 private terms replaced with [ENTIDAD-ORG]
1282
+ function renderPrivacy(event) {
1283
+ const node = el('div', 'system memory privacy');
1284
+ if (event.type === 'privacy.purged') {
1285
+ const { events, entries, memories } = event.payload;
1286
+ node.append('privacy · ', el('b', 'who', 'PURGE'), ` · ${events} event${events === 1 ? '' : 's'} · ${entries} indexed exchange${entries === 1 ? '' : 's'} · ${memories} memor${memories === 1 ? 'y' : 'ies'} rewritten with the marker`);
1287
+ return node;
1288
+ }
1289
+ const { agent, hits, marker } = event.payload;
1290
+ node.style.setProperty('--agent', agentColor(agent));
1291
+ node.append('privacy · ', el('b', 'who', `@${agent}`), ` · ${hits} private term${hits === 1 ? '' : 's'} replaced with ${marker}`);
1292
+ return node;
1293
+ }
1294
+
1254
1295
  function renderDistilled(event) {
1255
1296
  const { agent, added, considered, fromSequence, throughSequence, remaining, error, skipped, total, next } = event.payload;
1256
1297
  const node = el('div', `system memory${error ? ' warn' : ''}`);
@@ -1775,6 +1816,10 @@ function renderEventNode(event) {
1775
1816
  case 'message.failed': state.running.delete(event.payload.messageId); updateStopAll(); node = renderFailure(event); break;
1776
1817
  case 'handoff.created': node = renderHandoff(event); break;
1777
1818
  case 'memory.distilled': node = renderDistilled(event); break;
1819
+ case 'message.rated': { const { messageId: rated, rating } = event.payload; if (rating === 'none') state.ratings.delete(rated); else state.ratings.set(rated, rating); state.ratingNodes.get(rated)?.apply(rating === 'none' ? null : rating); return; }
1820
+ case 'privacy.redacted': node = renderPrivacy(event); break;
1821
+ case 'privacy.purged': node = renderPrivacy(event); break;
1822
+ case 'privacy.warning': if (!replaying) toast(`MU/TH/UR › your message carries ${event.payload.hits} private term${event.payload.hits === 1 ? '' : 's'}. Agents will read it as you wrote it; their replies are guarded.`); return;
1778
1823
  case 'memory.forgotten': node = renderForgotten(event); break;
1779
1824
  case 'memory.noted': attachMemoryHint(event); return;
1780
1825
  case 'dataset.exported': return;
@@ -2654,6 +2699,8 @@ function renderMotherRecorded() {
2654
2699
  jump.type = 'button';
2655
2700
  jump.addEventListener('click', () => {
2656
2701
  mother.input.value = '';
2702
+ // Choosing a fix from the log is a request to see it: the list opens and stays open.
2703
+ try { localStorage.setItem('pulse.mother.known', 'expanded'); } catch { /* no storage */ }
2657
2704
  renderMotherKnown(CONDITIONS, new Set(matches.map((item) => item.id)), failure.agent);
2658
2705
  document.getElementById(`mother-${condition.id}`)?.scrollIntoView({ behavior: 'smooth', block: 'center' });
2659
2706
  });
@@ -2666,7 +2713,23 @@ function renderMotherRecorded() {
2666
2713
 
2667
2714
  function renderMotherKnown(list = CONDITIONS, hits = new Set(), agent = null) {
2668
2715
  mother.known.replaceChildren();
2669
- mother.known.append(el('h3', null, `KNOWN CONDITIONS · ${list.length} OF ${CONDITIONS.length} · ${PLATFORMS[mother.platform].label.toUpperCase()} / ${PLATFORMS[mother.platform].shell.toUpperCase()}`));
2716
+ let collapsed = false;
2717
+ try { collapsed = localStorage.getItem('pulse.mother.known') === 'collapsed'; } catch { /* no storage */ }
2718
+ // An inquiry that narrowed the list, or a fix chosen from the log, always shows its answer, whatever the stored state.
2719
+ if (list.length !== CONDITIONS.length || hits.size) collapsed = false;
2720
+ const head = el('h3', 'toggle');
2721
+ const headButton = el('button', null, `KNOWN CONDITIONS · ${list.length} OF ${CONDITIONS.length} · ${PLATFORMS[mother.platform].label.toUpperCase()} / ${PLATFORMS[mother.platform].shell.toUpperCase()}`);
2722
+ headButton.type = 'button';
2723
+ headButton.setAttribute('aria-expanded', String(!collapsed));
2724
+ headButton.append(el('span', 'caret', collapsed ? '▸ EXPAND' : '▾ COLLAPSE'));
2725
+ headButton.addEventListener('click', () => {
2726
+ try { localStorage.setItem('pulse.mother.known', collapsed ? 'expanded' : 'collapsed'); } catch { /* no storage */ }
2727
+ renderMotherKnown(list, hits, agent);
2728
+ });
2729
+ head.append(headButton);
2730
+ mother.known.append(head);
2731
+ mother.known.classList.toggle('collapsed', collapsed);
2732
+ if (collapsed) return;
2670
2733
  const grid = el('div', 'mother-grid');
2671
2734
  const ordered = [...list].sort((a, b) => Number(hits.has(b.id)) - Number(hits.has(a.id)));
2672
2735
  for (const condition of ordered) grid.append(conditionCard(condition, { hit: hits.has(condition.id), agent: agent && condition.perAgent ? agent : null, hintAgent: agent }));
@@ -3512,7 +3575,11 @@ function renderSettings() {
3512
3575
  const datasetNote = el('span', 'note', 'LOADING…');
3513
3576
  const showDataset = (payload) => {
3514
3577
  const d = payload?.dataset;
3515
- datasetNote.textContent = d ? `${d.pairs} PAIRS · ${d.turns} TURNS · ${d.notes} NOTES · TRAIN ${d.train} · VALID ${d.valid} · ${new Date(d.exportedAt).toLocaleString()}${payload.trained ? ` · TRAINED MODEL ${payload.trained.toUpperCase()} IN USE` : ' · NO TRAINED MODEL YET · SEE docs/training'}` : 'NOT EXPORTED YET · PRESS EXPORT, THEN TRAIN WITH docs/training';
3578
+ const r = payload?.readiness;
3579
+ const live = r ? `${r.pairs} / ${r.target} CLEAN PAIRS${r.ready ? ' · READY TO TRAIN' : ''} · ${r.turns} TURNS · ${r.delegated} DELEGATED · ${r.notes} NOTES${r.good ? ` · ${r.good} RATED GOOD` : ''}${r.bad ? ` · ${r.bad} DROPPED AS BAD` : ''}` : '';
3580
+ const exported = d ? `LAST EXPORT ${new Date(d.exportedAt).toLocaleString()} · TRAIN ${d.train} · VALID ${d.valid}` : 'NOT EXPORTED YET';
3581
+ const trained = payload?.trained ? `TRAINED MODEL ${payload.trained.toUpperCase()} IN USE` : 'NO TRAINED MODEL YET · SEE docs/training';
3582
+ datasetNote.textContent = [live, exported, trained].filter(Boolean).join(' · ');
3516
3583
  };
3517
3584
  fetch('/api/dataset').then((response) => response.json()).then(showDataset).catch(() => { datasetNote.textContent = 'DATASET UNAVAILABLE'; });
3518
3585
  exportButton.addEventListener('click', async () => {
@@ -3523,10 +3590,95 @@ function renderSettings() {
3523
3590
  });
3524
3591
  dataset.append(exportButton, datasetNote);
3525
3592
  mform.append(dataset);
3593
+ // TRAIN: the recipe, with this room's paths and this project's model name filled in. Training runs outside MADRE.
3594
+ const train = el('div', 'full train-card');
3595
+ const trainHead = el('div', 'train-head', 'TRAIN MADRE AI · LOCAL, WITH MLX ON APPLE SILICON · NOTHING LEAVES THIS MACHINE');
3596
+ const trainNote = el('p', 'note', 'EXPORT THE DATASET FIRST. EACH STEP IS ONE COMMAND FOR YOUR TERMINAL; COPY, RUN, COME BACK. WHEN THE MODEL EXISTS IN OLLAMA, @MADRE SWITCHES TO IT AT THE NEXT RECHECK AND EVERY AGENT IS TOLD TO ASK IT FIRST.');
3597
+ const steps = el('ol', 'train-steps');
3598
+ train.append(trainHead, trainNote, steps);
3599
+ const renderTraining = (payload) => {
3600
+ const t = payload?.training;
3601
+ steps.replaceChildren();
3602
+ if (!t) { steps.append(el('li', null, 'TRAINING INFO UNAVAILABLE')); return; }
3603
+ const quote = (path) => `"${path.replace(/\/$/, '')}"`;
3604
+ const items = [
3605
+ ['ONCE · A PYTHON ENVIRONMENT WITH MLX-LM', 'python3 -m venv ~/.madre-train && source ~/.madre-train/bin/activate && pip install mlx-lm'],
3606
+ [`TRAIN THE LORA · BASE ${t.baseModel} FOR ${t.memoryGb} GB`, `source ~/.madre-train/bin/activate && bash ${quote(`${t.recipeDir}train.sh`)} ${quote(t.roomDir)} ${t.baseModel}`],
3607
+ ['FUSE THE ADAPTER INTO THE BASE', `source ~/.madre-train/bin/activate && cd ${quote(t.roomDir)} && mlx_lm.fuse --model ${t.baseModel} --adapter-path adapters --save-path fused`],
3608
+ [`REGISTER IN OLLAMA AS ${t.modelName}`, `cd ${quote(t.roomDir)} && cp ${quote(`${t.recipeDir}Modelfile`)} . && ollama create ${t.modelName} -f Modelfile`],
3609
+ ];
3610
+ for (const [label, command] of items) {
3611
+ const li = el('li');
3612
+ const head = el('div', 'train-step-label', label);
3613
+ const row = el('div', 'update-command');
3614
+ const code = el('code', null, command);
3615
+ const copy = el('button', null, 'COPY'); copy.type = 'button';
3616
+ copy.addEventListener('click', async () => { try { await navigator.clipboard.writeText(command); copy.textContent = 'COPIED'; setTimeout(() => { copy.textContent = 'COPY'; }, 1400); } catch { toast('MU/TH/UR › select the command and copy it.'); } });
3617
+ row.append(code, copy);
3618
+ li.append(head, row);
3619
+ steps.append(li);
3620
+ }
3621
+ steps.append(el('li', 'note', `QWEN NEEDS A GGUF BEFORE OLLAMA READS IT: ${quote(`${t.recipeDir}README.md`)} · SECTION 3 HAS THE TWO LINES. THEN ASK @MADRE TEN THINGS THE ROOM DECIDED AND FIVE IT NEVER DISCUSSED BEFORE TRUSTING IT.`));
3622
+ };
3623
+ fetch('/api/dataset').then((response) => response.json()).then(renderTraining).catch(() => renderTraining(null));
3624
+ mform.append(train);
3526
3625
  if (mem.envWins) mform.append(el('span', 'note full', 'ENVIRONMENT VARIABLES ARE SET FOR MEMORY; THEY WIN OVER THESE VALUES ON THE NEXT LAUNCH.'));
3527
3626
  mform.addEventListener('submit', (event) => event.preventDefault());
3528
3627
  section.append(mform);
3529
3628
  }
3629
+
3630
+ // PRIVACY: terms that never travel through the room. Replaced at every hop: agent replies,
3631
+ // the index, the notes, the dataset. PURGE does the same to what the room already holds.
3632
+ const priv = data.settings.privacy;
3633
+ if (priv) {
3634
+ section.append(el('h3', null, `PRIVACY · ${priv.terms.length ? `${priv.terms.length} PRIVATE TERM${priv.terms.length === 1 ? '' : 'S'}` : 'NO PRIVATE TERMS'}`));
3635
+ section.append(el('p', 'note', 'AN AGENT\'S OWN CONFIGURATION CAN LEAK INTO ITS REPLY: A COMPANY, A BRAND, A DOMAIN. NAME THEM HERE AND MADRE REPLACES THEM BEFORE THE LEDGER, THE ARCHIVIST, THE OTHER AGENTS OR THE DATASET SEE THEM. THE TERMS STAY IN CONFIG.JSON; THE ROOM ONLY EVER RECORDS HOW MANY.'));
3636
+ const pform = el('form', 'room-form privacy-form');
3637
+ const field = (labelText, node) => { const label = el('label'); label.append(labelText); label.append(node); return label; };
3638
+ const terms = el('textarea'); terms.rows = 3; terms.value = priv.terms.join('\n'); terms.placeholder = 'one term per line · a company, a brand, a domain, a name'; terms.spellcheck = false;
3639
+ const marker = el('input'); marker.value = priv.marker; marker.maxLength = 40; marker.spellcheck = false;
3640
+ const exposure = el('span', 'note full', 'CHECKING THE ROOM…');
3641
+ const purge = el('button', null, 'PURGE ROOM');
3642
+ purge.type = 'button';
3643
+ purge.title = 'Replace every private term already in the ledger, the index and the memories with the marker. Asks for the project designation.';
3644
+ const showExposure = (payload) => {
3645
+ const x = payload?.exposure;
3646
+ if (!x) { exposure.textContent = ''; return; }
3647
+ const total = x.events + x.entries + x.memories;
3648
+ exposure.textContent = !payload.terms?.length ? 'WRITE THE TERMS FIRST.' : total ? `STILL IN THE ROOM: ${x.events} EVENT${x.events === 1 ? '' : 'S'} · ${x.entries} INDEXED EXCHANGE${x.entries === 1 ? '' : 'S'} · ${x.memories} MEMOR${x.memories === 1 ? 'Y' : 'IES'} · PURGE REPLACES THEM.` : 'THE ROOM IS CLEAN: NO PRIVATE TERM IN THE LEDGER, THE INDEX OR THE MEMORIES.';
3649
+ purge.disabled = !payload.terms?.length || !total;
3650
+ };
3651
+ const savePrivacy = async (patch, describe) => {
3652
+ try {
3653
+ const payload = await fetch('/api/privacy', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(patch) }).then((response) => response.json());
3654
+ if (payload.error) throw new Error(payload.error);
3655
+ terms.value = payload.terms.join('\n'); marker.value = payload.marker; showExposure(payload);
3656
+ toast(`MU/TH/UR › ${describe(payload)}`);
3657
+ } catch (error) { toast(`Privacy setting was not saved: ${error.message}`); }
3658
+ };
3659
+ terms.addEventListener('change', () => savePrivacy({ terms: terms.value.split('\n') }, (payload) => `${payload.terms.length} private term${payload.terms.length === 1 ? '' : 's'} guarded from now on.`));
3660
+ marker.addEventListener('change', () => savePrivacy({ marker: marker.value }, (payload) => `private terms appear as ${payload.marker}.`));
3661
+ pform.append(field('PRIVATE TERMS · ONE PER LINE', terms));
3662
+ pform.append(field('REPLACED WITH', marker));
3663
+ const row = el('div', 'full dataset-row');
3664
+ purge.addEventListener('click', async () => {
3665
+ const designation = window.prompt('PURGE ROOM · Every private term already recorded becomes the marker, in the ledger, the index and the memories. This cannot be undone. Type the project designation to confirm:');
3666
+ if (designation === null) return;
3667
+ purge.disabled = true;
3668
+ try {
3669
+ const payload = await fetch('/api/privacy/purge', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ designation }) }).then((response) => response.json());
3670
+ if (payload.error) throw new Error(payload.error);
3671
+ toast(`MU/TH/UR › purged: ${payload.purged.events} events · ${payload.purged.entries} exchanges · ${payload.purged.memories} memories. Reloading.`);
3672
+ setTimeout(() => location.reload(), 1600);
3673
+ } catch (error) { toast(`Purge did not run: ${error.message}`); purge.disabled = false; }
3674
+ });
3675
+ row.append(purge, exposure);
3676
+ pform.append(row);
3677
+ if (priv.envWins) pform.append(el('span', 'note full', 'PULSE_PRIVATE_TERMS IS SET; THOSE TERMS ARE ADDED TO THIS LIST ON EVERY LAUNCH.'));
3678
+ pform.addEventListener('submit', (event) => event.preventDefault());
3679
+ section.append(pform);
3680
+ fetch('/api/privacy').then((response) => response.json()).then(showExposure).catch(() => { exposure.textContent = 'EXPOSURE CHECK UNAVAILABLE'; });
3681
+ }
3530
3682
  }
3531
3683
 
3532
3684
  // Save one setting the moment it changes, the way the scope boxes do; the
@@ -4336,6 +4488,60 @@ document.querySelector('#nostromo-forget')?.addEventListener('click', async (eve
4336
4488
  });
4337
4489
 
4338
4490
 
4491
+ /* ---------- Release channel: is there a newer MADRE? A pill in the bar, the command in MU/TH/UR. ---------- */
4492
+
4493
+ const updateUI = { section: document.querySelector('#mother-update'), pill: document.querySelector('#update-pill'), info: null, timer: null };
4494
+ async function loadVersion({ force = false } = {}) {
4495
+ try {
4496
+ updateUI.info = await fetch(`/api/version${force ? '?force=1' : ''}`).then((response) => response.json());
4497
+ } catch { updateUI.info = null; }
4498
+ renderUpdate();
4499
+ clearTimeout(updateUI.timer);
4500
+ updateUI.timer = setTimeout(() => void loadVersion(), 60 * 60 * 1000);
4501
+ updateUI.timer?.unref?.(); // a browser ignores this; the smoke test's Node must not stay alive for it
4502
+ }
4503
+ function renderUpdate() {
4504
+ const info = updateUI.info;
4505
+ const pill = updateUI.pill;
4506
+ if (pill) {
4507
+ pill.hidden = !info?.available;
4508
+ if (info?.available) { pill.replaceChildren(el('span', 'stop-all-glyph update-glyph'), `${info.latest} AVAILABLE`); pill.title = `MADRE ${info.latest} is on npm · you run ${info.current} · open MU/TH/UR for the command`; }
4509
+ }
4510
+ const section = updateUI.section;
4511
+ if (!section) return;
4512
+ section.replaceChildren();
4513
+ if (!info) { section.append(el('h3', null, 'RELEASE CHANNEL · UNAVAILABLE')); return; }
4514
+ const when = info.checkedAt ? new Date(info.checkedAt).toLocaleString() : null;
4515
+ section.append(el('h3', null, info.available ? `RELEASE CHANNEL · ${info.latest} AVAILABLE · YOU RUN ${info.current}` : `RELEASE CHANNEL · MADRE ${info.current}${info.latest ? ' · UP TO DATE' : info.enabled ? ' · NPM NOT REACHED YET' : ' · CHECK OFF'}`));
4516
+ if (info.available) {
4517
+ section.append(el('p', 'note', `A NEWER MADRE IS ON NPM. THIS COPY RUNS ${info.install === 'npx' ? 'FROM THE NPX CACHE' : info.install === 'project' ? 'FROM THIS PROJECT\'S NODE_MODULES' : info.install === 'global' ? 'AS A GLOBAL INSTALL' : 'FROM SOURCE'}; STOP THE ROOM, RUN THIS IN YOUR TERMINAL, START AGAIN. MADRE NEVER UPDATES ITSELF WHILE YOU WORK.`));
4518
+ const row = el('div', 'update-command');
4519
+ const code = el('code', null, info.command);
4520
+ const copy = el('button', null, 'COPY');
4521
+ copy.type = 'button';
4522
+ copy.addEventListener('click', async () => { try { await navigator.clipboard.writeText(info.command); copy.textContent = 'COPIED'; setTimeout(() => { copy.textContent = 'COPY'; }, 1400); } catch { toast('MU/TH/UR › select the command and copy it.'); } });
4523
+ row.append(code, copy);
4524
+ section.append(row);
4525
+ if (info.release) { const link = el('a', 'update-link', `WHAT ${info.latest} SHIPS ↗`); link.href = info.release; link.target = '_blank'; link.rel = 'noopener noreferrer'; section.append(link); }
4526
+ } else {
4527
+ section.append(el('p', 'note', `MADRE ASKS NPM FOR THE LATEST VERSION ONCE A DAY: THE PACKAGE NAME TRAVELS, NOTHING ELSE, THE SAME REQUEST NPX MAKES.${when ? ` LAST CHECK ${when.toUpperCase()}.` : ''}`));
4528
+ }
4529
+ const controls = el('div', 'sentinel-controls');
4530
+ const toggle = el('label', 'toggle');
4531
+ const box = el('input'); box.type = 'checkbox'; box.checked = Boolean(info.enabled); box.disabled = Boolean(info.envWins);
4532
+ box.addEventListener('change', async () => {
4533
+ box.disabled = true;
4534
+ try { await fetch('/api/updates/settings', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ check: box.checked }) }); toast(`MU/TH/UR › release channel ${box.checked ? 'on: one check a day' : 'off: no request leaves for npm'}.`); await loadVersion(); }
4535
+ catch (error) { toast(`Setting was not saved: ${error.message}`); box.disabled = false; }
4536
+ });
4537
+ toggle.append(box, `CHECK NPM FOR NEW VERSIONS ONCE A DAY${info.envWins ? ' · SET BY PULSE_UPDATE_CHECK' : ''}`);
4538
+ controls.append(toggle);
4539
+ if (info.enabled) { const now = el('button', null, 'CHECK NOW'); now.type = 'button'; now.addEventListener('click', () => void loadVersion({ force: true })); controls.append(now); }
4540
+ section.append(controls);
4541
+ }
4542
+ updateUI.pill?.addEventListener('click', () => { document.querySelector('#mother-button')?.click(); updateUI.section?.scrollIntoView({ block: 'start', behavior: 'smooth' }); });
4543
+ void loadVersion();
4544
+
4339
4545
  /* ---------- MU/TH/UR: the sentinel. Unknown conditions and crashes, redacted, ready to report. ---------- */
4340
4546
 
4341
4547
  const sentinelUI = { section: document.querySelector('#mother-sentinel'), settings: null, feedbackUrl: null, loaded: false };
package/public/index.html CHANGED
@@ -23,6 +23,7 @@
23
23
  <div id="connection" class="connection" data-state="connecting" title="Live updates">
24
24
  <span class="connection-dot"></span><span class="connection-label">connecting</span>
25
25
  </div>
26
+ <button id="update-pill" class="stop-all update-pill" type="button" hidden title="A newer MADRE is on npm"></button>
26
27
  <button id="stop-all" class="stop-all" type="button" title="STOPALL · halt every plan and every agent turn" hidden>
27
28
  <span class="stop-all-glyph" aria-hidden="true"></span>STOP ALL
28
29
  </button>
@@ -74,6 +75,7 @@
74
75
  <button id="mother-close" class="mother-close" type="button">END SESSION ×</button>
75
76
  </span>
76
77
  </header>
78
+ <section id="mother-update" class="mother-section update"></section>
77
79
  <section id="mother-settings" class="mother-section settings" hidden></section>
78
80
  <section id="mother-sentinel" class="mother-section sentinel"></section>
79
81
  <pre id="mother-boot" class="mother-boot" aria-live="polite"></pre>