@jossuealcala/madre 0.2.0 → 0.2.2
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 +28 -0
- package/CONTRIBUTING.md +38 -0
- package/README.md +19 -3
- package/SECURITY.md +19 -0
- package/package.json +4 -2
- package/public/app.js +179 -6
- package/public/index.html +2 -0
- package/public/styles.css +45 -0
- package/src/adapters/claude.mjs +3 -2
- package/src/distiller.mjs +3 -2
- package/src/room.mjs +15 -3
- package/src/sentinel-errors.mjs +185 -0
- package/src/server.mjs +49 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,34 @@
|
|
|
2
2
|
|
|
3
3
|
Todas las versiones publicadas de `@jossuealcala/madre`. Fechas en ISO.
|
|
4
4
|
|
|
5
|
+
## 0.2.2 · 2026-09-18
|
|
6
|
+
|
|
7
|
+
### Corrección crítica
|
|
8
|
+
- Claude Code negaba toda escritura en `#2 CREATE` y `#3 CONTROL`: las reglas `Write(/ruta/**)` se leían como relativas al proyecto. Ahora van como `//ruta/**`, la forma absoluta de Claude Code; las zonas prohibidas siguen bloqueadas antes del turno.
|
|
9
|
+
|
|
10
|
+
Reproducido con el CLI real en un repositorio temporal: con una barra el archivo quedaba bloqueado sin pregunta; con dos se escribe y `.env` sigue bloqueado. Sin otros cambios respecto a 0.2.1.
|
|
11
|
+
|
|
12
|
+
## 0.2.1 · 2026-09-18 · beta pública
|
|
13
|
+
|
|
14
|
+
Primera versión pensada para manos ajenas. Requiere Node 22.5 o superior.
|
|
15
|
+
|
|
16
|
+
### Monitoreo
|
|
17
|
+
- 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.
|
|
18
|
+
|
|
19
|
+
### Sala
|
|
20
|
+
- 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.
|
|
21
|
+
- Modo claro sin resplandor: la UX conserva sus colores y pierde el brillo de tubo; MU/TH/UR y NOSTROMO mantienen sus pantallas.
|
|
22
|
+
- 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.
|
|
23
|
+
- MADRE no repite el mismo juego de frases dos veces seguidas al tocar su corazón.
|
|
24
|
+
|
|
25
|
+
### Verdad y seguridad
|
|
26
|
+
- 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).
|
|
27
|
+
- 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.
|
|
28
|
+
|
|
29
|
+
### Proyecto
|
|
30
|
+
- CI en GitHub Actions: Ubuntu y macOS, Node 22 y 24, con pruebas, empaquetado e instalación del tarball.
|
|
31
|
+
- Plantillas de issues, `SECURITY.md` y `CONTRIBUTING.md`.
|
|
32
|
+
|
|
5
33
|
## 0.2.0 · 2026-09-17
|
|
6
34
|
|
|
7
35
|
Requiere Node 22.5 o superior (antes 20): la memoria de la sala corre sobre `node:sqlite`.
|
package/CONTRIBUTING.md
ADDED
|
@@ -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 ·
|
|
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.
|
|
293
|
+
Ver [CHANGELOG.md](CHANGELOG.md). La versión actual es 0.2.2, 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.
|
|
3
|
+
"version": "0.2.2",
|
|
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
|
-
|
|
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
|
-
|
|
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/adapters/claude.mjs
CHANGED
|
@@ -25,7 +25,8 @@ export function buildClaudeArgs({ prompt, model = null, attachmentsDir = null, l
|
|
|
25
25
|
];
|
|
26
26
|
const allowed = [
|
|
27
27
|
'Read', 'Glob', 'Grep',
|
|
28
|
-
|
|
28
|
+
// Claude Code reads `/path` as relative to the project and `//path` as an absolute path.
|
|
29
|
+
...(lease ? [`Write(//${lease.outDir}/**)`, `Edit(//${lease.outDir}/**)`] : []),
|
|
29
30
|
...(scopes?.web ? ['WebFetch', 'WebSearch'] : []),
|
|
30
31
|
...mcpTools,
|
|
31
32
|
];
|
|
@@ -43,7 +44,7 @@ export function buildClaudeArgs({ prompt, model = null, attachmentsDir = null, l
|
|
|
43
44
|
'--tools', tools.join(','),
|
|
44
45
|
...(lease || scopes?.web || anyMcp ? ['--allowedTools', allowed.join(',')] : []),
|
|
45
46
|
// CONTROL: the whole project is writable except MADRE's forbidden zones.
|
|
46
|
-
...(lease?.control ? ['--disallowedTools', ['.git/**', '.pulse/**', '.env', '.env.*', '**/.env', '**/.env.*'].flatMap((glob) => [`Write(
|
|
47
|
+
...(lease?.control ? ['--disallowedTools', ['.git/**', '.pulse/**', '.env', '.env.*', '**/.env', '**/.env.*'].flatMap((glob) => [`Write(//${lease.outDir}/${glob})`, `Edit(//${lease.outDir}/${glob})`]).join(',')] : []),
|
|
47
48
|
// --safe-mode disables every MCP server, ours included. With a MADRE server
|
|
48
49
|
// attached we drop it and instead load no setting sources at all: no user
|
|
49
50
|
// hooks, plugins or MCP servers, only the project's CLAUDE.md and ours.
|
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
|
-
|
|
16
|
-
|
|
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
|
|
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
|
-
|
|
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();
|