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