@jossuealcala/madre 0.2.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,34 @@ 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.3.0 · 2026-09-18
8
+
9
+ ### Ollama, la inteligencia local (roadmap 2a)
10
+ - Nuevo módulo `OLLAMA`: si Ollama corre en la máquina, los embeddings de la memoria se calculan localmente y la destilación la hace primero un modelo local, gratis y sin que nada salga. MODULES muestra servidor, modelos y roles, descarga los recomendados con `PULL` (progreso en la sala) y permite apagar cada rol o el módulo. Sin Ollama, todo sigue igual.
11
+ - El archivista local pide JSON estructurado y no cuenta contra ningún presupuesto de proveedor; el reporte `memory.distilled` dice `local`, modelo y tokens.
12
+ - Variables: `PULSE_EMBED_PROVIDER`, `PULSE_OLLAMA_HOST`, `PULSE_OLLAMA_MODEL`, `PULSE_OLLAMA_EMBED_MODEL`.
13
+
14
+ ### CONTROL: prevención antes que restauración
15
+ - Mientras un agente tiene CONTROL, los `.env`, `.pulse/`, `.madre/` y `.claude/settings.local.json` quedan en solo lectura a nivel de sistema de archivos y recuperan sus permisos al terminar; el aviso lista qué se bloqueó. `.git/` sigue restaurándose desde el checkpoint después del turno.
16
+
17
+ ### Núcleo
18
+ - `src/room.mjs` pasa de 1204 a 908 líneas: prompt, contexto, CONTROL, escalación, archivista, vectores, presupuesto, GHOST y adjuntos viven ahora en `src/room/`, cada uno con una responsabilidad. Mismo comportamiento, misma suite.
19
+
20
+ ### Dataset y modelo del proyecto (roadmap 2c)
21
+ - `EXPORT DATASET` en MEMORY y `madre dataset` en terminal escriben `train.jsonl` / `valid.jsonl` junto al ledger: los turnos reales de la sala como pares de chat redactados, más las notas destiladas como pares de recuerdo. `docs/training/` trae la receta LoRA con mlx-lm, el `Modelfile` y `train.sh`. Un modelo registrado en Ollama como `madre-<proyecto>` lo toma `@madre` automáticamente.
22
+
23
+ ### @madre, el quinto agente (roadmap 2b)
24
+ - Con Ollama y un modelo de chat, `@madre` entra a la sala: responde desde todo el archivo con citas `[#n]`, dice cuando algo nunca se discutió, nunca escribe ni delega, y sus tokens locales no cuentan. Los orquestadores pueden delegarle pasos de verificación. Entra y sale con Ollama (`agents.updated`); interruptor en MODULES → OLLAMA.
25
+ - El asistente de terminal y `madre doctor` muestran Ollama junto a las CLIs (servidor, modelo de chat, embeddings) y cuentan a `@madre` como agente en línea: con Ollama corriendo la sala abre aunque ninguna CLI tenga sesión, con el aviso de conectar una para trabajar en archivos.
26
+ - `@madre` ya no se confunde de identidad ni promete lo que no hace: la identidad se repite al final del briefing, cada llamada a Ollama pide una ventana de 8k tokens (la de 4k por defecto recortaba el prompt de sistema), las órdenes de acción (convocar, delegar, ejecutar, escribir) se contestan sin llamar al modelo señalando a los agentes CLI, y el chip `TO @madre` dice "memory · answers, does not act". Los modelos de chat generales (`qwen2.5`, `llama3.1`, `gemma3`) van antes que los `-coder`.
27
+ - README: seis módulos con OLLAMA y el SDK enlazado a CONTRIBUTING, enlaces absolutos para que npm los resuelva, estado real del adaptador de Claude (MCP de memoria), y bloque "Primeros cinco minutos" con el recorrido completo desde `npx` hasta conectar las IAs desde MU/TH/UR sin volver a la terminal.
28
+
29
+ ### SDK de módulos
30
+ - `src/modules/sdk.mjs` con `defineModule`: un módulo es un archivo con sus ajustes en `config.json`, su descripción para MODULES, su interruptor, sus rutas y sus hooks. Los seis módulos (AHP+, Image Studio, Git Pulse, AshCode, RIPLEY, Ollama) viven en `src/modules/`; `extensions.mjs` queda como capa de compatibilidad y el servidor monta las rutas de los módulos de forma genérica.
31
+
32
+ ### Memoria configurable desde MU/TH/UR
33
+ - Sección `MEMORY` en CONNECTIONS: archivista preferido, quiénes pueden destilar, cada cuántos intercambios o minutos de reposo, proveedor de embeddings y porcentaje de recall. Se guarda en `config.json` y se aplica en vivo.
34
+
7
35
  ## 0.2.3 · 2026-09-18
8
36
 
9
37
  ### RIPLEY navega
package/CONTRIBUTING.md CHANGED
@@ -19,12 +19,36 @@ node ./bin/madre.mjs doctor --catalog # what MU/TH/UR already knows
19
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
20
  - MU/TH/UR speaks in uppercase and in short sentences; the room speaks like a person. Keep both voices.
21
21
 
22
+ ## Writing a module
23
+
24
+ A module is one file in `src/modules/`, registered in `src/modules/index.mjs`:
25
+
26
+ ```js
27
+ import { defineModule } from './sdk.mjs';
28
+ export default defineModule({
29
+ id: 'night-vision', name: 'Night Vision', vendor: 'MADRE', summary: '…',
30
+ settings: { enabled: false, gain: 2 }, // lives in ~/.pulse/config.json → modules.nightVision
31
+ confirm: 'Send { "confirm": true }…', // optional: the switch asks first
32
+ async status(ctx) { return { detail: '…' }; },// optional: what MODULES shows
33
+ async onToggle(ctx, enabled) {}, // optional: apply live
34
+ routes: [{ method: 'GET', path: '/api/night-vision', handler: async (ctx, { payload }) => ({ status: 200, body: {} }) }],
35
+ });
36
+ ```
37
+
38
+ `ctx` carries `projectRoot`, `config`, `settings`, `agents`, `room`, `readConfig()`, `updateConfig(patch)`, `record(type, payload)` and `services` (what the server offers: `imageKey`, `setImageModule`, `ollama`). A builtin gets a default switch that flips `enabled`, persists and records `extension.toggled`. Add a card branch in `public/app.js` only if the generic switch is not enough, and a condition in `public/troubleshooting.js` so MU/TH/UR knows it.
39
+
22
40
  ## Where things live
23
41
 
24
42
  ```
25
43
  bin/madre.mjs the CLI · start, doctor, setup
26
44
  src/server.mjs HTTP + SSE, settings, modules, sentinel routes
27
- src/room.mjs turns, permission modes, plans, CONTROL, handoff, memory hooks
45
+ src/room.mjs the turn engine: send, dispatch, turns, plans, handoff, scopes
46
+ src/room/ its pieces: prompt (what an agent reads), context (transcript + recall),
47
+ control (checkpoint, diff, undo), escalation (waiting for the human),
48
+ archivist (distillation), vectors (embeddings), budget (token window),
49
+ ghost (off the record), attachments
50
+ src/modules/ one file per module on the SDK (sdk.mjs): ahp, image-studio, git-pulse, ashcode,
51
+ ripley, ollama; index.mjs is the registry, extensions.mjs the compatibility layer
28
52
  src/adapters/ one file per CLI: Codex, Claude Code, Gemini CLI, OpenCode
29
53
  src/memory.mjs SQLite index, distilled notes, vectors, recall
30
54
  src/distiller.mjs the archivist's prompt and parsing
package/README.md CHANGED
@@ -27,6 +27,15 @@ Una sola sala para conversar con los agentes de IA que ya están instalados en t
27
27
  npx @jossuealcala/madre start
28
28
  ```
29
29
 
30
+ **Primeros cinco minutos.** Node 22.5 o superior y al menos una de estas CLIs con sesión: Codex, Claude Code, Gemini CLI, OpenCode; u Ollama corriendo con un modelo de chat. Entra a la carpeta del proyecto y corre el comando de arriba.
31
+
32
+ 1. MADRE detecta qué CLIs tienes y quién tiene sesión. Si nadie está en línea, MU/TH/UR abre un asistente en la terminal: elige un número para correr el sign-in de esa CLI, `s` para abrir la sala.
33
+ 2. La sala abre en `http://127.0.0.1:4317`. Escribe a un agente con `TO @agente`; el modo del mensaje va en el chip de al lado (`#1 EXCHANGE` por defecto, solo lectura).
34
+ 3. Para conectar o reconectar una IA sin terminal: MU/TH/UR → `⚙ CONNECTIONS`. Codex y Claude tienen botón `SIGN IN`; Gemini y OpenCode muestran el comando exacto para su propio prompt. Ninguna credencial pasa por MADRE.
35
+ 4. Si tienes Ollama, MODULES → OLLAMA ya está encendido: la memoria se embebe localmente y `@madre` aparece en la fila.
36
+ 5. `⚙ CONNECTIONS → MEMORY` ajusta quién destila y cada cuánto; `◉ NOSTROMO` muestra lo que la sala recuerda.
37
+
38
+
30
39
  Tres nombres, tres capas. **MADRE** es el producto: lo que instalas, abres en el navegador y ves junto al logo del latido. **PULSE** es el canal sobre el que corre una sala: el registro de eventos, el bloque de delegación entre agentes, los homes aislados de cada CLI y la carpeta `.pulse/` donde caen los artefactos; por eso esos identificadores conservan su nombre. **MU/TH/UR** es la voz operativa dentro de MADRE: diagnóstico, conexiones y ajustes. El comando `pulse` sigue funcionando como alias de `madre`.
31
40
 
32
41
  ## Uso
@@ -208,22 +217,41 @@ Solo Codex genera imágenes de forma nativa. El módulo **Image Studio** (en `MO
208
217
 
209
218
  ## Módulos
210
219
 
211
- La sala ofrece módulos integrados de MADRE y una integración externa opcional. El botón `MODULES` de la barra lista los disponibles y su estado. Hoy hay cuatro:
220
+ La sala ofrece módulos integrados de MADRE y una integración externa opcional. El botón `MODULES` de la barra lista los disponibles y su estado. Hoy hay seis:
212
221
 
213
222
  - **Git Pulse** (integrado, sin instalación): `/git status`, `/git log [n]`, `/git diff` y `/git branches` traen a la sala la rama, los cambios sin confirmar, los últimos commits o el resumen del diff, en solo lectura y sin gastar un turno de agente. La tarjeta queda en el registro como `command.output` y entra en el contexto que reciben los agentes, así todos razonan sobre los mismos hechos del repositorio. Requiere que el proyecto sea un repositorio git.
214
223
  - **Image Studio** (integrado): ver la sección anterior.
215
224
  - **RIPLEY** (integrado): actívalo en `MODULES` y el visor de archivos renderiza HTML, SVG y Markdown en lugar de mostrar su código, con un botón `PREVIEW`/`SOURCE` en la cabecera. HTML y SVG se sirven por `/preview/project/<ruta>` dentro de un marco sellado: los scripts de la página sí corren, pero el marco no tiene origen propio (`sandbox allow-scripts`, sin `same-origin`), no puede hacer peticiones de red, ni enviar formularios, ni navegar la ventana de MADRE, y solo carga CSS, JS, imágenes y fuentes del propio proyecto a través de MADRE. Las rutas relativas de la página funcionan, el visor tiene atrás y recargar, se recarga solo cuando un agente cambia la página abierta, y los errores de la página aparecen en una franja con `ASK THE ROOM`. Markdown se renderiza en el propio visor. Apagado, esos archivos se muestran como texto con una nota. Sirve para ver lo que los agentes crean en `.pulse/out/` sin salir de la sala.
225
+ - **OLLAMA** (integrado): encendido solo si Ollama corre en la máquina. Muestra servidor, modelos y roles (embeddings, archivista, `@madre`), descarga los recomendados con `PULL` y deja apagar cada rol. Ver [Ollama: la inteligencia local](#ollama-la-inteligencia-local).
216
226
  - **AshCode** (integrado, beta): actívalo en `MODULES` para mostrar el botón `$ ash_code` en el campo de texto. Cuando está iluminado, MADRE intenta abreviar localmente mensajes en español o inglés antes de enviarlos a un agente, y pide respuestas concisas. La burbuja muestra el texto enviado y permite desplegar el original; las respuestas abreviadas también conservan el original. Se omite la transformación si detecta código, rutas, enlaces, negaciones, cifras, estructura compleja, idioma incierto o ninguna reducción segura. **Puede cambiar el significado o producir errores: revisa siempre el original y la respuesta.** Menos caracteres no demuestra menos tokens facturados; consulta el uso real del proveedor. Al encenderlo, el campo se ensancha como con CREATE, la etiqueta pasa a `MU/TH/UR · SPECIAL ORDER 937 ›` y la caja hace un guiño a MADRE: un barrido CRT con líneas de fósforo en verde Homebrew (el verde del perfil Homebrew de Terminal.app), que también viste el botón como un prompt de bash con cursor parpadeante. Se apaga desde `ORDER 937` para el siguiente mensaje o se deshabilita en `MODULES`. No requiere npm ni modifica el proyecto: su estado se guarda en la configuración local de MADRE.
217
227
  - **AHP+** (`@jossuealcala/ahp-plus`): estado verificado del proyecto, checkpoints y handoffs entre sesiones de IA, guardado en `.ahp/`. MADRE lo detecta por `.ahp/manifest.json` y lo instala con `npx --yes @jossuealcala/ahp-plus@1.4.1 setup . --platforms <agentes detectados>`, pidiendo adaptadores solo para los agentes presentes en la máquina que AHP+ soporta (Codex, Claude, OpenCode). Una vez instalado, `/ahp status`, `/ahp check` y `/ahp context` consultan su estado desde el campo de texto.
218
228
 
229
+ Cada módulo es un archivo en `src/modules/` declarado con `defineModule`: sus ajustes en `config.json`, su ficha para `MODULES`, su interruptor, sus rutas y sus hooks. Cómo escribir uno, en [CONTRIBUTING.md](https://github.com/jossuealcacao-exe/madre/blob/main/CONTRIBUTING.md#writing-a-module).
230
+
219
231
  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.
220
232
 
221
233
  ## Modelo de amenazas, en corto
222
234
 
223
235
  - **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.
236
+ - **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 MADRE añade prevención propia para todos los agentes: mientras dura el turno, los `.env`, `.pulse/`, `.madre/` y `.claude/settings.local.json` quedan en solo lectura a nivel de sistema de archivos y recuperan sus permisos al terminar; el aviso de CONTROL lista qué quedó bloqueado. `.git/` sigue escribible porque las CLIs lo necesitan, y por eso sigue restaurándose desde el checkpoint **después del turno**. Un agente que cambie permisos a propósito cae en esa restauración.
225
237
  - **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
238
 
239
+ ## Memoria desde MU/TH/UR
240
+
241
+ En `⚙ CONNECTIONS → MEMORY` se ajusta la memoria sin terminal y se aplica al siguiente turno: quién destila primero (`AUTO` elige al más barato permitido, con Ollama al frente cuando corre), quiénes pueden destilar, cada cuántos intercambios o tras cuántos minutos de reposo, dónde se calculan los embeddings (`AUTO`, `OLLAMA`, `GEMINI`, `OFF`) y qué parte del contexto puede ocupar el recall. Todo queda en `~/.pulse/config.json` bajo `memory`; las variables de entorno, si están puestas, mandan al siguiente arranque.
242
+
243
+ ## @madre, el quinto agente
244
+
245
+ Cuando Ollama corre con un modelo de chat, aparece en la sala un quinto agente: `@madre`. Es la memoria del proyecto convertida en interlocutor: responde desde el archivo completo, notas destiladas y citas exactas con su secuencia `[#n]`, más lo que lleve el mensaje. No escribe, no dibuja, no navega ni delega; cuando la sala nunca habló de algo, lo dice. Los demás agentes pueden delegarle un paso para comprobar qué se decidió. Sus tokens son locales y no cuentan contra ningún presupuesto. Entra y sale con Ollama, y MU/TH/UR lo anuncia; el interruptor `@MADRE IN THE ROOM` en MODULES → OLLAMA lo apaga.
246
+
247
+ ## El modelo del proyecto
248
+
249
+ `MEMORY → EXPORT DATASET` (o `madre dataset`) escribe junto al ledger los turnos reales de la sala como pares de chat redactados, más las notas destiladas, en el formato que leen `mlx-lm` y los demás entrenadores. [`docs/training/`](https://github.com/jossuealcacao-exe/madre/blob/main/docs/training/README.md) explica cómo entrenar un LoRA local sobre un modelo pequeño y registrarlo en Ollama como `madre-<proyecto>`; en cuanto existe, `@madre` responde con él. Es la destilación de MADRE AI: la sala produce el dato, tú decides cuándo entrenar, y el modelo se evalúa contra lo que la sala sí decidió antes de confiar en él.
250
+
251
+ ## Ollama: la inteligencia local
252
+
253
+ Si Ollama corre en la máquina, MADRE lo usa sin configurar nada: los embeddings de la memoria se calculan localmente con el modelo de embeddings disponible (`nomic-embed-text` recomendado) y la destilación de memorias la hace primero el modelo local (`qwen2.5:7b` en una Mac de 16 GB, `qwen2.5:3b` en 8 GB; cualquiera de la familia Qwen, Llama o Gemma sirve, y MADRE prefiere los de chat general sobre los `-coder`), antes que Gemini y el resto. Recordar deja de costar tokens y nada del proyecto sale de la máquina para eso. El módulo `OLLAMA` en MODULES muestra qué hay, permite descargar los modelos recomendados con `PULL`, apagar cada rol y desactivar el módulo. Sin Ollama, todo sigue como antes. `PULSE_OLLAMA_HOST`, `PULSE_OLLAMA_MODEL` y `PULSE_OLLAMA_EMBED_MODEL` eligen host y modelos; `PULSE_EMBED_PROVIDER=gemini` mantiene los embeddings en Gemini aunque Ollama exista.
254
+
227
255
  ## Sentinel de errores y feedback
228
256
 
229
257
  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.
@@ -250,7 +278,12 @@ Cada agente tiene un timeout de 180 s por defecto; la burbuja de espera muestra
250
278
  | `PULSE_DISTILL_MAX_CHARS` | `6000` | Tamaño máximo del lote que lee el destilador |
251
279
  | `PULSE_DISTILL_AGENT` | — | Agente destilador preferido; por defecto el más barato disponible |
252
280
  | `PULSE_DISTILL_MODEL` | — | Modelo para la destilación |
253
- | `PULSE_EMBED` | `1` | Embeddings con la clave de Gemini para recall por significado (`0` lo apaga) |
281
+ | `PULSE_EMBED` | `1` | Embeddings para recall por significado (`0` lo apaga) |
282
+ | `PULSE_EMBED_PROVIDER` | `auto` | `ollama`, `gemini` o `auto` (Ollama si corre con modelo de embeddings, si no Gemini) |
283
+ | `PULSE_OLLAMA` | `1` | `0` ignora Ollama por completo |
284
+ | `PULSE_OLLAMA_HOST` | `http://127.0.0.1:11434` | Dónde escucha Ollama |
285
+ | `PULSE_OLLAMA_MODEL` | el mejor disponible | Modelo local para destilar |
286
+ | `PULSE_OLLAMA_EMBED_MODEL` | el mejor disponible | Modelo local de embeddings |
254
287
  | `PULSE_EMBED_MODEL` | `gemini-embedding-001` | Modelo de embeddings |
255
288
  | `PULSE_EMBED_DIMS` | `768` | Dimensiones del vector |
256
289
  | `PULSE_MEMORY_TOOLS` | `1` | Servidor MCP `pulse-memory` adjunto a cada turno (`0` lo quita) |
@@ -283,14 +316,14 @@ Gemini CLI lee `@algo` en el prompt como un archivo a incluir, incluso en modo h
283
316
 
284
317
  - Codex: consulta de solo lectura habilitada.
285
318
  - OpenCode: consulta restringida habilitada; MADRE inyecta permisos efímeros y no modifica la configuración global. Si tu configuración de OpenCode no fija modelo, `run` elige el proveedor por defecto, que puede no ser el que tiene sesión válida; fija `PULSE_OPENCODE_MODEL=proveedor/modelo` (por ejemplo `openai/gpt-5.6-sol`) al arrancar MADRE.
286
- - Claude Code: consulta restringida habilitada con Safe Mode, herramientas locales de lectura, MCP desactivado y sesiones no persistentes.
319
+ - Claude Code: consulta restringida habilitada con Safe Mode, herramientas locales de lectura, MCP limitado al servidor de memoria de MADRE (`pulse-memory`) y sesiones no persistentes.
287
320
  - Gemini CLI: consulta restringida habilitada con Plan Mode y una política efímera que solo permite herramientas locales de lectura. MADRE ejecuta Gemini con un `GEMINI_CLI_HOME` temporal que solo recibe las credenciales existentes (tokens OAuth y `~/.gemini/.env`; una API key guardada en el llavero del sistema funciona sin copia); hooks, extensiones, servidores MCP y memoria del `~/.gemini` real no se cargan. El relanzamiento interno del CLI se desactiva para que el timeout controle el proceso que hace la petición.
288
321
 
289
322
  Todos los adaptadores corren en su propio grupo de procesos. Si un agente no responde antes del timeout, MADRE termina el árbol completo (SIGTERM y, tras un periodo de gracia, SIGKILL), no solo el lanzador.
290
323
 
291
324
  ## Cambios
292
325
 
293
- Ver [CHANGELOG.md](CHANGELOG.md). La versión publicada es 0.2.2 y la siguiente en preparación es 0.2.3, 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).
326
+ Ver [CHANGELOG.md](https://github.com/jossuealcacao-exe/madre/blob/main/CHANGELOG.md) y el [roadmap](https://github.com/jossuealcacao-exe/madre/blob/main/docs/ROADMAP.md). La versión actual es 0.3.0, 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](https://github.com/jossuealcacao-exe/madre/blob/main/SECURITY.md).
294
327
 
295
328
  ## Licencia
296
329
 
package/SECURITY.md CHANGED
@@ -13,7 +13,7 @@ Please include the MADRE version (`madre doctor --json`), the platform, which ag
13
13
 
14
14
  ## Known, accepted for the beta
15
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.
16
+ - In `#3 CONTROL`, `.env` files, `.pulse/`, `.madre/` and `.claude/settings.local.json` are made read-only for the length of the turn and restored from the checkpoint afterwards; `.git/` stays writable because the CLIs need it and is only restored after the turn. An agent that changes permissions on purpose is caught by that restoration, not prevented.
17
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
18
 
19
19
  NOBODY DELETES MOTHER'S MEMORY. EVERYTHING ELSE IS FAIR GAME.
package/bin/madre.mjs CHANGED
@@ -79,15 +79,38 @@ if (command === 'doctor' && (has('--catalog') || has('--conditions'))) {
79
79
  console.log('');
80
80
  }
81
81
  process.exitCode = 0;
82
+ } else if (command === 'dataset') {
83
+ // Export the room's dataset without opening the room: the same files the server writes.
84
+ const { createHash } = await import('node:crypto');
85
+ const { join, basename, resolve: resolvePath } = await import('node:path');
86
+ const { homedir } = await import('node:os');
87
+ const { realpath } = await import('node:fs/promises');
88
+ const { EventStore } = await import('../src/event-store.mjs');
89
+ const { RoomMemory } = await import('../src/memory.mjs');
90
+ const { exportDataset } = await import('../src/dataset.mjs');
91
+ const root = stateRoot ?? process.env.PULSE_HOME ?? join(homedir(), '.pulse');
92
+ const canonical = await realpath(projectRoot).catch(() => resolvePath(projectRoot));
93
+ const roomId = `${basename(canonical) || 'root'}-${createHash('sha256').update(canonical).digest('hex').slice(0, 16)}`;
94
+ const roomDir = join(root, 'rooms', roomId);
95
+ const store = await new EventStore(join(roomDir, 'events.jsonl')).initialize();
96
+ const memory = await new RoomMemory(join(roomDir, 'memory.sqlite')).initialize(store).catch(() => null);
97
+ const result = await exportDataset({ events: await store.readAll(), notes: memory ? memory.memories({ limit: 5000 }) : [], dir: join(roomDir, 'dataset'), project: basename(canonical), home: homedir() });
98
+ memory?.close();
99
+ if (has('--json')) console.log(JSON.stringify(result, null, 2));
100
+ else console.log(`\nMADRE dataset · ${result.project}\n\n pairs ${result.pairs} (${result.turns} turns · ${result.notes} notes)\n train ${result.train}\n valid ${result.valid}\n by agent ${Object.entries(result.byAgent).map(([id, n]) => `@${id} ${n}`).join(' · ') || '-'}\n folder ${result.dir}\n\n Train it: docs/training/README.md (mlx-lm on Apple Silicon), then \`ollama create madre-${result.project.toLowerCase().replace(/[^a-z0-9]+/g, '-')}\` and @madre picks it up.\n`);
101
+ process.exitCode = 0;
82
102
  } else if (command === 'doctor') {
83
103
  const agents = await detectAgents();
84
104
  const probes = await probeAll(agents);
105
+ const { localIntelligence, madreOnline } = await import('../src/setup.mjs');
106
+ const ollama = await localIntelligence();
85
107
  const report = agents.map((agent) => ({ ...agent, session: probes[agent.id] }));
86
108
  const result = {
87
- ok: report.some((agent) => isOnline(agent, agent.session)),
109
+ ok: report.some((agent) => isOnline(agent, agent.session)) || madreOnline(ollama),
88
110
  node: process.version,
89
111
  project: projectRoot,
90
112
  agents: report,
113
+ ollama: { running: Boolean(ollama.running), chatModel: ollama.chatModel ?? null, embedModel: ollama.embedModel ?? null, madre: madreOnline(ollama) },
91
114
  };
92
115
 
93
116
  if (has('--json')) {
@@ -99,6 +122,7 @@ if (command === 'doctor' && (has('--catalog') || has('--conditions'))) {
99
122
  const session = agent.detected ? ` · ${agent.session.state}${agent.session.detail ? ` (${agent.session.detail})` : ''}` : '';
100
123
  console.log(` ${agent.label.padEnd(10)} ${mark}${agent.version ? ` · ${agent.version}` : ''}${session}`);
101
124
  }
125
+ 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'}`);
102
126
  console.log(`\n Project ${result.project}`);
103
127
  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');
104
128
  }
@@ -118,7 +142,8 @@ if (command === 'doctor' && (has('--catalog') || has('--conditions'))) {
118
142
  if (process.stdin.isTTY && process.stdout.isTTY && !has('--no-setup')) {
119
143
  const agents = await detectAgents();
120
144
  const probes = await probeAll(agents);
121
- if (!agents.some((agent) => isOnline(agent, probes[agent.id]))) {
145
+ const { localIntelligence, madreOnline } = await import('../src/setup.mjs');
146
+ if (!agents.some((agent) => isOnline(agent, probes[agent.id])) && !madreOnline(await localIntelligence())) {
122
147
  const { action } = await runSetup({ projectRoot, stateRoot });
123
148
  if (action !== 'start') process.exit(0);
124
149
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jossuealcala/madre",
3
- "version": "0.2.3",
3
+ "version": "0.3.0",
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",
@@ -55,7 +55,7 @@
55
55
  "scripts": {
56
56
  "start": "node ./bin/madre.mjs start",
57
57
  "doctor": "node ./bin/madre.mjs doctor",
58
- "test": "node --test",
58
+ "test": "node --import ./test/setup.mjs --test",
59
59
  "check": "npm test && node ./bin/madre.mjs doctor --json",
60
60
  "pack:check": "node ./scripts/pack-check.mjs",
61
61
  "release": "node ./scripts/release.mjs"
package/public/app.js CHANGED
@@ -45,6 +45,7 @@ const PLACEHOLDERS = {
45
45
  ghost: 'Off the record. Ask anything; nothing is saved, nobody else will remember it.',
46
46
  control: 'Control armed. Say what to change in the project; every action runs without asking.',
47
47
  expendable: 'Type here, human. MOTHER is listening.',
48
+ memory: 'Ask what the room remembers. @madre answers from memory with citations; it does not act.',
48
49
  };
49
50
  let winkTimer = null;
50
51
  const LINE_PX = 21;
@@ -727,6 +728,11 @@ function renderPicker() {
727
728
  const text = paint(el('span', 'pick-label'), current.id);
728
729
  text.append('to ');
729
730
  text.append(el('b', null, `@${current.id}`));
731
+ if (current.local) {
732
+ const note = el('span', 'pick-note', 'memory · answers, does not act');
733
+ note.title = '@madre runs on this machine and speaks only for what the room said. To convene the crew or change files, write to a CLI agent.';
734
+ text.append(note);
735
+ }
730
736
  const modeChip = el('button', `mode-chip m${state.mode}`);
731
737
  modeChip.type = 'button';
732
738
  modeChip.append(el('b', null, `#${state.mode}`), `${MODES[state.mode].label} ▾`);
@@ -1771,6 +1777,20 @@ function renderEventNode(event) {
1771
1777
  case 'memory.distilled': node = renderDistilled(event); break;
1772
1778
  case 'memory.forgotten': node = renderForgotten(event); break;
1773
1779
  case 'memory.noted': attachMemoryHint(event); return;
1780
+ case 'dataset.exported': return;
1781
+ case 'agents.updated': {
1782
+ for (const agent of event.payload.agents ?? []) {
1783
+ const known = state.agents.get(agent.id);
1784
+ state.agents.set(agent.id, { ...(known ?? { tokens: 0, rawTokens: 0, windowMs: null, rollsOverAt: null, officialPercent: null, officialResetAt: null, officialWindows: null }), ...agent });
1785
+ const option = [...els.target.options].find((item) => item.value === agent.id);
1786
+ if (agent.ready && !option) els.target.add(new Option(agent.label, agent.id));
1787
+ if (!agent.ready && option) option.remove();
1788
+ }
1789
+ for (const id of event.payload.removed ?? []) { state.agents.delete(id); [...els.target.options].find((item) => item.value === id)?.remove(); }
1790
+ renderAgents(); renderPicker(); renderOnboarding();
1791
+ if (!replaying && event.payload.reason) toast(`MU/TH/UR › ${event.payload.reason}`);
1792
+ return;
1793
+ }
1774
1794
  case 'mother.alert': node = renderMotherAlert(event); break;
1775
1795
  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;
1776
1796
  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; }
@@ -2322,7 +2342,8 @@ function updateCrewLabel() {
2322
2342
  : state.expendable ? 'CREW · EXPENDABLE ›' : 'HUMAN ›';
2323
2343
  }
2324
2344
  function updatePlaceholder() {
2325
- els.input.placeholder = state.mode === 3 ? PLACEHOLDERS.control : state.mode === 0 ? PLACEHOLDERS.ghost : state.create ? PLACEHOLDERS.create : (state.ashCode && state.ashCodeInstalled) ? PLACEHOLDERS.order : state.expendable ? PLACEHOLDERS.expendable : PLACEHOLDERS.plain;
2345
+ const local = Boolean(state.agents.get(els.target.value)?.local);
2346
+ els.input.placeholder = state.mode === 3 ? PLACEHOLDERS.control : state.mode === 0 ? PLACEHOLDERS.ghost : local ? PLACEHOLDERS.memory : state.create ? PLACEHOLDERS.create : (state.ashCode && state.ashCodeInstalled) ? PLACEHOLDERS.order : state.expendable ? PLACEHOLDERS.expendable : PLACEHOLDERS.plain;
2326
2347
  }
2327
2348
  function setOrder937(on, { wink = false } = {}) {
2328
2349
  state.ashCode = on;
@@ -2813,6 +2834,61 @@ function builtinCard(item) {
2813
2834
  for (const line of item.creates ?? []) list.append(el('li', null, line));
2814
2835
  for (const line of item.requires ?? []) list.append(el('li', null, `requires ${line}`));
2815
2836
  card.append(list);
2837
+ if (item.id === 'ollama') {
2838
+ const info = item.ollama ?? { running: false, models: [], settings: {} };
2839
+ const status = el('dl', 'ollama-status');
2840
+ const put = (k, v) => { status.append(el('dt', null, k), el('dd', null, v)); };
2841
+ put('SERVER', info.running ? `running · ${info.host}` : 'not running');
2842
+ put('EMBEDDINGS', info.embedModel ? `${info.embedModel}${info.settings.embeddings === false ? ' · off' : ''}` : 'no embedding model');
2843
+ put('ARCHIVIST', info.chatModel ? `${info.chatModel}${info.settings.archivist === false ? ' · off' : ''}` : 'no chat model');
2844
+ if (info.models?.length) put('MODELS', info.models.map((model) => model.name).join(', '));
2845
+ card.append(status);
2846
+ const actions = el('div', 'actions');
2847
+ const recheck = el('button', null, 'RECHECK');
2848
+ recheck.type = 'button';
2849
+ recheck.addEventListener('click', async () => { recheck.disabled = true; await fetch('/api/ollama/probe', { method: 'POST' }).catch(() => null); await refreshModules(); });
2850
+ actions.append(recheck);
2851
+ if (info.running) {
2852
+ const toggle = el('button', on ? null : 'primary', info.settings.enabled === false ? 'ENABLE OLLAMA' : 'DISABLE OLLAMA');
2853
+ toggle.type = 'button';
2854
+ toggle.addEventListener('click', async () => {
2855
+ toggle.disabled = true;
2856
+ try {
2857
+ const response = await fetch('/api/extensions/ollama/install', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' });
2858
+ const result = await response.json().catch(() => ({}));
2859
+ if (!response.ok) throw new Error(result.error ?? `HTTP ${response.status}`);
2860
+ toast(result.enabled ? 'MU/TH/UR › OLLAMA ON · memory embeds and distils on this machine.' : 'MU/TH/UR › OLLAMA OFF · back to the providers.');
2861
+ await refreshModules();
2862
+ } catch (error) { toast(`Ollama could not change state: ${error.message}`); toggle.disabled = false; }
2863
+ });
2864
+ actions.append(toggle);
2865
+ for (const [role, model, present] of [['embeddings', item.recommended?.embed ?? 'nomic-embed-text', Boolean(info.embedModel)], ['archivist', item.recommended?.chat ?? 'qwen2.5:3b', Boolean(info.chatModel)], ['agent', item.recommended?.chat ?? 'qwen2.5:3b', Boolean(info.chatModel)]]) {
2866
+ if (present) {
2867
+ const box = el('label', 'toggle');
2868
+ const input = el('input'); input.type = 'checkbox'; input.checked = info.settings[role] !== false;
2869
+ input.addEventListener('change', async () => { input.disabled = true; await fetch('/api/ollama/settings', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ [role]: input.checked }) }).catch(() => null); await refreshModules(); });
2870
+ box.append(input, role === 'agent' ? '@MADRE IN THE ROOM' : role.toUpperCase());
2871
+ actions.append(box);
2872
+ } else {
2873
+ const pull = el('button', 'primary', `PULL ${model}`);
2874
+ pull.type = 'button';
2875
+ pull.title = `Download ${model} into Ollama for ${role}`;
2876
+ pull.addEventListener('click', async () => {
2877
+ pull.disabled = true;
2878
+ const response = await fetch('/api/ollama/pull', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ model }) });
2879
+ const result = await response.json().catch(() => ({}));
2880
+ if (!response.ok) { toast(`MU/TH/UR › ${result.error ?? 'could not pull'}`); pull.disabled = false; }
2881
+ else toast(`MU/TH/UR › pulling ${model}; progress shows in the room.`);
2882
+ });
2883
+ actions.append(pull);
2884
+ }
2885
+ }
2886
+ } else {
2887
+ card.append(el('p', 'confirm', 'Start Ollama (the app, or `ollama serve` in a terminal), then RECHECK. Without it the room keeps using its providers.'));
2888
+ }
2889
+ card.append(actions);
2890
+ return card;
2891
+ }
2816
2892
  if (item.id === 'ripley') {
2817
2893
  const actions = el('div', 'actions');
2818
2894
  const toggle = el('button', on ? null : 'primary', on ? 'DISABLE RIPLEY' : 'ENABLE RIPLEY');
@@ -3385,6 +3461,72 @@ function renderSettings() {
3385
3461
  save.disabled = false;
3386
3462
  });
3387
3463
  section.append(form);
3464
+
3465
+ // MEMORY: who distils, with whom, how often, where it embeds, how much recall a turn gets. Saves as you change it.
3466
+ const mem = data.settings.memory;
3467
+ if (mem) {
3468
+ section.append(el('h3', null, `MEMORY · ${mem.stats ? `${mem.stats.entries} EXCHANGES · ${mem.stats.memories} MEMORIES · ${mem.stats.pending} WAITING` : 'NO INDEX'}`));
3469
+ section.append(el('p', 'note', 'THE ARCHIVIST READS WHAT NOBODY HAS DISTILLED AND KEEPS THE FEW NOTES WORTH REMEMBERING. THE CHEAPEST ALLOWED AGENT GOES FIRST; A LOCAL MODEL COSTS NOTHING AND KEEPS EVERYTHING ON THIS MACHINE.'));
3470
+ const mform = el('form', 'room-form memory-form');
3471
+ const save = async (memoryPatch, describe) => { try { await saveSettingNow({ memory: memoryPatch }, describe); await loadSettings(); } catch (error) { toast(`Memory setting was not saved: ${error.message}`); } };
3472
+ const field = (labelText, node) => { const label = el('label'); label.append(labelText); label.append(node); return label; };
3473
+ const archivist = el('select');
3474
+ for (const [value, text] of [['auto', 'AUTO · cheapest allowed'], ...mem.candidates.map((c) => [c.id, `@${c.id}${c.local ? ` · ${c.label}` : ''}`])]) { const option = el('option', null, text); option.value = value; if (value === mem.archivist) option.selected = true; archivist.append(option); }
3475
+ archivist.addEventListener('change', () => save({ archivist: archivist.value }, archivist.value === 'auto' ? 'archivist: the cheapest allowed agent goes first.' : `archivist: @${archivist.value} distils first.`));
3476
+ mform.append(field('ARCHIVIST', archivist));
3477
+ const every = el('input'); every.type = 'number'; every.min = '1'; every.step = '1'; every.value = String(mem.every);
3478
+ wireInstantNumber(every, { min: 1, toPatch: (value) => ({ memory: { every: value } }), describe: (value) => `distil every ${value} exchange${value === 1 ? '' : 's'}.` });
3479
+ mform.append(field('DISTIL EVERY · EXCHANGES', every));
3480
+ const idle = el('input'); idle.type = 'number'; idle.min = '1'; idle.step = '1'; idle.value = String(mem.idleMinutes);
3481
+ wireInstantNumber(idle, { min: 1, toPatch: (value) => ({ memory: { idleMinutes: value } }), describe: (value) => `or after ${value} quiet minute${value === 1 ? '' : 's'}.` });
3482
+ mform.append(field('OR AFTER · QUIET MINUTES', idle));
3483
+ const share = el('input'); share.type = 'number'; share.min = '0'; share.max = '60'; share.step = '5'; share.value = String(Math.round(mem.recallShare * 100));
3484
+ wireInstantNumber(share, { min: 0, toPatch: (value) => ({ memory: { recallShare: Math.min(60, value) / 100 } }), describe: (value) => `recall may take ${Math.min(60, value)}% of each turn's context.` });
3485
+ mform.append(field('RECALL · % OF CONTEXT', share));
3486
+ const embed = el('select');
3487
+ const embedOptions = [['auto', 'AUTO · Ollama if running, else Gemini'], ['ollama', `OLLAMA · local${mem.ollama.embedModel ? ` · ${mem.ollama.embedModel}` : ' · no model yet'}`], ['gemini', 'GEMINI · needs your key'], ['off', 'OFF · words only']];
3488
+ for (const [value, text] of embedOptions) { const option = el('option', null, text); option.value = value; if (value === (mem.embedProvider ?? data.config?.memory?.embedProvider ?? 'auto')) option.selected = true; embed.append(option); }
3489
+ embed.addEventListener('change', () => save({ embedProvider: embed.value }, `embeddings: ${embed.options[embed.selectedIndex].textContent.toLowerCase()}.`));
3490
+ const embedLabel = field(`EMBEDDINGS · NOW ${mem.embedder ? mem.embedder.toUpperCase() : 'OFF'}`, embed);
3491
+ mform.append(embedLabel);
3492
+ const who = el('div', 'full');
3493
+ who.append(el('span', 'note', 'MAY DISTIL:'));
3494
+ const allowed = new Set(mem.archivists ?? mem.candidates.map((c) => c.id));
3495
+ for (const candidate of mem.candidates) {
3496
+ const toggle = el('label', 'toggle');
3497
+ const box = el('input'); box.type = 'checkbox'; box.checked = allowed.has(candidate.id);
3498
+ box.addEventListener('change', () => {
3499
+ if (box.checked) allowed.add(candidate.id); else allowed.delete(candidate.id);
3500
+ if (!allowed.size) { box.checked = true; allowed.add(candidate.id); toast('MU/TH/UR › someone has to keep the archive.'); return; }
3501
+ void save({ archivists: allowed.size === mem.candidates.length ? [] : [...allowed] }, `archivists: ${[...allowed].map((id) => `@${id}`).join(', ')}.`);
3502
+ });
3503
+ toggle.append(box, `@${candidate.id.toUpperCase()}${candidate.local ? ' · LOCAL · FREE' : ''}`);
3504
+ who.append(toggle);
3505
+ }
3506
+ mform.append(who);
3507
+ // The dataset behind MADRE AI: export what the room kept, train outside, @madre picks the result up.
3508
+ const dataset = el('div', 'full dataset-row');
3509
+ const exportButton = el('button', null, 'EXPORT DATASET');
3510
+ exportButton.type = 'button';
3511
+ exportButton.title = 'Write train.jsonl and valid.jsonl next to the ledger, redacted, in chat format for mlx-lm';
3512
+ const datasetNote = el('span', 'note', 'LOADING…');
3513
+ const showDataset = (payload) => {
3514
+ const d = payload?.dataset;
3515
+ datasetNote.textContent = d ? `${d.pairs} PAIRS · ${d.turns} TURNS · ${d.notes} NOTES · TRAIN ${d.train} · VALID ${d.valid} · ${new Date(d.exportedAt).toLocaleString()}${payload.trained ? ` · TRAINED MODEL ${payload.trained.toUpperCase()} IN USE` : ' · NO TRAINED MODEL YET · SEE docs/training'}` : 'NOT EXPORTED YET · PRESS EXPORT, THEN TRAIN WITH docs/training';
3516
+ };
3517
+ fetch('/api/dataset').then((response) => response.json()).then(showDataset).catch(() => { datasetNote.textContent = 'DATASET UNAVAILABLE'; });
3518
+ exportButton.addEventListener('click', async () => {
3519
+ exportButton.disabled = true;
3520
+ try { const payload = await fetch('/api/dataset', { method: 'POST' }).then((response) => response.json()); showDataset(payload); toast(`MU/TH/UR › dataset exported: ${payload.dataset.pairs} pairs in ${payload.dir}`); }
3521
+ catch (error) { toast(`Dataset export failed: ${error.message}`); }
3522
+ finally { exportButton.disabled = false; }
3523
+ });
3524
+ dataset.append(exportButton, datasetNote);
3525
+ mform.append(dataset);
3526
+ 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
+ mform.addEventListener('submit', (event) => event.preventDefault());
3528
+ section.append(mform);
3529
+ }
3388
3530
  }
3389
3531
 
3390
3532
  // Save one setting the moment it changes, the way the scope boxes do; the
package/public/brands.js CHANGED
@@ -47,6 +47,24 @@ export const BRANDS = {
47
47
  },
48
48
  };
49
49
 
50
+ BRANDS.madre = {
51
+ label: 'MADRE',
52
+ vendor: 'the room · local',
53
+ color: '#9bff66',
54
+ colorLight: '#2f8a1c',
55
+ ink: '#0a1405',
56
+ mark: () => glyph('<path d="M4 16 H11 L13 16 L15 9 L18 23 L20 16 L22 16 L23.5 13 L25 16 H28" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>'),
57
+ };
58
+
59
+ BRANDS.ollama = {
60
+ label: 'Ollama',
61
+ vendor: 'local',
62
+ color: '#E6E6E6',
63
+ colorLight: '#3A3A3A',
64
+ ink: '#111111',
65
+ mark: () => glyph('<circle cx="16" cy="16" r="9" fill="none" stroke="currentColor" stroke-width="2"/><circle cx="12.5" cy="15" r="1.6" fill="currentColor"/><circle cx="19.5" cy="15" r="1.6" fill="currentColor"/>'),
66
+ };
67
+
50
68
  export const fallbackBrand = (id) => ({
51
69
  label: id,
52
70
  vendor: '',
package/public/styles.css CHANGED
@@ -381,6 +381,7 @@ textarea::placeholder { color: var(--text-3); }
381
381
  .field button[type="submit"] svg { width: 18px; height: 18px; }
382
382
  .field button[type="submit"]:hover { filter: brightness(1.06); }
383
383
  .field button[type="submit"]:disabled { opacity: .4; cursor: default; box-shadow: none; }
384
+ .pick-label .pick-note { font-family: var(--mono); font-size: 9px; letter-spacing: .14em; text-transform: uppercase; color: var(--text-3); margin-left: 4px; white-space: nowrap; }
384
385
  .safety { margin: 14px auto 18px; color: var(--text-3); font-family: var(--mono); font-size: 9.5px; letter-spacing: .08em; text-transform: uppercase; text-align: center; }
385
386
 
386
387
  /* toast */
@@ -1029,4 +1030,12 @@ dialog.viewer::backdrop { background: rgba(0,0,0,.7); backdrop-filter: blur(4px)
1029
1030
  .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; }
1030
1031
  .mother-section.sentinel button:hover { background: var(--ph-faint); }
1031
1032
  .mother-section.sentinel .sentinel-report.sent { opacity: .65; }
1033
+ .settings .memory-form .toggle { display: inline-flex; align-items: center; gap: 6px; margin-right: 14px; font-size: 9.5px; letter-spacing: .14em; color: var(--ph-dim); cursor: pointer; text-transform: uppercase; }
1034
+ .settings .memory-form .toggle input { accent-color: #9bff66; width: auto; }
1035
+ .settings .memory-form .full .note { margin-right: 12px; }
1036
+ .settings .memory-form .dataset-row { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; padding-top: 8px; border-top: 1px solid var(--ph-faint); }
1037
+ .module-card .ollama-status { margin: 0 0 10px; display: grid; grid-template-columns: auto 1fr; gap: 3px 12px; font-size: 10px; letter-spacing: .1em; }
1038
+ .module-card .ollama-status dt { color: var(--ph-dim); } .module-card .ollama-status dd { margin: 0; color: rgba(210, 255, 190, .85); overflow-wrap: anywhere; }
1039
+ .module-card .actions .toggle { display: inline-flex; align-items: center; gap: 6px; font-size: 9.5px; letter-spacing: .14em; color: var(--ph-dim); cursor: pointer; }
1040
+ .module-card .actions .toggle input { accent-color: #9bff66; }
1032
1041
  .mother-section.sentinel .note { margin: 0 0 10px; font-size: 10px; letter-spacing: .12em; line-height: 1.7; color: var(--ph-dim); }
@@ -420,6 +420,28 @@ export const CONDITIONS = [
420
420
  remedy: 'Enable RIPLEY in MODULES. If a page looks broken in PREVIEW it is usually because it fetches something from the internet or another server, which the frame forbids by design: open it with OPEN RAW in a normal tab if you trust it.',
421
421
  fixes: same(['# MODULES → RIPLEY → ENABLE RIPLEY', '# viewer → PREVIEW / SOURCE']),
422
422
  },
423
+ {
424
+ id: 'ollama',
425
+ severity: 'informational',
426
+ title: 'Ollama: local embeddings and a local archivist',
427
+ match: /ollama|11434|local model|nomic-embed|qwen|llama3/i,
428
+ diagnosis: 'When Ollama runs on this machine with an embedding model and a chat model, MADRE embeds the memory locally (recall by meaning without any key) and distils memories with the local model first, before Gemini and the others. Nothing leaves the machine for remembering. Off or absent, everything falls back to the providers. MODULES shows what Ollama has, lets you pull the recommended models, and switches each role.',
429
+ remedy: 'Install Ollama (ollama.com), start it, then in MODULES press RECHECK and PULL the models it suggests. Set PULSE_OLLAMA_MODEL or PULSE_OLLAMA_EMBED_MODEL to prefer others; PULSE_EMBED_PROVIDER=gemini keeps embeddings on Gemini even with Ollama running.',
430
+ fixes: {
431
+ darwin: ['brew install ollama', 'ollama serve', 'ollama pull nomic-embed-text', 'ollama pull qwen2.5:3b'],
432
+ linux: ['curl -fsSL https://ollama.com/install.sh | sh', 'ollama serve', 'ollama pull nomic-embed-text', 'ollama pull qwen2.5:3b'],
433
+ win32: ['winget install Ollama.Ollama', 'ollama serve', 'ollama pull nomic-embed-text', 'ollama pull qwen2.5:3b'],
434
+ },
435
+ },
436
+ {
437
+ id: 'madre-agent',
438
+ severity: 'informational',
439
+ title: '@madre: the room\'s own memory, as an agent',
440
+ match: /@madre|madre-local|needs Ollama running with a chat model/i,
441
+ diagnosis: 'When Ollama runs with a chat model, a fifth agent joins the room: @madre. It answers from the whole archive (distilled notes and exact quotes, cited as [#n]) and from what the message carries, locally, for free. It never writes, draws, browses or delegates; when the room never discussed something it says so. Other agents may delegate a step to it to check what was decided. It leaves the room when Ollama stops, and MU/TH/UR says so.',
442
+ remedy: 'Start Ollama and PULL a chat model in MODULES → OLLAMA; the switch @MADRE IN THE ROOM turns the agent off if you do not want it. Ask it "what did we decide about …" or "did we ever discuss …".',
443
+ fixes: same(['ollama serve', 'ollama pull qwen2.5:3b', '@madre what did we decide about the webhook?']),
444
+ },
423
445
  {
424
446
  id: 'control-changes',
425
447
  severity: 'informational',