@jossuealcala/madre 0.4.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +85 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/public/app.js +158 -20
- package/public/es.js +210 -2
- package/public/index.html +2 -2
- package/public/styles.css +20 -3
- package/public/troubleshooting.js +87 -0
- package/src/adapters/process.mjs +62 -15
- package/src/mcp/image-server.mjs +24 -2
- package/src/modules/image-studio.mjs +3 -2
- package/src/modules/playwright.mjs +9 -5
- package/src/outbound.mjs +6 -1
- package/src/sentinel-errors.mjs +19 -1
- package/src/server.mjs +14 -6
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,91 @@ Todas las versiones publicadas de `@jossuealcala/madre`. Fechas en ISO.
|
|
|
4
4
|
|
|
5
5
|
Una versión se cierra cuando está en npm: hasta entonces su sección se llama **Sin publicar** y puede crecer. Cada versión publicada tiene exactamente una etiqueta `vX.Y.Z`, una release en GitHub y una sección aquí; el parche puede llegar a dos dígitos (`0.2.10`) antes de subir el menor. Ver `docs/ROADMAP.md` para el criterio de qué sube cada número.
|
|
6
6
|
|
|
7
|
+
## 0.4.1 · 2026-09-27
|
|
8
|
+
|
|
9
|
+
### MADRE tiene casa: madre.run
|
|
10
|
+
|
|
11
|
+
- El colector de errores vivía en `madre-reports.jossue-alcala-o.workers.dev`, y **ese host se le enseña al usuario** en el registro de salidas del núcleo. Un subdominio de `workers.dev` con un nombre de cuenta pegado parece un endpoint improvisado; `madre.run` parece el producto. La dirección es parte de lo que se le pide confiar a alguien.
|
|
12
|
+
- **La dirección vieja sigue contestando, y tiene que seguir.** Cada sala en 0.4.0 y anteriores la lleva compilada dentro: apagarla detendría sus reportes en silencio. Queda escrita en el código y en la prueba, en vez de desaparecer del registro.
|
|
13
|
+
- **Dos puertas para cada reporte humano, siempre.** Un fallo con traza pertenece a GitHub, donde se deduplica y la respuesta le sirve al siguiente; pero pedirle una cuenta a quien llegó de un grupo en español para decir «no me detecta Codex» es perderlo. En español manda el formulario y el issue queda a un clic; en inglés, al revés. Nadie choca con un muro de login con un reporte en la mano.
|
|
14
|
+
- Máquina y humano van a puertas distintas a propósito: `madre.run/v1/reports` para el centinela, `/reportar` y `/report` para personas. Mezclarlos ensucia lo que el autor lee.
|
|
15
|
+
- Y el logo —el del README y la marca de la barra— apunta a la casa del producto en vez de al sitio personal del autor. La atribución se queda donde corresponde, al pie del README.
|
|
16
|
+
- De paso salió un defecto que yo mismo introduje al cablear esto: un identificador de reporte inexistente devolvía 200, porque el formulario no necesita que el reporte exista para construirse. Ahora ninguna de las dos puertas se ofrece para algo que no está.
|
|
17
|
+
|
|
18
|
+
### Image Studio buscaba la llave donde MADRE no la guarda
|
|
19
|
+
|
|
20
|
+
- Y en la misma ficha, una línea estaba bajo la etiqueta equivocada: «se engancha solo dentro de un permiso de creación con el alcance de imagen encendido» no es algo que el módulo *escriba*, es una condición para que se enganche siquiera. Pasó a **NECESITA**, y de paso quedó redactada como requisito: «un permiso de creación con el alcance de imagen encendido; solo ahí se engancha» — bajo *necesita*, la frase anterior no concordaba.
|
|
21
|
+
|
|
22
|
+
- Cuando pegas una llave de Gemini en ⚙ CONEXIONES, MADRE la escribe en **`~/.gemini/.env`**, que es donde ese CLI la lee. Image Studio miraba tres sitios: `GEMINI_API_KEY`, `GOOGLE_API_KEY` y el llavero de macOS. **Ese archivo no estaba en la lista.** La sala guardaba una llave en un sitio donde después se negaba a mirar, y la ficha contestaba «no se encontró llave de Gemini» por una llave que acababa de escribir ella misma.
|
|
23
|
+
- **En Linux era total:** la rama del llavero es solo de macOS, así que `~/.gemini/.env` era el único lugar posible — y nunca se leía. Image Studio no podía encontrar una llave en Linux salvo exportando una variable a mano.
|
|
24
|
+
- Ahora el orden es: lo que se le dio a este proceso, después el archivo que el CLI lee en cada arranque —y que MADRE escribe—, y al final el llavero, que solo existe si el propio inicio de sesión del CLI lo puso ahí. Entiende la forma citada y la de `export`, y una asignación vacía no es una llave.
|
|
25
|
+
|
|
26
|
+
### Los remedios: prosa en español y un botón donde había un comando
|
|
27
|
+
|
|
28
|
+
- **44 de 53 condiciones explicaban su remedio en inglés.** No los comandos —un comando no se traduce— sino los **comentarios** que los acompañan: 145 líneas de prosa, y la mitad nombrando botones. `# ⚙ CONNECTIONS → Gemini → GENERATE IMAGES → SAVE` en una sala donde esos botones se llaman **CONEXIONES**, **GENERA IMÁGENES** y **GUARDAR**. El remedio mandaba a buscar cosas que no existen con ese nombre.
|
|
29
|
+
- Los 80 comentarios distintos están en español, con los nombres que el usuario de verdad ve. La guardia del catálogo ahora los recorre como recorre el título y el diagnóstico de cada condición — y aprendió que algunos se **componen** al cargar (el helper que arma un `export` los escribe), así que pregunta también a las condiciones y no solo al código fuente.
|
|
30
|
+
- **Ocho remedios eran un comando solo porque nadie había cableado el botón.** Firmar un agente, instalar uno, encender un alcance: MADRE ya hace las tres desde CONEXIONES. Esas condiciones ahora declaran `solvedIn: 'connections'` y su ficha abre el panel **en el agente del que se trata**, en vez de entregar una línea para pegar. Los comandos se quedan debajo, para otra máquina o para quien prefiera teclear.
|
|
31
|
+
- Lo que sigue en terminal, sigue a propósito: los siete remedios que tocan el perfil de tu shell. MADRE no debe editarte el `.zshrc` — ahí vive configuración que no escribió ella.
|
|
32
|
+
|
|
33
|
+
### El registro, legible: un icono que no estorba y un archivo que se manda
|
|
34
|
+
|
|
35
|
+
- **COPIAR se sentaba encima del comando que ofrecía.** Era una palabra flotando en la esquina del bloque, y cualquier comando largo le pasaba por debajo. Ahora es un icono de dos hojas, con su sitio reservado: el comando ya no corre bajo nada, y al copiar el icono se enciende en vez de convertirse en otra palabra.
|
|
36
|
+
- **La `×` de descartar se caía al renglón de abajo.** La fila del registro tenía cuatro columnas y al agregarle la salida quedaron cinco cosas. Ahora son cinco columnas, con aire entre ellas.
|
|
37
|
+
- **DESCARGAR EL REGISTRO.** Un CSV con una fila por condición: cuándo, qué agente, **el código de soporte**, la condición y el error. Se abre en cualquier hoja de cálculo y lleva BOM, para que los acentos no se rompan al abrirlo. Quien lo reciba no necesita tener MADRE abierta para leerlo.
|
|
38
|
+
|
|
39
|
+
### Entrar a NOSTROMO no puede dejarte encima de la salida
|
|
40
|
+
|
|
41
|
+
- Y el arreglo dejó su propia marca: al darle el foco al mapa, el navegador le dibujaba **un marco azul alrededor de toda la constelación**. Al mapa no se llega con Tab —está fuera del orden de tabulación a propósito—, así que ese anillo no le señalaba nada a nadie. Quitado; no se esconde ninguna pista al hacerlo, porque no hay adónde tabular.
|
|
42
|
+
|
|
43
|
+
- Escribías la designación, dabas Enter, y el foco quedaba en **SALIR**: el siguiente Enter te echaba de donde acababas de entrar. Un diálogo le entrega el foco a su primer hijo enfocable, y con COLD, PREGUNTAR y RECENTRAR ocultos, ese primero era la salida. Ahora lo toma el mapa, que es a lo que fuiste.
|
|
44
|
+
|
|
45
|
+
### El registro de errores: un código que se dicta, y una salida por fila
|
|
46
|
+
|
|
47
|
+
- **El badge de MU/TH/UR subía y nunca bajaba.** Cada condición registrada se reconstruye del ledger en cada carga, y no había forma de descartar ninguna: veintitantas alertas encima del botón, todas de cosas ya resueltas. Ahora cada fila del registro tiene su `×`, y cuando hay varias, un **DESCARTAR LOS N** de una vez.
|
|
48
|
+
- Descartar **no borra nada**: el ledger es historia y no se reescribe. Es una preferencia de lectura y vive donde viven las preferencias de lectura — este navegador —, atada a lo que el registro *es* (hora, agente, primera línea), así que la misma entrada reproducida del ledger en la siguiente carga sigue descartada. Y hay vuelta atrás: **VER LOS N DESCARTADOS** los trae todos de regreso.
|
|
49
|
+
- El saludo de MU/TH/UR y el badge cuentan ahora lo mismo: lo que sigue abierto.
|
|
50
|
+
- **Cada condición tiene su código de soporte**, `MU-001` a `MU-054`. Sale en la ficha y es lo que el botón del registro enseña, porque es lo que una persona dicta por teléfono cuando pide ayuda. El código vive **en la condición**, no en su posición en la lista: insertar una nueva en medio no renumera a las demás, que es justo lo que arruinaría un código de soporte. Con su prueba: todas lo tienen, ninguna lo repite, y un módulo que declara una condición declara su código con ella.
|
|
51
|
+
|
|
52
|
+
### Un número no es una razón
|
|
53
|
+
|
|
54
|
+
- `Claude exited with code 143.` se lee como que el agente se rompió. No se rompió: **143 es 128+15**, un CLI que atendió un SIGTERM y se despidió. Algo lo **detuvo** — y eso manda a buscar a un lugar completamente distinto que una caída.
|
|
55
|
+
- MADRE además **ignoraba la señal**: `child.on('close', (code) => …)` tiene un segundo argumento que dice qué señal cerró el proceso, y no se leía. Un proceso matado por señal llegaba como `código null`.
|
|
56
|
+
- Ahora las dos formas se dicen con palabras: SIGTERM es «algo le pidió que se fuera: PARAR TODO, un tiempo límite, o la sala cerrándose»; SIGKILL, «matado en seco, casi siempre la máquina quedándose sin memoria»; SIGINT, «un Ctrl+C le llegó». Un fallo ordinario conserva su número, que ahí sí es lo que diagnostica.
|
|
57
|
+
|
|
58
|
+
### PLAYWRIGHT nunca conectó, y npm se estaba comiendo las banderas
|
|
59
|
+
|
|
60
|
+
- Pedirle a un agente que use el navegador contestaba `CONNECTION_CLOSED: "Connection closed"`. La causa: MADRE lanza el servidor con `npx --no @playwright/mcp --headless --isolated …` y **npm se queda con esas banderas como configuración suya** —`Unknown cli config "--headless"`— y le entrega al servidor solo los valores sueltos. El servidor muere con `too many arguments. Expected 0 arguments but got 4` antes de hablar una palabra de MCP. Falta un `--` entre el paquete y sus banderas. Probado: sin el separador el servidor muere con código 1; con él contesta el saludo MCP.
|
|
61
|
+
- Debajo había un segundo: **el servidor y su navegador son dos cosas distintas**, y la ficha solo comprobaba el paquete. Un PLAYWRIGHT «encendido · chromium» puede no tener navegador, y entonces cada herramienta contesta `Browser "chrome-for-testing" is not installed`. El remedio que mostraba MADRE además ya no era el correcto: hoy el navegador lo baja el propio servidor (`npx @playwright/mcp install-browser chrome-for-testing`), no el CLI de playwright.
|
|
62
|
+
- Y un tercero al ir a traducir lo anterior: **las condiciones que declara un módulo no estaban en el catálogo español**. Viven en el archivo del módulo, no en `troubleshooting.js`, así que la guardia de las 51 nunca las vio y PLAYWRIGHT se explicaba en inglés dentro de una sala en español. La guardia ahora lee también los módulos.
|
|
63
|
+
|
|
64
|
+
### Un CLI que se actualiza solo debajo de una sala abierta
|
|
65
|
+
|
|
66
|
+
- En un QA salió `spawn /Applications/ChatGPT.app/Contents/Resources/codex ENOENT` con el binario **existiendo y siendo ejecutable**. La app de ChatGPT había actualizado su Codex a media sesión —de `0.154.0-alpha.6.2` a `0.155.0-alpha.9.2`— y MADRE seguía apuntando al que ya no estaba. Reabrir la sala encuentra el nuevo; sin saber eso, parece que el producto se rompió.
|
|
67
|
+
- Lo que hacía ilegible ese momento: **`spawn` falla de dos maneras y solo una estaba explicada.** La asíncrona levanta `error` en el proceso hijo y ahí sí había una frase con qué hacer. La síncrona **lanza antes de que exista un hijo al que escucharle**, así que se saltaba el manejador entero y a la sala llegaba el error crudo de node. Ahora las dos pasan por la misma explicación, y nombra la causa que nadie puede adivinar: un CLI que se actualiza solo puede moverse debajo de una sala ya abierta.
|
|
68
|
+
|
|
69
|
+
### Un aviso no es una causa
|
|
70
|
+
|
|
71
|
+
- Un usuario reportó que MADRE «solo funciona con APIs»: tenía Codex y Claude con sus planes, los dos con sesión iniciada, y ningún turno contestaba. Lo que leía era, en Claude, un aviso sobre `ANTHROPIC_API_KEY`; en Codex, la línea `Reading additional input from stdin...`. Ni una de las dos cosas era la razón.
|
|
72
|
+
- **El defecto estaba en cómo MADRE reporta un turno fallido.** Cuando un turno no producía respuesta, el mensaje era `stderr.trim() || …` — lo que hubiera quedado en stderr, y el `||` **descartaba lo único que diagnostica**: el código de salida, o el hecho de que el CLI salió limpio sin decir nada. Así un aviso sobre un ajuste que no venía al caso se volvía «la causa», y la causa real no llegaba a nadie. Para Codex era peor: su parser nunca devuelve un error propio, así que stderr era literalmente todo lo que quedaba.
|
|
73
|
+
- Ahora el mensaje dice primero **qué pasó** —`salió con código 3`, o `salió limpio sin respuesta`— y después, separado y etiquetado, **lo que el CLI imprimió**. Nunca uno disfrazado del otro. El código de salida viaja en el error para el centinela.
|
|
74
|
+
- Y MU/TH/UR aprendió las dos condiciones. La de Claude explica lo que el usuario no podía saber: MADRE le entrega al CLI el entorno tal como está esta computadora, así que una `ANTHROPIC_API_KEY` exportada en tu perfil **gana sobre el plan de claude.ai** y el turno se le cobra a la API. La de Codex dice lo contrario de lo que parece: esa línea es inofensiva, MADRE lo corre con stdin cerrado, y si el turno falló la razón es la línea de arriba.
|
|
75
|
+
|
|
76
|
+
### Lo que encontró quien la probó
|
|
77
|
+
|
|
78
|
+
- **`PULSE_LANGUAGE` no cambiaba la interfaz** — y el defecto era peor que eso. El servidor sí la respetaba al arrancar y también al presionar el botón; pero `/api/state`, que es de donde la página aprende qué idioma habla esta máquina, **volvía a leer el archivo de configuración** en vez de preguntar por el idioma ya resuelto. Con la variable puesta, la sala quedaba **partida**: los textos del servidor en inglés dentro de una interfaz en español. Una sala partida es peor que cualquiera de los dos idiomas. Ahora la página recibe el idioma que la sala está hablando, no el que está escrito en el archivo, y no hay una segunda expresión que pueda volver a discrepar.
|
|
79
|
+
- **«1 ARCHIVOS».** Un archivo creado no son archivos. El defecto venía del inglés —`created · 1 files` decía lo mismo— así que no era de la traducción: estaba ahí desde que existen los artefactos.
|
|
80
|
+
- **EYECAT hablaba inglés.** Sus dos hallazgos —«una nota que sus propias fuentes no sostienen» y «dos memorias que no pueden ser ciertas a la vez»— nunca se envolvieron. Eran frases enteras, no una palabra suelta.
|
|
81
|
+
- Y al buscar esa, apareció una cuarta: **la severidad de cada condición de MU/TH/UR se imprimía cruda**. Ocho fichas diciendo `blocking`, `degraded`, `transient`, `informational` en una pantalla por lo demás en español.
|
|
82
|
+
|
|
83
|
+
### La tercera vez que pasa lo mismo
|
|
84
|
+
|
|
85
|
+
- Las clases de memoria, el nivel de un aviso y ahora la severidad de una condición: las tres son **un id en el catálogo y una palabra en la pantalla**, y las tres se imprimieron crudas. La guardia del catálogo no puede verlas por construcción — nunca pasan por `t()`, así que no hay llave que falte. Ahora hay una guardia para **la forma**: falla si un `.kind`, `.level`, `.severity`, `.status` o `.stage` se escribe directo en un nodo, y exige que los tres mapas sigan declarados entrada por entrada, porque calcular la llave los esconde de las dos guardias a la vez.
|
|
86
|
+
- De paso salió un homónimo que un catálogo con llave en inglés no puede sostener: `FIXED` ya era **FIJO** —la parte del prompt que no cambia— y la severidad `fixed` quiere decir **resuelta**. La ficha en inglés pasó a decir `ALREADY FIXED`, que es más claro en los dos idiomas.
|
|
87
|
+
|
|
88
|
+
### No es un defecto
|
|
89
|
+
|
|
90
|
+
- **Los agentes contestan en tu idioma, no en el del mensaje.** MADRE sí pide el idioma: el bloque `style` del briefing les dice «escribe a la sala en el idioma en que escribe el humano» (`src/room/prompt.mjs:86`). Lo que pasa es que tus instrucciones globales —tu `CLAUDE.md`, tu `AGENTS.md`— **ganan sobre el briefing**, y así debe ser: son tuyas y son permanentes. Es la consecuencia directa de que MADRE use tus sesiones tal cual, que es justo lo que promete.
|
|
91
|
+
|
|
7
92
|
## 0.4.0 · 2026-09-26
|
|
8
93
|
|
|
9
94
|
### Actualizar de verdad, y una puerta para la comunidad
|
package/README.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<p align="center">
|
|
2
|
-
<a href="https://
|
|
2
|
+
<a href="https://madre.run"><img src="https://raw.githubusercontent.com/jossuealcacao-exe/madre/main/docs/madre-banner.svg" alt="MADRE · MU/TH/UR 6000 · INTERFACE 2037" width="100%"></a>
|
|
3
3
|
</p>
|
|
4
4
|
|
|
5
5
|
<p align="center">
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jossuealcala/madre",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.1",
|
|
4
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",
|
package/public/app.js
CHANGED
|
@@ -128,6 +128,17 @@ const state = {
|
|
|
128
128
|
};
|
|
129
129
|
try { state.chosenModel = JSON.parse(localStorage.getItem('pulse.chosenModel') ?? '{}') || {}; } catch { state.chosenModel = {}; }
|
|
130
130
|
|
|
131
|
+
// What the human has already dealt with. The ledger is history and is never rewritten, so a
|
|
132
|
+
// dismissal is a reading preference and lives where reading preferences live: this browser.
|
|
133
|
+
// Keyed by what a record IS — time, agent and the first line — so the same entry replayed from
|
|
134
|
+
// the ledger on the next load is still the one that was dismissed.
|
|
135
|
+
const dismissed = new Set();
|
|
136
|
+
try { for (const key of JSON.parse(localStorage.getItem('pulse.mother.dismissed') ?? '[]')) dismissed.add(key); } catch { /* no storage */ }
|
|
137
|
+
const failureKey = (failure) => `${failure.time ?? ''}|${failure.agent ?? ''}|${String(failure.error ?? '').split('\n')[0].slice(0, 120)}`;
|
|
138
|
+
const saveDismissed = () => { try { localStorage.setItem('pulse.mother.dismissed', JSON.stringify([...dismissed].slice(-400))); } catch { /* no storage */ } };
|
|
139
|
+
const openFailures = () => state.failures.filter((failure) => !failure.recovered && !dismissed.has(failureKey(failure)));
|
|
140
|
+
|
|
141
|
+
|
|
131
142
|
|
|
132
143
|
/* ---------- helpers ---------- */
|
|
133
144
|
|
|
@@ -2110,7 +2121,7 @@ function attachArtifacts(event) {
|
|
|
2110
2121
|
|
|
2111
2122
|
function artifactTiles(files) {
|
|
2112
2123
|
const wrap = el('div', 'artifacts');
|
|
2113
|
-
wrap.append(el('span', 'label', t('created · {n} files', { n: files.length })));
|
|
2124
|
+
wrap.append(el('span', 'label', t('created · {n} {files}', { n: files.length, files: files.length === 1 ? t('file') : t('files') })));
|
|
2114
2125
|
const tiles = el('div', 'files');
|
|
2115
2126
|
for (const file of files) {
|
|
2116
2127
|
const url = `/api/files?path=${encodeURIComponent(file.path)}`;
|
|
@@ -3276,27 +3287,63 @@ const mother = {
|
|
|
3276
3287
|
bootTimer: null,
|
|
3277
3288
|
};
|
|
3278
3289
|
|
|
3279
|
-
function
|
|
3280
|
-
state.failures.push(entry);
|
|
3281
|
-
const count = state.failures.filter((failure) => !failure.recovered).length;
|
|
3282
|
-
// Called while the transcript is still being replayed, before the MU/TH/UR
|
|
3283
|
-
// block below has initialised, so look the badge up directly.
|
|
3290
|
+
function paintMotherBadge() {
|
|
3284
3291
|
const badge = document.querySelector('#mother-count');
|
|
3285
3292
|
if (!badge) return;
|
|
3293
|
+
const count = openFailures().length;
|
|
3286
3294
|
badge.hidden = count === 0;
|
|
3287
3295
|
badge.textContent = String(count);
|
|
3288
3296
|
}
|
|
3289
3297
|
|
|
3298
|
+
function recordFailure(entry) {
|
|
3299
|
+
state.failures.push(entry);
|
|
3300
|
+
// Called while the transcript is still being replayed, before the MU/TH/UR
|
|
3301
|
+
// block below has initialised, so look the badge up directly.
|
|
3302
|
+
paintMotherBadge();
|
|
3303
|
+
}
|
|
3304
|
+
|
|
3305
|
+
// The recorded conditions as a file. One row per record, the columns a person would ask for,
|
|
3306
|
+
// and the support code beside each so it can be looked up without this room open.
|
|
3307
|
+
function downloadConditionLog(records) {
|
|
3308
|
+
const cell = (value) => `"${String(value ?? '').replace(/"/g, '""').replace(/\r?\n/g, ' ')}"`;
|
|
3309
|
+
const header = ['when', 'agent', 'code', 'condition', 'error'].map(cell).join(',');
|
|
3310
|
+
const rows = [...records].reverse().map((failure) => {
|
|
3311
|
+
const matches = diagnose(failure.error, failure.agent);
|
|
3312
|
+
return [
|
|
3313
|
+
failure.time ?? '',
|
|
3314
|
+
failure.agent ?? 'room',
|
|
3315
|
+
matches.map((condition) => condition.code ?? condition.id).join(' ') || 'unclassified',
|
|
3316
|
+
matches.map((condition) => condition.id).join(' '),
|
|
3317
|
+
resay(String(failure.error ?? '').trim()),
|
|
3318
|
+
].map(cell).join(',');
|
|
3319
|
+
});
|
|
3320
|
+
// A BOM, or a spreadsheet opens the accents as mojibake.
|
|
3321
|
+
const blob = new Blob([`\uFEFF${[header, ...rows].join('\n')}\n`], { type: 'text/csv;charset=utf-8' });
|
|
3322
|
+
const url = URL.createObjectURL(blob);
|
|
3323
|
+
const link = el('a');
|
|
3324
|
+
link.href = url;
|
|
3325
|
+
link.download = `madre-conditions-${new Date().toISOString().slice(0, 10)}.csv`;
|
|
3326
|
+
document.body.append(link);
|
|
3327
|
+
link.click();
|
|
3328
|
+
link.remove();
|
|
3329
|
+
setTimeout(() => URL.revokeObjectURL(url), 2000);
|
|
3330
|
+
}
|
|
3331
|
+
|
|
3290
3332
|
function commandBlock(lines) {
|
|
3291
3333
|
const block = el('div', 'mother-cmd');
|
|
3292
|
-
const copy = el('button', 'copy'
|
|
3334
|
+
const copy = el('button', 'copy');
|
|
3293
3335
|
copy.type = 'button';
|
|
3336
|
+
copy.title = t('COPY');
|
|
3337
|
+
copy.setAttribute('aria-label', t('COPY'));
|
|
3338
|
+
// Two sheets, drawn rather than written: a word here sat on top of the command it was offering.
|
|
3339
|
+
copy.innerHTML = '<svg viewBox="0 0 16 16" aria-hidden="true" width="12" height="12"><rect x="5.5" y="5.5" width="8" height="9" rx="1.5" fill="none" stroke="currentColor"/><path d="M10.5 3.5H3.5a1 1 0 0 0-1 1v7" fill="none" stroke="currentColor"/></svg>';
|
|
3294
3340
|
const runnable = lines.filter((line) => !line.trim().startsWith('#'));
|
|
3295
3341
|
copy.addEventListener('click', async () => {
|
|
3296
3342
|
try {
|
|
3297
3343
|
await navigator.clipboard.writeText(runnable.join('\n'));
|
|
3298
|
-
copy.
|
|
3299
|
-
|
|
3344
|
+
copy.classList.add('done');
|
|
3345
|
+
copy.title = t('COPIED');
|
|
3346
|
+
setTimeout(() => { copy.classList.remove('done'); copy.title = t('COPY'); }, 1500);
|
|
3300
3347
|
} catch {
|
|
3301
3348
|
copy.textContent = t('SELECT');
|
|
3302
3349
|
}
|
|
@@ -3304,19 +3351,47 @@ function commandBlock(lines) {
|
|
|
3304
3351
|
block.append(copy);
|
|
3305
3352
|
const pre = el('pre');
|
|
3306
3353
|
for (const line of lines) {
|
|
3307
|
-
|
|
3354
|
+
// A command is a command in every language and is never touched. A comment is not a command:
|
|
3355
|
+
// it is prose a person reads, and half of them name buttons — which in this room are called
|
|
3356
|
+
// something else. The catalogue's guard walks these the way it walks a condition's title.
|
|
3357
|
+
const comment = line.trim().startsWith('#');
|
|
3358
|
+
const span = el('span', comment ? 'c' : null, comment ? t(line.trim()) : line);
|
|
3308
3359
|
pre.append(span, '\n');
|
|
3309
3360
|
}
|
|
3310
3361
|
block.append(pre);
|
|
3311
3362
|
return block;
|
|
3312
3363
|
}
|
|
3313
3364
|
|
|
3365
|
+
// A condition's severity, like a memory's kind and a warning's level: an id in the catalogue and
|
|
3366
|
+
// a word on the screen. Keyed in upper case, which is how the tag reads and which keeps `WARNING`
|
|
3367
|
+
// the severity apart from `warning` the quota level — the same English word, two different rooms.
|
|
3368
|
+
const SEVERITY_WORDS = { blocking: t('BLOCKING'), common: t('COMMON'), degraded: t('DEGRADED'), fixed: t('ALREADY FIXED'), informational: t('INFORMATIONAL'), transient: t('TRANSIENT'), tunable: t('TUNABLE'), warning: t('WARNING') };
|
|
3369
|
+
const severityWord = (severity) => SEVERITY_WORDS[severity] ?? String(severity);
|
|
3370
|
+
|
|
3371
|
+
// Some remedies are a command only because nobody wired the button. Signing an agent in,
|
|
3372
|
+
// installing one, turning a scope on: MADRE already does all three from CONNECTIONS, so the
|
|
3373
|
+
// card opens that panel on the agent in question instead of handing out a line to paste. The
|
|
3374
|
+
// commands stay underneath for whoever prefers them, or is on another machine.
|
|
3375
|
+
function connectionsShortcut(condition, agent) {
|
|
3376
|
+
const who = condition.agent ?? agent ?? null;
|
|
3377
|
+
const jump = el('button', 'mother-goto', who ? t('OPEN ⚙ CONNECTIONS · @{agent}', { agent: who }) : t('OPEN ⚙ CONNECTIONS'));
|
|
3378
|
+
jump.type = 'button';
|
|
3379
|
+
jump.addEventListener('click', () => {
|
|
3380
|
+
rememberFold('connections', true);
|
|
3381
|
+
renderSettings();
|
|
3382
|
+
const target = who ? document.getElementById(`conn-${who}`) : settingsUI.section;
|
|
3383
|
+
setTimeout(() => target?.scrollIntoView({ behavior: 'smooth', block: 'center' }), 60);
|
|
3384
|
+
});
|
|
3385
|
+
return jump;
|
|
3386
|
+
}
|
|
3387
|
+
|
|
3314
3388
|
function conditionCard(condition, { hit = false, agent = null, hintAgent = null } = {}) {
|
|
3315
3389
|
const card = el('article', `mother-card${hit ? ' hit' : ''}`);
|
|
3316
3390
|
card.id = `mother-${condition.id}`;
|
|
3317
3391
|
const tags = el('div', 'tags');
|
|
3318
|
-
tags.append(el('span', `sev-${condition.severity}`, condition.severity));
|
|
3392
|
+
tags.append(el('span', `sev-${condition.severity}`, severityWord(condition.severity)));
|
|
3319
3393
|
if (condition.agent) tags.append(paint(el('span', 'agent', `@${condition.agent}`), condition.agent));
|
|
3394
|
+
if (condition.code) tags.append(el('span', 'code', condition.code));
|
|
3320
3395
|
tags.append(el('span', null, condition.id));
|
|
3321
3396
|
card.append(tags);
|
|
3322
3397
|
card.append(el('h4', null, condition.title));
|
|
@@ -3345,6 +3420,8 @@ function conditionCard(condition, { hit = false, agent = null, hintAgent = null
|
|
|
3345
3420
|
card.append(bar);
|
|
3346
3421
|
}
|
|
3347
3422
|
const chosen = agent ?? condition.agent ?? null;
|
|
3423
|
+
// The button first, the commands under it: this room can do it, and a terminal is a detour.
|
|
3424
|
+
if (condition.solvedIn === 'connections') card.append(connectionsShortcut(condition, chosen));
|
|
3348
3425
|
card.append(commandBlock(fixesFor(condition, mother.platform, chosen)));
|
|
3349
3426
|
if (condition.perAgent && !chosen) {
|
|
3350
3427
|
for (const id of Object.keys(condition.perAgent)) {
|
|
@@ -3374,14 +3451,41 @@ function renderMotherRecorded() {
|
|
|
3374
3451
|
let collapsed = false;
|
|
3375
3452
|
// What this section was set to before it folded like the rest; the fold remembers from here on.
|
|
3376
3453
|
try { collapsed = localStorage.getItem('pulse.mother.log') === 'collapsed'; } catch { /* no storage */ }
|
|
3377
|
-
const
|
|
3454
|
+
const shown = state.failures.filter((failure) => !dismissed.has(failureKey(failure)));
|
|
3455
|
+
const body = folding(mother.recorded, t('RECORDED CONDITIONS · THIS ROOM · {n}', { n: shown.length }), {
|
|
3378
3456
|
key: 'recorded', open: !collapsed,
|
|
3379
3457
|
});
|
|
3380
|
-
|
|
3458
|
+
// Everything dismissed is still in the ledger; this only stops it from being shown again.
|
|
3459
|
+
if (dismissed.size) {
|
|
3460
|
+
const back = el('button', 'mother-clear', t('SHOW THE {n} DISMISSED', { n: dismissed.size }));
|
|
3461
|
+
back.type = 'button';
|
|
3462
|
+
back.addEventListener('click', () => { dismissed.clear(); saveDismissed(); paintMotherBadge(); renderMotherRecorded(); });
|
|
3463
|
+
body.append(back);
|
|
3464
|
+
}
|
|
3465
|
+
if (shown.length) {
|
|
3466
|
+
// A file to send to whoever is helping. CSV because it opens in anything and each row
|
|
3467
|
+
// carries its support code: the reader does not have to know MADRE to read it.
|
|
3468
|
+
const save = el('button', 'mother-clear', t('DOWNLOAD THE LOG'));
|
|
3469
|
+
save.type = 'button';
|
|
3470
|
+
save.addEventListener('click', () => downloadConditionLog(shown));
|
|
3471
|
+
body.append(save);
|
|
3472
|
+
}
|
|
3473
|
+
if (shown.length > 1) {
|
|
3474
|
+
const all = el('button', 'mother-clear', t('DISMISS ALL {n}', { n: shown.length }));
|
|
3475
|
+
all.type = 'button';
|
|
3476
|
+
all.addEventListener('click', () => {
|
|
3477
|
+
for (const failure of shown) dismissed.add(failureKey(failure));
|
|
3478
|
+
saveDismissed();
|
|
3479
|
+
paintMotherBadge();
|
|
3480
|
+
renderMotherRecorded();
|
|
3481
|
+
});
|
|
3482
|
+
body.append(all);
|
|
3483
|
+
}
|
|
3484
|
+
if (!shown.length) {
|
|
3381
3485
|
body.append(el('p', 'mother-answer', t('NO CONDITIONS RECORDED. ALL SYSTEMS NOMINAL.')));
|
|
3382
3486
|
return;
|
|
3383
3487
|
}
|
|
3384
|
-
for (const failure of [...
|
|
3488
|
+
for (const failure of [...shown].reverse().slice(0, 40)) {
|
|
3385
3489
|
const rowNode = paint(el('div', 'mother-record'), failure.agent);
|
|
3386
3490
|
rowNode.append(el('span', 't', formatTime(failure.time)));
|
|
3387
3491
|
rowNode.append(el('span', 'a', failure.agent ?? t('room')));
|
|
@@ -3407,7 +3511,8 @@ function renderMotherRecorded() {
|
|
|
3407
3511
|
const links = el('span', 'k');
|
|
3408
3512
|
if (!matches.length) links.append(el('span', 'none', t('UNCLASSIFIED')));
|
|
3409
3513
|
for (const condition of matches) {
|
|
3410
|
-
const jump = el('button', null, condition.id);
|
|
3514
|
+
const jump = el('button', null, condition.code ?? condition.id);
|
|
3515
|
+
jump.title = condition.id;
|
|
3411
3516
|
jump.type = 'button';
|
|
3412
3517
|
jump.addEventListener('click', () => {
|
|
3413
3518
|
mother.input.value = '';
|
|
@@ -3419,6 +3524,17 @@ function renderMotherRecorded() {
|
|
|
3419
3524
|
links.append(jump);
|
|
3420
3525
|
}
|
|
3421
3526
|
rowNode.append(links);
|
|
3527
|
+
// One record, one way out. The ledger keeps it; this room stops counting it.
|
|
3528
|
+
const drop = el('button', 'drop', '×');
|
|
3529
|
+
drop.type = 'button';
|
|
3530
|
+
drop.title = t('Dismiss this record · it stays in the ledger');
|
|
3531
|
+
drop.addEventListener('click', () => {
|
|
3532
|
+
dismissed.add(failureKey(failure));
|
|
3533
|
+
saveDismissed();
|
|
3534
|
+
paintMotherBadge();
|
|
3535
|
+
renderMotherRecorded();
|
|
3536
|
+
});
|
|
3537
|
+
rowNode.append(drop);
|
|
3422
3538
|
body.append(rowNode);
|
|
3423
3539
|
}
|
|
3424
3540
|
}
|
|
@@ -3470,7 +3586,7 @@ function renderMother() {
|
|
|
3470
3586
|
function bootMother() {
|
|
3471
3587
|
clearTimeout(mother.bootTimer);
|
|
3472
3588
|
const online = [...state.agents.values()].filter((agent) => agent.ready).length;
|
|
3473
|
-
const open =
|
|
3589
|
+
const open = openFailures().length;
|
|
3474
3590
|
const lines = [
|
|
3475
3591
|
t('INTERFACE 2037 READY FOR INQUIRY'),
|
|
3476
3592
|
t('CREW: {n} AGENTS · {ready} READY · ROOM /{project}', { n: state.agents.size, ready: online, project: els.project.textContent }),
|
|
@@ -3566,7 +3682,7 @@ function renderEyecat(event) {
|
|
|
3566
3682
|
const node = el('div', 'command-card eyecat');
|
|
3567
3683
|
node.dataset.eyecat = finding.key;
|
|
3568
3684
|
const head = el('div', 'head');
|
|
3569
|
-
head.append(el('b', null, t('◉ EYECAT')), el('span', null, finding.kind === 'unsupported' ? 'a note its own sources do not support' : 'two memories that cannot both be true'));
|
|
3685
|
+
head.append(el('b', null, t('◉ EYECAT')), el('span', null, finding.kind === 'unsupported' ? t('a note its own sources do not support') : t('two memories that cannot both be true')));
|
|
3570
3686
|
if (finding.confidence !== null && finding.confidence !== undefined) head.append(el('span', 'args', t('{pct}% · judged by @{judge}', { pct: Math.round(finding.confidence * 100), judge: finding.judge })));
|
|
3571
3687
|
else head.append(el('span', 'args', t('judged by @{judge}', { judge: finding.judge })));
|
|
3572
3688
|
node.append(head);
|
|
@@ -5203,6 +5319,7 @@ function folding(section, title, { key, open = false, badge = null } = {}) {
|
|
|
5203
5319
|
|
|
5204
5320
|
function connectionCard(agent) {
|
|
5205
5321
|
const card = paint(el('article', 'conn-card'), agent.id);
|
|
5322
|
+
card.id = `conn-${agent.id}`;
|
|
5206
5323
|
const session = settingsUI.data.sessions?.[agent.id];
|
|
5207
5324
|
const head = el('div', 'head');
|
|
5208
5325
|
head.append(avatar(agent.id, { size: 26, status: agent.ready ? 'ready' : agent.detected ? 'detected' : 'offline' }));
|
|
@@ -5916,6 +6033,10 @@ async function openNostromo() {
|
|
|
5916
6033
|
if (!nostromo.dialog) return;
|
|
5917
6034
|
mother.dialog?.close?.();
|
|
5918
6035
|
nostromo.dialog.showModal();
|
|
6036
|
+
// A dialog hands focus to its first focusable child, and with COLD, ASK and RECENTER hidden
|
|
6037
|
+
// that was LEAVE: you pressed Enter to come in and the next Enter threw you out. The map is
|
|
6038
|
+
// what you came for, so the map takes it.
|
|
6039
|
+
nostromo.canvas?.focus?.({ preventScroll: true });
|
|
5919
6040
|
paintLegend();
|
|
5920
6041
|
nostromo.sub.textContent = t('MEMORY RESEARCH · LOADING…');
|
|
5921
6042
|
nostromo.card.hidden = true;
|
|
@@ -7595,12 +7716,13 @@ void loadVersion();
|
|
|
7595
7716
|
|
|
7596
7717
|
/* ---------- MU/TH/UR: the sentinel. Unknown conditions and crashes, redacted, ready to report. ---------- */
|
|
7597
7718
|
|
|
7598
|
-
const sentinelUI = { section: document.querySelector('#mother-sentinel'), settings: null, feedbackUrl: null, loaded: false };
|
|
7719
|
+
const sentinelUI = { section: document.querySelector('#mother-sentinel'), settings: null, feedbackUrl: null, formUrl: null, loaded: false };
|
|
7599
7720
|
async function loadSentinel() {
|
|
7600
7721
|
try {
|
|
7601
7722
|
const data = await fetch('/api/sentinel').then((response) => response.json());
|
|
7602
7723
|
sentinelUI.settings = data.settings;
|
|
7603
7724
|
sentinelUI.feedbackUrl = data.feedbackUrl;
|
|
7725
|
+
sentinelUI.formUrl = data.formUrl;
|
|
7604
7726
|
for (const report of data.reports ?? []) state.reports.set(report.id, report);
|
|
7605
7727
|
sentinelUI.loaded = true;
|
|
7606
7728
|
} catch { /* the room works without it */ }
|
|
@@ -7630,10 +7752,17 @@ function renderMotherSentinel() {
|
|
|
7630
7752
|
});
|
|
7631
7753
|
auto.append(box, t('AUTO-REPORT UNKNOWN CONDITIONS') + (settings.canSend ? '' : t(' · NO COLLECTOR CONFIGURED (PULSE_REPORT_URL)')));
|
|
7632
7754
|
controls.append(auto);
|
|
7633
|
-
|
|
7755
|
+
// Whoever arrived from a post in Spanish will not make a GitHub account to say "it does not
|
|
7756
|
+
// detect Codex". The form leads here; the issue stays one click away, and the other way round
|
|
7757
|
+
// in English, where the person reading this is already holding an account.
|
|
7758
|
+
const spanish = language() === 'es';
|
|
7759
|
+
const feedback = el('button', spanish ? null : 'primary', t('✎ FEEDBACK TO THE AUTHOR'));
|
|
7634
7760
|
feedback.type = 'button';
|
|
7635
7761
|
feedback.addEventListener('click', () => openFeedback());
|
|
7636
|
-
|
|
7762
|
+
const viaForm = el('button', spanish ? 'primary' : null, t('✎ REPORT ON MADRE.RUN ↗'));
|
|
7763
|
+
viaForm.type = 'button';
|
|
7764
|
+
viaForm.addEventListener('click', () => { if (sentinelUI.formUrl) window.open(sentinelUI.formUrl, '_blank', 'noopener'); });
|
|
7765
|
+
controls.append(spanish ? viaForm : feedback, spanish ? feedback : viaForm);
|
|
7637
7766
|
sentinelBody.append(controls);
|
|
7638
7767
|
for (const report of reports.slice(0, 20)) {
|
|
7639
7768
|
const rowNode = paint(el('div', `mother-record sentinel-report${report.sent?.ok ? ' sent' : ''}`), report.agent ?? 'room');
|
|
@@ -7646,6 +7775,8 @@ function renderMotherSentinel() {
|
|
|
7646
7775
|
if (report.count > 1) actions.append(el('span', 'none', `×${report.count}`));
|
|
7647
7776
|
actions.append(el('span', 'none', report.fingerprint));
|
|
7648
7777
|
if (report.sent?.ok) actions.append(el('span', 'none', t('SENT')));
|
|
7778
|
+
// A crash with a trace belongs in issues, where it deduplicates and the answer helps the next
|
|
7779
|
+
// one. The form is beside it for whoever has no account and would otherwise report nothing.
|
|
7649
7780
|
const issue = el('button', null, t('REPORT ON GITHUB ↗'));
|
|
7650
7781
|
issue.type = 'button';
|
|
7651
7782
|
issue.addEventListener('click', async () => {
|
|
@@ -7653,6 +7784,13 @@ function renderMotherSentinel() {
|
|
|
7653
7784
|
if (result.url) window.open(result.url, '_blank', 'noopener'); else toast(t('MU/TH/UR › no repository to file this in.'));
|
|
7654
7785
|
});
|
|
7655
7786
|
actions.append(issue);
|
|
7787
|
+
const form = el('button', null, t('OR ON MADRE.RUN ↗'));
|
|
7788
|
+
form.type = 'button';
|
|
7789
|
+
form.addEventListener('click', async () => {
|
|
7790
|
+
const result = await fetch(`/api/sentinel/${report.id}/issue`).then((response) => response.json()).catch(() => ({}));
|
|
7791
|
+
if (result.form) window.open(result.form, '_blank', 'noopener');
|
|
7792
|
+
});
|
|
7793
|
+
actions.append(form);
|
|
7656
7794
|
if (settings.canSend && !report.sent?.ok) {
|
|
7657
7795
|
const send = el('button', null, t('SEND REPORT'));
|
|
7658
7796
|
send.type = 'button';
|